Many improvements and stability fixes
Some checks failed
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
Some checks failed
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
This commit is contained in:
parent
f0aa005d8c
commit
f96200c799
@ -6,5 +6,6 @@ UI presentation logic separately from business logic.
|
||||
"""
|
||||
|
||||
from .page_setup_dialog import PageSetupDialog
|
||||
from .print_settings_dialog import PrintSettingsDialog
|
||||
|
||||
__all__ = ["PageSetupDialog"]
|
||||
__all__ = ["PageSetupDialog", "PrintSettingsDialog"]
|
||||
|
||||
@ -18,6 +18,8 @@ from PyQt6.QtWidgets import (
|
||||
QGroupBox,
|
||||
QComboBox,
|
||||
QCheckBox,
|
||||
QRadioButton,
|
||||
QButtonGroup,
|
||||
)
|
||||
from pyPhotoAlbum.project import Project
|
||||
|
||||
@ -166,10 +168,23 @@ class PageSetupDialog(QDialog):
|
||||
height_layout.addWidget(self.height_spinbox)
|
||||
layout.addLayout(height_layout)
|
||||
|
||||
# Set as default checkbox
|
||||
self.set_default_checkbox = QCheckBox("Set as default for new pages")
|
||||
self.set_default_checkbox.setToolTip("Update project default page size for future pages")
|
||||
layout.addWidget(self.set_default_checkbox)
|
||||
# Apply scope radio buttons
|
||||
scope_label = QLabel("Apply to:")
|
||||
layout.addWidget(scope_label)
|
||||
|
||||
self._apply_scope_group = QButtonGroup(self)
|
||||
self.scope_page_only = QRadioButton("This page only")
|
||||
self.scope_non_manual = QRadioButton("All non-manual pages")
|
||||
self.scope_all_pages = QRadioButton("All pages (override manual sizing)")
|
||||
self.scope_page_only.setChecked(True)
|
||||
|
||||
self._apply_scope_group.addButton(self.scope_page_only, 0)
|
||||
self._apply_scope_group.addButton(self.scope_non_manual, 1)
|
||||
self._apply_scope_group.addButton(self.scope_all_pages, 2)
|
||||
|
||||
layout.addWidget(self.scope_page_only)
|
||||
layout.addWidget(self.scope_non_manual)
|
||||
layout.addWidget(self.scope_all_pages)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
@ -274,7 +289,10 @@ class PageSetupDialog(QDialog):
|
||||
is_cover = selected_page.is_cover
|
||||
self.width_spinbox.setEnabled(not is_cover)
|
||||
self.height_spinbox.setEnabled(not is_cover)
|
||||
self.set_default_checkbox.setEnabled(not is_cover)
|
||||
self.scope_non_manual.setEnabled(not is_cover)
|
||||
self.scope_all_pages.setEnabled(not is_cover)
|
||||
if is_cover:
|
||||
self.scope_page_only.setChecked(True)
|
||||
|
||||
def _update_spine_info(self):
|
||||
"""Update the spine information display."""
|
||||
@ -318,5 +336,5 @@ class PageSetupDialog(QDialog):
|
||||
"height_mm": self.height_spinbox.value(),
|
||||
"working_dpi": self.working_dpi_spinbox.value(),
|
||||
"export_dpi": self.export_dpi_spinbox.value(),
|
||||
"set_as_default": self.set_default_checkbox.isChecked(),
|
||||
"apply_scope": self._apply_scope_group.checkedId(),
|
||||
}
|
||||
|
||||
90
pyPhotoAlbum/dialogs/print_settings_dialog.py
Normal file
90
pyPhotoAlbum/dialogs/print_settings_dialog.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""
|
||||
Print Settings Dialog for pyPhotoAlbum
|
||||
|
||||
Project-level bleed and safe-area configuration applied uniformly to all pages.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QDoubleSpinBox,
|
||||
QPushButton,
|
||||
QGroupBox,
|
||||
)
|
||||
from pyPhotoAlbum.project import Project
|
||||
|
||||
|
||||
class PrintSettingsDialog(QDialog):
|
||||
"""Dialog for configuring project-wide print settings (bleed and safe area)."""
|
||||
|
||||
def __init__(self, parent, project: Project):
|
||||
super().__init__(parent)
|
||||
self.project = project
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle("Print Settings")
|
||||
self.setMinimumWidth(340)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
group = QGroupBox("Bleed && Safe Area (applied to all pages)")
|
||||
group_layout = QVBoxLayout()
|
||||
|
||||
# Bleed
|
||||
bleed_layout = QHBoxLayout()
|
||||
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||
self.bleed_spinbox = QDoubleSpinBox()
|
||||
self.bleed_spinbox.setRange(0.0, 20.0)
|
||||
self.bleed_spinbox.setSingleStep(0.5)
|
||||
self.bleed_spinbox.setDecimals(1)
|
||||
self.bleed_spinbox.setSuffix(" mm")
|
||||
self.bleed_spinbox.setValue(self.project.page_bleed_mm)
|
||||
self.bleed_spinbox.setToolTip(
|
||||
"Extra white space added around each page in the exported PDF.\n"
|
||||
"The printer cuts here — 3 mm is standard."
|
||||
)
|
||||
bleed_layout.addWidget(self.bleed_spinbox)
|
||||
group_layout.addLayout(bleed_layout)
|
||||
|
||||
# Safe area
|
||||
safe_layout = QHBoxLayout()
|
||||
safe_layout.addWidget(QLabel("Safe Area:"))
|
||||
self.safe_spinbox = QDoubleSpinBox()
|
||||
self.safe_spinbox.setRange(0.0, 50.0)
|
||||
self.safe_spinbox.setSingleStep(0.5)
|
||||
self.safe_spinbox.setDecimals(1)
|
||||
self.safe_spinbox.setSuffix(" mm")
|
||||
self.safe_spinbox.setValue(self.project.page_safe_area_mm)
|
||||
self.safe_spinbox.setToolTip(
|
||||
"Keep text and important content inside this distance from the cut/trim line.\n"
|
||||
"Shown as a red guide in the editor."
|
||||
)
|
||||
safe_layout.addWidget(self.safe_spinbox)
|
||||
group_layout.addLayout(safe_layout)
|
||||
|
||||
group.setLayout(group_layout)
|
||||
layout.addWidget(group)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
ok_btn.setDefault(True)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
btn_layout.addWidget(ok_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"page_bleed_mm": self.bleed_spinbox.value(),
|
||||
"page_safe_area_mm": self.safe_spinbox.value(),
|
||||
}
|
||||
@ -387,14 +387,14 @@ class MainWindow(
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Save:
|
||||
# Trigger save
|
||||
self.save_project()
|
||||
|
||||
# Check if save was successful (project should be clean now)
|
||||
if self.project.is_dirty():
|
||||
# User cancelled save dialog or save failed
|
||||
event.ignore()
|
||||
return
|
||||
# Save is async — ignore event and let on_complete trigger close
|
||||
self._pending_close = True
|
||||
save_started = self.save_project()
|
||||
if not save_started:
|
||||
# User cancelled the file dialog
|
||||
self._pending_close = False
|
||||
event.ignore()
|
||||
return
|
||||
elif reply == QMessageBox.StandardButton.Cancel:
|
||||
# User cancelled exit
|
||||
event.ignore()
|
||||
|
||||
@ -5,6 +5,7 @@ File operations mixin for pyPhotoAlbum
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QDialog,
|
||||
@ -32,6 +33,16 @@ from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSI
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
|
||||
class _SaveBridge(QObject):
|
||||
"""Thread-safe signal bridge for async save callbacks.
|
||||
|
||||
Signals can be safely emitted from any thread; connected slots run on
|
||||
the main (GUI) thread via Qt's queued connection.
|
||||
"""
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(bool, str)
|
||||
|
||||
|
||||
class FileOperationsMixin:
|
||||
"""Mixin providing file-related operations"""
|
||||
|
||||
@ -274,8 +285,16 @@ class FileOperationsMixin:
|
||||
print(error_msg)
|
||||
|
||||
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
|
||||
def save_project(self):
|
||||
"""Save the current project asynchronously with progress feedback"""
|
||||
def save_project(self) -> bool:
|
||||
"""Save the current project asynchronously with progress feedback.
|
||||
|
||||
Returns True if an async save was started, False if the user cancelled.
|
||||
"""
|
||||
# Prevent concurrent saves — only one background save at a time
|
||||
if getattr(self, "_save_in_progress", False):
|
||||
self.show_status("Save already in progress...")
|
||||
return False
|
||||
|
||||
# If project has a file path, use it; otherwise prompt for location
|
||||
file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None
|
||||
|
||||
@ -284,50 +303,68 @@ class FileOperationsMixin:
|
||||
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
print(f"Saving project to: {file_path}")
|
||||
if not file_path:
|
||||
return False
|
||||
|
||||
# Create loading widget if not exists
|
||||
if not hasattr(self, "_loading_widget"):
|
||||
self._loading_widget = LoadingWidget(self)
|
||||
self._save_in_progress = True
|
||||
print(f"Saving project to: {file_path}")
|
||||
|
||||
# Show loading widget
|
||||
self._loading_widget.show_loading("Saving project...")
|
||||
# Create loading widget if not exists
|
||||
if not hasattr(self, "_loading_widget"):
|
||||
self._loading_widget = LoadingWidget(self)
|
||||
|
||||
# Define callbacks for async save
|
||||
def on_progress(progress: int, message: str):
|
||||
"""Update progress display"""
|
||||
if hasattr(self, "_loading_widget"):
|
||||
# Show loading widget
|
||||
self._loading_widget.show_loading("Saving project...")
|
||||
|
||||
# Bridge object: signals are thread-safe so background thread can
|
||||
# emit them and slots always run on the main (GUI) thread.
|
||||
bridge = _SaveBridge(parent=self)
|
||||
|
||||
def _on_progress(progress: int, message: str):
|
||||
if hasattr(self, "_loading_widget"):
|
||||
try:
|
||||
self._loading_widget.set_progress(progress, 100)
|
||||
self._loading_widget.set_status(message)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def on_complete(success: bool, error: str):
|
||||
"""Handle save completion"""
|
||||
# Hide loading widget
|
||||
def _on_finished(success: bool, error: str):
|
||||
self._save_in_progress = False
|
||||
try:
|
||||
if hasattr(self, "_loading_widget"):
|
||||
self._loading_widget.hide_loading()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if success:
|
||||
self.project.file_path = file_path
|
||||
self.project.mark_clean()
|
||||
self.show_status(f"Project saved: {file_path}")
|
||||
print(f"Successfully saved project to: {file_path}")
|
||||
else:
|
||||
error_msg = f"Failed to save project: {error}"
|
||||
self.show_status(error_msg)
|
||||
self.show_error("Save Failed", error_msg)
|
||||
print(error_msg)
|
||||
if success:
|
||||
self.project.file_path = file_path
|
||||
self.project.mark_clean()
|
||||
self.show_status(f"Project saved: {file_path}")
|
||||
print(f"Successfully saved project to: {file_path}")
|
||||
if getattr(self, "_pending_close", False):
|
||||
self._pending_close = False
|
||||
self.close()
|
||||
else:
|
||||
self._pending_close = False
|
||||
error_msg = f"Failed to save project: {error}"
|
||||
self.show_status(error_msg)
|
||||
self.show_error("Save Failed", error_msg)
|
||||
print(error_msg)
|
||||
|
||||
# Start async save
|
||||
save_to_zip_async(
|
||||
self.project,
|
||||
file_path,
|
||||
on_complete=on_complete,
|
||||
on_progress=on_progress
|
||||
)
|
||||
bridge.progress.connect(_on_progress)
|
||||
bridge.finished.connect(_on_finished)
|
||||
|
||||
# Show immediate feedback
|
||||
self.show_status("Saving project in background...", 2000)
|
||||
# Start async save — callbacks emit signals (thread-safe)
|
||||
save_to_zip_async(
|
||||
self.project,
|
||||
file_path,
|
||||
on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""),
|
||||
on_progress=lambda p, m: bridge.progress.emit(p, m),
|
||||
)
|
||||
|
||||
# Show immediate feedback
|
||||
self.show_status("Saving project in background...", 2000)
|
||||
return True
|
||||
|
||||
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
|
||||
def heal_assets(self):
|
||||
@ -456,16 +493,24 @@ class FileOperationsMixin:
|
||||
scaling_group = None
|
||||
scaling_buttons = None
|
||||
|
||||
scope_buttons = None
|
||||
if self.project.pages:
|
||||
scaling_group = QGroupBox("Apply to Existing Pages")
|
||||
scaling_layout = QVBoxLayout()
|
||||
|
||||
info_label = QLabel(
|
||||
"How should existing content be adjusted?\n(Pages with manual sizing will not be affected)"
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
scaling_layout.addWidget(info_label)
|
||||
# Scope: which pages to update
|
||||
scaling_layout.addWidget(QLabel("Pages to update:"))
|
||||
scope_buttons = QButtonGroup()
|
||||
scope_non_manual = QRadioButton("Non-manual pages only")
|
||||
scope_non_manual.setChecked(True)
|
||||
scope_all = QRadioButton("All pages (override manual sizing)")
|
||||
scope_buttons.addButton(scope_non_manual, 0)
|
||||
scope_buttons.addButton(scope_all, 1)
|
||||
scaling_layout.addWidget(scope_non_manual)
|
||||
scaling_layout.addWidget(scope_all)
|
||||
|
||||
# Content scaling
|
||||
scaling_layout.addWidget(QLabel("Content adjustment:"))
|
||||
scaling_buttons = QButtonGroup()
|
||||
|
||||
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||
@ -515,12 +560,15 @@ class FileOperationsMixin:
|
||||
new_working_dpi = working_dpi_spinbox.value()
|
||||
new_export_dpi = export_dpi_spinbox.value()
|
||||
|
||||
# Determine scaling mode
|
||||
# Determine scaling mode and scope
|
||||
scaling_mode = "none"
|
||||
include_manual = False
|
||||
if scaling_buttons:
|
||||
selected_id = scaling_buttons.checkedId()
|
||||
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
||||
scaling_mode = modes.get(selected_id, "none")
|
||||
if scope_buttons:
|
||||
include_manual = scope_buttons.checkedId() == 1
|
||||
|
||||
# Apply settings
|
||||
old_size = self.project.page_size_mm
|
||||
@ -528,22 +576,23 @@ class FileOperationsMixin:
|
||||
self.project.working_dpi = new_working_dpi
|
||||
self.project.export_dpi = new_export_dpi
|
||||
|
||||
# Update existing pages (exclude manually sized ones)
|
||||
# Update existing pages
|
||||
if self.project.pages and old_size != (new_width, new_height):
|
||||
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode)
|
||||
self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual)
|
||||
|
||||
self.update_view()
|
||||
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
||||
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
|
||||
|
||||
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
|
||||
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False):
|
||||
"""
|
||||
Apply new page size to all non-manually-sized pages
|
||||
Apply new page size to existing pages.
|
||||
|
||||
Args:
|
||||
old_size: Old page size (width, height) in mm
|
||||
new_size: New page size (width, height) in mm
|
||||
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
||||
include_manual: If True, also resize manually-sized pages
|
||||
"""
|
||||
old_width, old_height = old_size
|
||||
new_width, new_height = new_size
|
||||
@ -552,8 +601,9 @@ class FileOperationsMixin:
|
||||
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
||||
|
||||
for page in self.project.pages:
|
||||
# Skip manually sized pages
|
||||
if page.manually_sized:
|
||||
if page.is_cover:
|
||||
continue
|
||||
if page.manually_sized and not include_manual:
|
||||
continue
|
||||
|
||||
# Update page size
|
||||
@ -634,7 +684,7 @@ class FileOperationsMixin:
|
||||
file_path += ".pdf"
|
||||
|
||||
# Use async PDF export (non-blocking, UI stays responsive)
|
||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
|
||||
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi)
|
||||
if success:
|
||||
self.show_status("PDF export started...", 2000)
|
||||
else:
|
||||
|
||||
@ -148,11 +148,26 @@ class PageOperationsMixin:
|
||||
self.project.working_dpi = values["working_dpi"]
|
||||
self.project.export_dpi = values["export_dpi"]
|
||||
|
||||
# Set as default if checkbox is checked
|
||||
if values["set_as_default"]:
|
||||
|
||||
# Apply to other pages based on scope
|
||||
# 0 = page only, 1 = non-manual pages, 2 = all pages
|
||||
apply_scope = values.get("apply_scope", 0)
|
||||
if apply_scope in (1, 2):
|
||||
self.project.page_size_mm = (width_mm, height_mm)
|
||||
print(f"Project default page size set to {width_mm}×{height_mm} mm")
|
||||
|
||||
for page in self.project.pages:
|
||||
if page is selected_page or page.is_cover:
|
||||
continue
|
||||
if apply_scope == 1 and page.manually_sized:
|
||||
continue
|
||||
if page.is_double_spread:
|
||||
page.layout.base_width = width_mm
|
||||
page.layout.size = (width_mm * 2, height_mm)
|
||||
else:
|
||||
page.layout.size = (width_mm, height_mm)
|
||||
page.layout.base_width = width_mm
|
||||
|
||||
self.update_view()
|
||||
|
||||
# Build status message
|
||||
@ -161,7 +176,7 @@ class PageOperationsMixin:
|
||||
status_msg = f"{page_name} updated"
|
||||
else:
|
||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
||||
if values["set_as_default"]:
|
||||
if values.get("apply_scope", 0) in (1, 2):
|
||||
status_msg += " (set as default)"
|
||||
self.show_status(status_msg, 2000)
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ View operations mixin for pyPhotoAlbum
|
||||
"""
|
||||
|
||||
from pyPhotoAlbum.decorators import ribbon_action
|
||||
from pyPhotoAlbum.dialogs import PrintSettingsDialog
|
||||
|
||||
|
||||
class ViewOperationsMixin:
|
||||
@ -118,6 +119,34 @@ class ViewOperationsMixin:
|
||||
self.show_status(f"Grid {status}", 2000)
|
||||
print(f"Grid {status}")
|
||||
|
||||
@ribbon_action(label="Print Settings...", tooltip="Configure bleed and safe area for all pages", tab="View", group="Guides")
|
||||
def open_print_settings(self):
|
||||
"""Open the print settings dialog (bleed and safe area)"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
dialog = PrintSettingsDialog(self, self.project)
|
||||
if dialog.exec():
|
||||
values = dialog.get_values()
|
||||
self.project.page_bleed_mm = values["page_bleed_mm"]
|
||||
self.project.page_safe_area_mm = values["page_safe_area_mm"]
|
||||
self.update_view()
|
||||
self.show_status(
|
||||
f"Bleed: {values['page_bleed_mm']:.1f}mm, Safe area: {values['page_safe_area_mm']:.1f}mm", 2000
|
||||
)
|
||||
|
||||
@ribbon_action(label="Print Guides", tooltip="Toggle bleed/cut/safe-area guide lines in the editor", tab="View", group="Guides")
|
||||
def toggle_print_guides(self):
|
||||
"""Toggle print guide lines (bleed/cut/safe area)"""
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
self.project.show_print_guides = not self.project.show_print_guides
|
||||
|
||||
status = "visible" if self.project.show_print_guides else "hidden"
|
||||
self.update_view()
|
||||
self.show_status(f"Print guides {status}", 2000)
|
||||
|
||||
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
|
||||
def toggle_snap_lines(self):
|
||||
"""Toggle guide lines visibility"""
|
||||
|
||||
@ -93,6 +93,10 @@ class RenderingMixin:
|
||||
page.layout._parent_widget = self
|
||||
page.layout.render(dpi=dpi, project=project)
|
||||
renderer.end_render()
|
||||
|
||||
# Draw bleed/cut/safe-area guides for this page
|
||||
self._draw_page_print_guides(renderer, project)
|
||||
|
||||
pages_rendered += 1
|
||||
|
||||
elif page_type == "ghost":
|
||||
@ -326,3 +330,77 @@ class RenderingMixin:
|
||||
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
def _draw_page_print_guides(self, renderer, project):
|
||||
"""
|
||||
Draw bleed/cut/safe-area guide lines around a page using OpenGL.
|
||||
|
||||
- Green dashed rectangle: bleed boundary (extend backgrounds to here)
|
||||
- Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0)
|
||||
- Red rectangle: safe area (keep text and logos inside)
|
||||
|
||||
Lines are only drawn when the corresponding project settings are non-zero.
|
||||
"""
|
||||
if not getattr(project, "show_print_guides", False):
|
||||
return
|
||||
|
||||
bleed_mm = getattr(project, "page_bleed_mm", 0.0)
|
||||
safe_mm = getattr(project, "page_safe_area_mm", 0.0)
|
||||
|
||||
if bleed_mm <= 0.0 and safe_mm <= 0.0:
|
||||
return
|
||||
|
||||
dpi = project.working_dpi
|
||||
zoom = renderer.zoom
|
||||
sx = renderer.screen_x
|
||||
sy = renderer.screen_y
|
||||
sw = renderer.screen_width
|
||||
sh = renderer.screen_height
|
||||
|
||||
# Convert mm to screen pixels
|
||||
bleed_screen = bleed_mm * dpi / 25.4 * zoom
|
||||
safe_screen = safe_mm * dpi / 25.4 * zoom
|
||||
|
||||
glLineWidth(1.0)
|
||||
|
||||
def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False):
|
||||
glColor3f(r, g, b)
|
||||
if dashed:
|
||||
glEnable(GL_LINE_STIPPLE)
|
||||
glLineStipple(1, 0x00FF)
|
||||
else:
|
||||
glDisable(GL_LINE_STIPPLE)
|
||||
glBegin(GL_LINE_LOOP)
|
||||
glVertex2f(x, y)
|
||||
glVertex2f(x + w, y)
|
||||
glVertex2f(x + w, y + h)
|
||||
glVertex2f(x, y + h)
|
||||
glEnd()
|
||||
glDisable(GL_LINE_STIPPLE)
|
||||
|
||||
# Bleed boundary – green dashed rectangle outside the page
|
||||
if bleed_screen > 0:
|
||||
_draw_rect_outline(
|
||||
sx - bleed_screen,
|
||||
sy - bleed_screen,
|
||||
sw + 2 * bleed_screen,
|
||||
sh + 2 * bleed_screen,
|
||||
0.0, 0.67, 0.0,
|
||||
dashed=True,
|
||||
)
|
||||
|
||||
# Cut/trim line – magenta rectangle at the page edge (only meaningful when bleed > 0)
|
||||
if bleed_screen > 0:
|
||||
_draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8)
|
||||
|
||||
# Safe area – red rectangle inside the page
|
||||
if safe_screen > 0:
|
||||
_draw_rect_outline(
|
||||
sx + safe_screen,
|
||||
sy + safe_screen,
|
||||
sw - 2 * safe_screen,
|
||||
sh - 2 * safe_screen,
|
||||
0.8, 0.0, 0.0,
|
||||
)
|
||||
|
||||
glColor3f(1.0, 1.0, 1.0) # Reset colour
|
||||
|
||||
@ -58,8 +58,7 @@ class PageLayout:
|
||||
|
||||
def remove_element(self, element: BaseLayoutElement):
|
||||
"""Remove a layout element from the page"""
|
||||
if element in self.elements:
|
||||
self.elements.remove(element)
|
||||
self.elements.remove(element)
|
||||
|
||||
def set_grid_layout(self, grid: "GridLayout"):
|
||||
"""Set a grid layout for the page"""
|
||||
|
||||
@ -122,9 +122,15 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
|
||||
if task.corner_radius > 0:
|
||||
cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
|
||||
|
||||
# Serialize image to bytes (PNG for lossless with alpha)
|
||||
# Serialize image: JPEG for photos (much smaller), PNG only when alpha is needed
|
||||
buffer = io.BytesIO()
|
||||
cropped_img.save(buffer, format="PNG", optimize=False)
|
||||
has_transparency = cropped_img.mode == "RGBA" and task.corner_radius > 0
|
||||
if has_transparency:
|
||||
cropped_img.save(buffer, format="PNG", optimize=False)
|
||||
else:
|
||||
# Convert to RGB for JPEG (drop alpha channel if present but unused)
|
||||
rgb_img = cropped_img.convert("RGB")
|
||||
rgb_img.save(buffer, format="JPEG", quality=92, optimize=True)
|
||||
return (task.task_id, buffer.getvalue(), None)
|
||||
|
||||
except Exception as e:
|
||||
@ -216,10 +222,18 @@ class PDFExporter:
|
||||
# Get page dimensions from project (in mm)
|
||||
page_width_mm, page_height_mm = self.project.page_size_mm
|
||||
|
||||
# Convert to PDF points
|
||||
# Bleed expands each page on all sides
|
||||
bleed_mm = self.project.page_bleed_mm
|
||||
bleed_pt = bleed_mm * self.MM_TO_POINTS
|
||||
|
||||
# Convert to PDF points (base page = cut/trim size)
|
||||
page_width_pt = page_width_mm * self.MM_TO_POINTS
|
||||
page_height_pt = page_height_mm * self.MM_TO_POINTS
|
||||
|
||||
# Expanded page size includes bleed on all sides
|
||||
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
||||
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
||||
|
||||
# Phase 1: Collect all image tasks and process in parallel
|
||||
if progress_callback:
|
||||
progress_callback(0, total_pages, "Collecting images for processing...")
|
||||
@ -232,7 +246,7 @@ class PDFExporter:
|
||||
self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
|
||||
|
||||
# Phase 2: Build PDF using pre-processed images
|
||||
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
|
||||
c = canvas.Canvas(output_path, pagesize=(expanded_width_pt, expanded_height_pt))
|
||||
|
||||
pages_processed = 0
|
||||
for page in self.project.pages:
|
||||
@ -251,10 +265,10 @@ class PDFExporter:
|
||||
if progress_callback:
|
||||
progress_callback(pages_processed, total_pages, "Inserting blank page for alignment...")
|
||||
|
||||
self._export_spread(c, page, page_width_pt, page_height_pt)
|
||||
self._export_spread(c, page, page_width_pt, page_height_pt, bleed_pt)
|
||||
pages_processed += 2
|
||||
else:
|
||||
self._export_single_page(c, page, page_width_pt, page_height_pt)
|
||||
self._export_single_page(c, page, page_width_pt, page_height_pt, bleed_pt)
|
||||
pages_processed += 1
|
||||
|
||||
c.save()
|
||||
@ -514,8 +528,6 @@ class PDFExporter:
|
||||
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||
self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
|
||||
|
||||
# Draw guide lines for front/spine/back zones
|
||||
self._draw_cover_guides(c, cover_width_pt, cover_height_pt)
|
||||
|
||||
c.showPage() # Finish cover page
|
||||
self.current_pdf_page += 1
|
||||
@ -523,51 +535,24 @@ class PDFExporter:
|
||||
# Reset page size for content pages
|
||||
c.setPageSize((page_width_pt, page_height_pt))
|
||||
|
||||
def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float):
|
||||
"""Draw guide lines for cover zones (front/spine/back)"""
|
||||
from reportlab.lib.colors import lightgrey
|
||||
|
||||
# Calculate zone boundaries
|
||||
bleed_pt = self.project.cover_bleed_mm * self.MM_TO_POINTS
|
||||
page_width_pt = self.project.page_size_mm[0] * self.MM_TO_POINTS
|
||||
spine_width_pt = self.project.calculate_spine_width() * self.MM_TO_POINTS
|
||||
|
||||
# Zone boundaries (from left to right)
|
||||
# Bleed | Back | Spine | Front | Bleed
|
||||
back_start = bleed_pt
|
||||
spine_start = bleed_pt + page_width_pt
|
||||
front_start = bleed_pt + page_width_pt + spine_width_pt
|
||||
front_end = bleed_pt + page_width_pt + spine_width_pt + page_width_pt
|
||||
|
||||
# Draw dashed lines at zone boundaries
|
||||
c.saveState()
|
||||
c.setStrokeColor(lightgrey)
|
||||
c.setDash(3, 3)
|
||||
c.setLineWidth(0.5)
|
||||
|
||||
# Back/Spine boundary
|
||||
c.line(spine_start, 0, spine_start, cover_height_pt)
|
||||
|
||||
# Spine/Front boundary
|
||||
c.line(front_start, 0, front_start, cover_height_pt)
|
||||
|
||||
# Bleed boundaries (outer edges)
|
||||
if bleed_pt > 0:
|
||||
c.line(back_start, 0, back_start, cover_height_pt)
|
||||
c.line(front_end, 0, front_end, cover_height_pt)
|
||||
|
||||
c.restoreState()
|
||||
|
||||
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
|
||||
def _export_single_page(
|
||||
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
|
||||
):
|
||||
"""Export a single page to PDF"""
|
||||
# Render all elements
|
||||
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
||||
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
||||
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
||||
|
||||
# Render all elements, offset by bleed
|
||||
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number)
|
||||
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
|
||||
|
||||
c.showPage() # Finish this page
|
||||
self.current_pdf_page += 1
|
||||
|
||||
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
|
||||
def _export_spread(
|
||||
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
|
||||
):
|
||||
"""Export a double-page spread as two PDF pages"""
|
||||
# Get center line position in mm
|
||||
page_width_mm = self.project.page_size_mm[0]
|
||||
@ -580,7 +565,11 @@ class PDFExporter:
|
||||
# Calculate threshold for tiny elements (1:500) in pixels
|
||||
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
|
||||
|
||||
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
||||
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
||||
|
||||
# Process elements for left page
|
||||
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
||||
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||
element_x_px, element_y_px = element.position
|
||||
element_width_px, element_height_px = element.size
|
||||
@ -588,7 +577,7 @@ class PDFExporter:
|
||||
# Check if element is on left page, right page, or spanning (compare in pixels)
|
||||
if element_x_px + element_width_px <= center_px + threshold_px:
|
||||
# Entirely on left page
|
||||
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number)
|
||||
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
|
||||
elif element_x_px >= center_px - threshold_px:
|
||||
# Skip for now, will render on right page
|
||||
pass
|
||||
@ -604,12 +593,13 @@ class PDFExporter:
|
||||
page_number=page.page_number,
|
||||
side="left",
|
||||
)
|
||||
self._render_split_element(params)
|
||||
self._render_split_element(params, bleed_pt)
|
||||
|
||||
c.showPage() # Finish left page
|
||||
self.current_pdf_page += 1
|
||||
|
||||
# Process elements for right page
|
||||
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
||||
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||
element_x_px, element_y_px = element.position
|
||||
element_width_px, element_height_px = element.size
|
||||
@ -617,7 +607,9 @@ class PDFExporter:
|
||||
# Check if element is on right page or spanning (compare in pixels)
|
||||
if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
|
||||
# Entirely on right page or mostly on right
|
||||
self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1)
|
||||
self._render_element(
|
||||
c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1, bleed_pt
|
||||
)
|
||||
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
|
||||
# Spanning element - render right portion
|
||||
params = SplitRenderParams(
|
||||
@ -630,7 +622,7 @@ class PDFExporter:
|
||||
page_number=page.page_number + 1,
|
||||
side="right",
|
||||
)
|
||||
self._render_split_element(params)
|
||||
self._render_split_element(params, bleed_pt)
|
||||
|
||||
c.showPage() # Finish right page
|
||||
self.current_pdf_page += 1
|
||||
@ -643,6 +635,7 @@ class PDFExporter:
|
||||
page_width_pt: float,
|
||||
page_height_pt: float,
|
||||
page_number: Union[int, str],
|
||||
bleed_pt: float = 0.0,
|
||||
):
|
||||
"""
|
||||
Render a single element on the PDF canvas.
|
||||
@ -651,9 +644,10 @@ class PDFExporter:
|
||||
c: ReportLab canvas
|
||||
element: The layout element to render
|
||||
x_offset_mm: X offset in mm (for right page of spread)
|
||||
page_width_pt: Page width in points
|
||||
page_height_pt: Page height in points
|
||||
page_width_pt: Page width in points (cut/trim size, excluding bleed)
|
||||
page_height_pt: Page height in points (cut/trim size, excluding bleed)
|
||||
page_number: Current page number (for error messages)
|
||||
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
|
||||
"""
|
||||
# Skip placeholders
|
||||
if isinstance(element, PlaceholderData):
|
||||
@ -673,9 +667,10 @@ class PDFExporter:
|
||||
# Adjust x position for offset (now in mm)
|
||||
adjusted_x_mm = element_x_mm - x_offset_mm
|
||||
|
||||
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left)
|
||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
||||
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left).
|
||||
# bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
|
||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
|
||||
y_pt = page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||
width_pt = element_width_mm * self.MM_TO_POINTS
|
||||
height_pt = element_height_mm * self.MM_TO_POINTS
|
||||
|
||||
@ -693,12 +688,13 @@ class PDFExporter:
|
||||
elif isinstance(element, TextBoxData):
|
||||
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
|
||||
|
||||
def _render_split_element(self, params: SplitRenderParams):
|
||||
def _render_split_element(self, params: SplitRenderParams, bleed_pt: float = 0.0):
|
||||
"""
|
||||
Render a split element (only the portion on one side of the split line).
|
||||
|
||||
Args:
|
||||
params: SplitRenderParams containing all rendering parameters
|
||||
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
|
||||
"""
|
||||
# Skip placeholders
|
||||
if isinstance(params.element, PlaceholderData):
|
||||
@ -731,9 +727,9 @@ class PDFExporter:
|
||||
# Adjust render position for offset
|
||||
adjusted_x_mm = render_x_mm - params.x_offset_mm
|
||||
|
||||
# Convert to points
|
||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
||||
y_pt = params.page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||
# Convert to points (bleed_pt shifts content inside the expanded PDF page)
|
||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
|
||||
y_pt = params.page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
||||
width_pt = crop_width_mm * self.MM_TO_POINTS
|
||||
height_pt = element_height_mm * self.MM_TO_POINTS
|
||||
|
||||
@ -771,6 +767,7 @@ class PDFExporter:
|
||||
params.page_width_pt,
|
||||
params.page_height_pt,
|
||||
params.page_number,
|
||||
bleed_pt,
|
||||
)
|
||||
|
||||
def _render_image(self, ctx: RenderContext):
|
||||
|
||||
@ -166,6 +166,11 @@ class Project:
|
||||
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
|
||||
self.binding_type = "saddle_stitch" # Binding type for spine calculation
|
||||
|
||||
# Print guide configuration
|
||||
self.page_bleed_mm = 0.0 # Bleed margin for interior pages (default 0mm, e.g. 3mm for printing)
|
||||
self.page_safe_area_mm = 5.0 # Safe area margin inside cut line (default 5mm)
|
||||
self.show_print_guides = False # Show bleed/cut/safe-area guides in the editor
|
||||
|
||||
# Embedded templates - templates that travel with the project
|
||||
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@ -404,6 +409,9 @@ class Project:
|
||||
"paper_thickness_mm": self.paper_thickness_mm,
|
||||
"cover_bleed_mm": self.cover_bleed_mm,
|
||||
"binding_type": self.binding_type,
|
||||
"page_bleed_mm": self.page_bleed_mm,
|
||||
"page_safe_area_mm": self.page_safe_area_mm,
|
||||
"show_print_guides": self.show_print_guides,
|
||||
"embedded_templates": self.embedded_templates,
|
||||
"snap_to_grid": self.snap_to_grid,
|
||||
"snap_to_edges": self.snap_to_edges,
|
||||
@ -436,6 +444,9 @@ class Project:
|
||||
self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
|
||||
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
|
||||
self.binding_type = data.get("binding_type", "saddle_stitch")
|
||||
self.page_bleed_mm = data.get("page_bleed_mm", 0.0)
|
||||
self.page_safe_area_mm = data.get("page_safe_area_mm", 5.0)
|
||||
self.show_print_guides = data.get("show_print_guides", False)
|
||||
|
||||
# Deserialize embedded templates
|
||||
self.embedded_templates = data.get("embedded_templates", {})
|
||||
|
||||
@ -197,76 +197,63 @@ def save_to_zip_async(
|
||||
Returns:
|
||||
The background thread (already started)
|
||||
"""
|
||||
# Ensure .ppz extension
|
||||
final_zip_path = zip_path
|
||||
if not final_zip_path.lower().endswith(".ppz"):
|
||||
final_zip_path += ".ppz"
|
||||
|
||||
# ---- Work done on the CALLING (main) thread ----
|
||||
# Serialization is pure Python and holds the GIL, but it's fast.
|
||||
# Doing it here keeps the background thread to file-I/O only, which
|
||||
# releases the GIL and keeps the UI responsive.
|
||||
if on_progress:
|
||||
on_progress(0, "Preparing to save...")
|
||||
|
||||
_import_external_images(project)
|
||||
|
||||
if on_progress:
|
||||
on_progress(10, "Serializing project data...")
|
||||
project_data = project.serialize()
|
||||
project_data["serialization_version"] = SERIALIZATION_VERSION
|
||||
project_data["data_version"] = CURRENT_DATA_VERSION
|
||||
project_json_str = json.dumps(project_data, indent=2, sort_keys=True)
|
||||
|
||||
# Collect the asset file list now so the background thread doesn't
|
||||
# need to touch the (potentially temporary) project folder.
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
folder_path = project.folder_path
|
||||
asset_files: list[tuple[str, str]] = []
|
||||
if os.path.exists(assets_folder):
|
||||
for root, _dirs, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, folder_path)
|
||||
asset_files.append((file_path, arcname))
|
||||
|
||||
total_files = 1 + len(asset_files) # project.json + assets
|
||||
|
||||
if on_progress:
|
||||
on_progress(20, f"Starting background write ({total_files} files)...")
|
||||
|
||||
# ---- Work done on the BACKGROUND thread ----
|
||||
# Only file I/O here — zipfile/zlib/shutil all release the GIL.
|
||||
def _background_save():
|
||||
"""Background thread function to create the ZIP file."""
|
||||
"""Background thread: write ZIP file from pre-serialized data."""
|
||||
temp_dir = None
|
||||
try:
|
||||
# Report progress: Starting
|
||||
if on_progress:
|
||||
on_progress(0, "Preparing to save...")
|
||||
|
||||
# Ensure .ppz extension
|
||||
final_zip_path = zip_path
|
||||
if not final_zip_path.lower().endswith(".ppz"):
|
||||
final_zip_path += ".ppz"
|
||||
|
||||
# Check for and import any external images before saving
|
||||
if on_progress:
|
||||
on_progress(5, "Checking for external images...")
|
||||
_import_external_images(project)
|
||||
|
||||
# Serialize project to dictionary
|
||||
if on_progress:
|
||||
on_progress(10, "Serializing project data...")
|
||||
project_data = project.serialize()
|
||||
|
||||
# Add version information
|
||||
project_data["serialization_version"] = SERIALIZATION_VERSION
|
||||
project_data["data_version"] = CURRENT_DATA_VERSION
|
||||
|
||||
# Create a temporary directory for staging
|
||||
if on_progress:
|
||||
on_progress(15, "Creating temporary staging area...")
|
||||
temp_dir = tempfile.mkdtemp(prefix="pyPhotoAlbum_save_")
|
||||
|
||||
# Write project.json to temp directory
|
||||
if on_progress:
|
||||
on_progress(20, "Writing project metadata...")
|
||||
temp_project_json = os.path.join(temp_dir, "project.json")
|
||||
with open(temp_project_json, "w") as f:
|
||||
json.dump(project_data, f, indent=2, sort_keys=True)
|
||||
|
||||
# Create temp ZIP file (not final location - for atomic write)
|
||||
temp_zip_path = os.path.join(temp_dir, "project.ppz")
|
||||
|
||||
# Count assets for progress reporting
|
||||
assets_folder = project.asset_manager.assets_folder
|
||||
total_files = 1 # project.json
|
||||
asset_files = []
|
||||
if os.path.exists(assets_folder):
|
||||
for root, dirs, files in os.walk(assets_folder):
|
||||
for file in files:
|
||||
asset_files.append((root, file))
|
||||
total_files += 1
|
||||
|
||||
if on_progress:
|
||||
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
|
||||
|
||||
# Create ZIP file in temp location
|
||||
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Write project.json
|
||||
zipf.write(temp_project_json, "project.json")
|
||||
zipf.writestr("project.json", project_json_str)
|
||||
|
||||
# Add all assets with progress reporting
|
||||
if asset_files:
|
||||
# Progress from 25% to 90% for assets
|
||||
progress_range = 90 - 25
|
||||
for idx, (root, file) in enumerate(asset_files):
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, project.folder_path)
|
||||
for idx, (file_path, arcname) in enumerate(asset_files):
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# Report progress every 10 files or at end
|
||||
if idx % 10 == 0 or idx == len(asset_files) - 1:
|
||||
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
|
||||
if on_progress:
|
||||
@ -275,18 +262,12 @@ def save_to_zip_async(
|
||||
f"Adding assets... ({idx + 1}/{len(asset_files)})"
|
||||
)
|
||||
|
||||
# Atomic move: move temp ZIP to final location
|
||||
if on_progress:
|
||||
on_progress(95, "Finalizing save...")
|
||||
|
||||
# Ensure parent directory exists
|
||||
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
|
||||
|
||||
# Remove old file if it exists
|
||||
if os.path.exists(final_zip_path):
|
||||
os.remove(final_zip_path)
|
||||
|
||||
# Move temp ZIP to final location (atomic on same filesystem)
|
||||
shutil.move(temp_zip_path, final_zip_path)
|
||||
|
||||
if on_progress:
|
||||
@ -294,30 +275,24 @@ def save_to_zip_async(
|
||||
|
||||
print(f"Project saved to {final_zip_path}")
|
||||
|
||||
# Call completion callback with success
|
||||
if on_complete:
|
||||
on_complete(True, None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error saving project: {str(e)}"
|
||||
print(error_msg)
|
||||
|
||||
# Call completion callback with error
|
||||
if on_complete:
|
||||
on_complete(False, error_msg)
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
pass
|
||||
|
||||
# Start background thread
|
||||
save_thread = threading.Thread(target=_background_save, daemon=True)
|
||||
save_thread.start()
|
||||
|
||||
return save_thread
|
||||
|
||||
|
||||
|
||||
@ -809,13 +809,14 @@ class TestExportPdf:
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.project.export_dpi = 150
|
||||
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
||||
window.gl_widget.export_pdf_async.return_value = True
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Verify export was called
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
||||
# Verify export was called with project's export_dpi, not a hardcoded value
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=150)
|
||||
assert "PDF export started" in window._status_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
@ -833,8 +834,10 @@ class TestExportPdf:
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Verify .pdf was added
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
||||
# Verify .pdf was added and project's export_dpi is used
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(
|
||||
window.project, "/path/to/output.pdf", export_dpi=window.project.export_dpi
|
||||
)
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
|
||||
|
||||
@ -75,7 +75,7 @@ class TestFrameDefinition:
|
||||
frame_type=FrameType.FULL,
|
||||
)
|
||||
assert frame.description == ""
|
||||
assert frame.assets == {}
|
||||
assert frame.asset_path is None
|
||||
assert frame.colorizable is True
|
||||
assert frame.default_thickness == 5.0
|
||||
|
||||
@ -144,19 +144,20 @@ class TestFrameManager:
|
||||
assert isinstance(names, list)
|
||||
assert "simple_line" in names
|
||||
assert "double_line" in names
|
||||
assert "leafy_corners" in names
|
||||
assert "geometric_corners" in names
|
||||
|
||||
def test_bundled_frames_exist(self, frame_manager):
|
||||
"""Test that expected bundled frames exist"""
|
||||
expected_frames = [
|
||||
"simple_line",
|
||||
"double_line",
|
||||
"rounded_modern",
|
||||
"geometric_corners",
|
||||
"leafy_corners",
|
||||
"ornate_flourish",
|
||||
"victorian",
|
||||
"art_nouveau",
|
||||
"floral_corner",
|
||||
"floral_flourish",
|
||||
"ornate_corner",
|
||||
"simple_corner",
|
||||
"corner_decoration",
|
||||
"corner_ornament",
|
||||
]
|
||||
for name in expected_frames:
|
||||
frame = frame_manager.get_frame(name)
|
||||
@ -164,15 +165,15 @@ class TestFrameManager:
|
||||
|
||||
def test_modern_frames_are_full_type(self, frame_manager):
|
||||
"""Test that modern frames are FULL type"""
|
||||
modern_frames = ["simple_line", "double_line", "rounded_modern"]
|
||||
modern_frames = ["simple_line", "double_line"]
|
||||
for name in modern_frames:
|
||||
frame = frame_manager.get_frame(name)
|
||||
assert frame is not None
|
||||
assert frame.frame_type == FrameType.FULL
|
||||
|
||||
def test_leafy_corners_is_corners_type(self, frame_manager):
|
||||
"""Test that leafy_corners is CORNERS type"""
|
||||
frame = frame_manager.get_frame("leafy_corners")
|
||||
def test_geometric_corners_is_corners_type(self, frame_manager):
|
||||
"""Test that geometric_corners is CORNERS type"""
|
||||
frame = frame_manager.get_frame("geometric_corners")
|
||||
assert frame is not None
|
||||
assert frame.frame_type == FrameType.CORNERS
|
||||
|
||||
@ -207,7 +208,7 @@ class TestFrameCategories:
|
||||
def test_modern_category_not_empty(self, frame_manager):
|
||||
"""Test MODERN category has frames"""
|
||||
frames = frame_manager.get_frames_by_category(FrameCategory.MODERN)
|
||||
assert len(frames) >= 3 # simple_line, double_line, rounded_modern
|
||||
assert len(frames) >= 2 # simple_line, double_line
|
||||
|
||||
def test_vintage_category_not_empty(self, frame_manager):
|
||||
"""Test VINTAGE category has frames"""
|
||||
@ -238,9 +239,9 @@ class TestFrameDescriptions:
|
||||
frame = frame_manager.get_frame("simple_line")
|
||||
assert frame.description != ""
|
||||
|
||||
def test_leafy_corners_has_description(self, frame_manager):
|
||||
"""Test leafy_corners has a description"""
|
||||
frame = frame_manager.get_frame("leafy_corners")
|
||||
def test_floral_corner_has_description(self, frame_manager):
|
||||
"""Test floral_corner has a description"""
|
||||
frame = frame_manager.get_frame("floral_corner")
|
||||
assert frame.description != ""
|
||||
|
||||
def test_all_frames_have_descriptions(self, frame_manager):
|
||||
@ -268,11 +269,11 @@ class TestFrameThickness:
|
||||
|
||||
def test_vintage_frames_are_thicker(self, frame_manager):
|
||||
"""Test vintage frames have thicker default"""
|
||||
leafy = frame_manager.get_frame("leafy_corners")
|
||||
victorian = frame_manager.get_frame("victorian")
|
||||
floral = frame_manager.get_frame("floral_corner")
|
||||
ornate = frame_manager.get_frame("ornate_corner")
|
||||
|
||||
assert leafy.default_thickness >= 8.0
|
||||
assert victorian.default_thickness >= 10.0
|
||||
assert floral.default_thickness >= 8.0
|
||||
assert ornate.default_thickness >= 8.0
|
||||
|
||||
def test_all_thicknesses_positive(self, frame_manager):
|
||||
"""Test all frames have positive thickness"""
|
||||
|
||||
@ -110,7 +110,8 @@ class TestPageSetupDialog:
|
||||
# Size editing should be disabled for covers
|
||||
assert not dialog.width_spinbox.isEnabled()
|
||||
assert not dialog.height_spinbox.isEnabled()
|
||||
assert not dialog.set_default_checkbox.isEnabled()
|
||||
assert not dialog.scope_non_manual.isEnabled()
|
||||
assert not dialog.scope_all_pages.isEnabled()
|
||||
|
||||
def test_dialog_double_spread_width_calculation(self, qtbot):
|
||||
"""Test double spread shows per-page width, not total width"""
|
||||
@ -182,7 +183,7 @@ class TestPageSetupDialog:
|
||||
dialog.height_spinbox.setValue(280)
|
||||
dialog.working_dpi_spinbox.setValue(150)
|
||||
dialog.export_dpi_spinbox.setValue(600)
|
||||
dialog.set_default_checkbox.setChecked(True)
|
||||
dialog.scope_all_pages.setChecked(True)
|
||||
dialog.cover_checkbox.setChecked(True)
|
||||
dialog.thickness_spinbox.setValue(0.15)
|
||||
dialog.bleed_spinbox.setValue(5.0)
|
||||
@ -199,7 +200,7 @@ class TestPageSetupDialog:
|
||||
assert values["height_mm"] == 280
|
||||
assert values["working_dpi"] == 150
|
||||
assert values["export_dpi"] == 600
|
||||
assert values["set_as_default"] is True
|
||||
assert values["apply_scope"] == 2
|
||||
|
||||
def test_dialog_page_change_updates_values(self, qtbot):
|
||||
"""Test changing selected page updates displayed values"""
|
||||
@ -552,7 +553,7 @@ class TestPageSetupIntegration:
|
||||
"height_mm": 280,
|
||||
"working_dpi": 150,
|
||||
"export_dpi": 600,
|
||||
"set_as_default": True,
|
||||
"apply_scope": 1,
|
||||
}
|
||||
|
||||
# Access the unwrapped function to test business logic directly
|
||||
@ -586,7 +587,7 @@ class TestPageSetupIntegration:
|
||||
assert window.project.cover_bleed_mm == 5.0
|
||||
assert window.project.working_dpi == 150
|
||||
assert window.project.export_dpi == 600
|
||||
assert window.project.page_size_mm == (200, 280) # set_as_default=True
|
||||
assert window.project.page_size_mm == (200, 280) # apply_scope=1
|
||||
|
||||
# Check page size updated
|
||||
assert window.project.pages[0].layout.size == (200, 280)
|
||||
@ -644,7 +645,7 @@ class TestPageSetupIntegration:
|
||||
"height_mm": 297,
|
||||
"working_dpi": 96,
|
||||
"export_dpi": 300,
|
||||
"set_as_default": False,
|
||||
"apply_scope": 0,
|
||||
}
|
||||
|
||||
# Get the undecorated method
|
||||
@ -716,7 +717,7 @@ class TestPageSetupIntegration:
|
||||
"height_mm": 280, # New height
|
||||
"working_dpi": 96,
|
||||
"export_dpi": 300,
|
||||
"set_as_default": False,
|
||||
"apply_scope": 0,
|
||||
}
|
||||
|
||||
from pyPhotoAlbum.mixins.operations import page_ops
|
||||
@ -731,3 +732,147 @@ class TestPageSetupIntegration:
|
||||
assert window.project.pages[0].layout.base_width == 200
|
||||
assert window.project.pages[0].layout.size == (400, 280) # Double width
|
||||
assert window.project.pages[0].manually_sized is True
|
||||
|
||||
def test_set_as_default_updates_double_spread_pages(self, qtbot):
|
||||
"""Test set_as_default updates existing double spread pages with doubled width"""
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||
|
||||
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._project = Project(name="Test")
|
||||
self._project.page_size_mm = (210, 297)
|
||||
self._project.working_dpi = 96
|
||||
self._project.export_dpi = 300
|
||||
self._project.paper_thickness_mm = 0.1
|
||||
self._project.cover_bleed_mm = 3.0
|
||||
|
||||
# Normal page being edited
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
# Double spread page (not manually sized - should be updated)
|
||||
page2 = Page(layout=PageLayout(width=420, height=297), page_number=2)
|
||||
page2.is_double_spread = True
|
||||
page2.layout.base_width = 210
|
||||
page2.layout.is_facing_page = True
|
||||
# Manually sized page (should NOT be updated)
|
||||
page3 = Page(layout=PageLayout(width=150, height=200), page_number=3)
|
||||
page3.manually_sized = True
|
||||
self._project.pages = [page1, page2, page3]
|
||||
|
||||
self._gl_widget = Mock()
|
||||
self._gl_widget._page_renderers = []
|
||||
self._status_bar = Mock()
|
||||
self._update_view_called = False
|
||||
|
||||
def _get_most_visible_page_index(self):
|
||||
return 0
|
||||
|
||||
def update_view(self):
|
||||
self._update_view_called = True
|
||||
|
||||
def show_status(self, message, timeout=0):
|
||||
pass
|
||||
|
||||
window = TestWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
values = {
|
||||
"selected_index": 0,
|
||||
"selected_page": window.project.pages[0],
|
||||
"is_cover": False,
|
||||
"paper_thickness_mm": 0.1,
|
||||
"cover_bleed_mm": 3.0,
|
||||
"width_mm": 200,
|
||||
"height_mm": 280,
|
||||
"working_dpi": 96,
|
||||
"export_dpi": 300,
|
||||
"apply_scope": 1,
|
||||
}
|
||||
|
||||
from pyPhotoAlbum.mixins.operations import page_ops
|
||||
|
||||
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
|
||||
while hasattr(undecorated_page_setup, "__wrapped__"):
|
||||
undecorated_page_setup = undecorated_page_setup.__wrapped__
|
||||
|
||||
undecorated_page_setup(window, values)
|
||||
|
||||
# Project default updated
|
||||
assert window.project.page_size_mm == (200, 280)
|
||||
# Double spread page updated with doubled width
|
||||
assert window.project.pages[1].layout.base_width == 200
|
||||
assert window.project.pages[1].layout.size == (400, 280)
|
||||
# Manually sized page NOT updated
|
||||
assert window.project.pages[2].layout.size == (150, 200)
|
||||
|
||||
def test_apply_scope_all_pages_overrides_manual_sizing(self, qtbot):
|
||||
"""Test apply_scope=2 updates all pages including manually sized ones"""
|
||||
from PyQt6.QtWidgets import QMainWindow
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||
|
||||
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._project = Project(name="Test")
|
||||
self._project.page_size_mm = (210, 297)
|
||||
self._project.working_dpi = 96
|
||||
self._project.export_dpi = 300
|
||||
self._project.paper_thickness_mm = 0.1
|
||||
self._project.cover_bleed_mm = 3.0
|
||||
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=420, height=297), page_number=2)
|
||||
page2.is_double_spread = True
|
||||
page2.layout.base_width = 210
|
||||
page2.layout.is_facing_page = True
|
||||
page3 = Page(layout=PageLayout(width=150, height=200), page_number=3)
|
||||
page3.manually_sized = True
|
||||
self._project.pages = [page1, page2, page3]
|
||||
|
||||
self._gl_widget = Mock()
|
||||
self._gl_widget._page_renderers = []
|
||||
self._status_bar = Mock()
|
||||
self._update_view_called = False
|
||||
|
||||
def _get_most_visible_page_index(self):
|
||||
return 0
|
||||
|
||||
def update_view(self):
|
||||
self._update_view_called = True
|
||||
|
||||
def show_status(self, message, timeout=0):
|
||||
pass
|
||||
|
||||
window = TestWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
values = {
|
||||
"selected_index": 0,
|
||||
"selected_page": window.project.pages[0],
|
||||
"is_cover": False,
|
||||
"paper_thickness_mm": 0.1,
|
||||
"cover_bleed_mm": 3.0,
|
||||
"width_mm": 200,
|
||||
"height_mm": 280,
|
||||
"working_dpi": 96,
|
||||
"export_dpi": 300,
|
||||
"apply_scope": 2,
|
||||
}
|
||||
|
||||
from pyPhotoAlbum.mixins.operations import page_ops
|
||||
|
||||
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
|
||||
while hasattr(undecorated_page_setup, "__wrapped__"):
|
||||
undecorated_page_setup = undecorated_page_setup.__wrapped__
|
||||
|
||||
undecorated_page_setup(window, values)
|
||||
|
||||
# Project default updated
|
||||
assert window.project.page_size_mm == (200, 280)
|
||||
# Double spread updated
|
||||
assert window.project.pages[1].layout.size == (400, 280)
|
||||
# Manually sized page IS updated when scope=2
|
||||
assert window.project.pages[2].layout.size == (200, 280)
|
||||
|
||||
@ -53,7 +53,9 @@ class TestPageSetupDialogWithMocks:
|
||||
dialog.cover_checkbox = Mock()
|
||||
dialog.width_spinbox = Mock()
|
||||
dialog.height_spinbox = Mock()
|
||||
dialog.set_default_checkbox = Mock()
|
||||
dialog.scope_non_manual = Mock()
|
||||
dialog.scope_all_pages = Mock()
|
||||
dialog.scope_page_only = Mock()
|
||||
|
||||
# Mock the update spine info method
|
||||
dialog._update_spine_info = Mock()
|
||||
@ -187,8 +189,8 @@ class TestPageSetupDialogWithMocks:
|
||||
dialog.export_dpi_spinbox = Mock()
|
||||
dialog.export_dpi_spinbox.value.return_value = 600
|
||||
|
||||
dialog.set_default_checkbox = Mock()
|
||||
dialog.set_default_checkbox.isChecked.return_value = True
|
||||
dialog._apply_scope_group = Mock()
|
||||
dialog._apply_scope_group.checkedId.return_value = 2
|
||||
|
||||
# Get values
|
||||
values = dialog.get_values()
|
||||
@ -203,7 +205,7 @@ class TestPageSetupDialogWithMocks:
|
||||
assert values["height_mm"] == 280.0
|
||||
assert values["working_dpi"] == 150
|
||||
assert values["export_dpi"] == 600
|
||||
assert values["set_as_default"] is True
|
||||
assert values["apply_scope"] == 2
|
||||
|
||||
def test_cover_page_width_display(self):
|
||||
"""Test cover page shows full width, not base width"""
|
||||
@ -224,7 +226,9 @@ class TestPageSetupDialogWithMocks:
|
||||
dialog.cover_checkbox = Mock()
|
||||
dialog.width_spinbox = Mock()
|
||||
dialog.height_spinbox = Mock()
|
||||
dialog.set_default_checkbox = Mock()
|
||||
dialog.scope_non_manual = Mock()
|
||||
dialog.scope_all_pages = Mock()
|
||||
dialog.scope_page_only = Mock()
|
||||
dialog._update_spine_info = Mock()
|
||||
|
||||
# Call _on_page_changed for cover page
|
||||
@ -238,7 +242,8 @@ class TestPageSetupDialogWithMocks:
|
||||
# Verify widgets were disabled for cover
|
||||
dialog.width_spinbox.setEnabled.assert_called_with(False)
|
||||
dialog.height_spinbox.setEnabled.assert_called_with(False)
|
||||
dialog.set_default_checkbox.setEnabled.assert_called_with(False)
|
||||
dialog.scope_non_manual.setEnabled.assert_called_with(False)
|
||||
dialog.scope_all_pages.setEnabled.assert_called_with(False)
|
||||
|
||||
# Note: Additional widget state tests are covered in test_page_setup_dialog.py
|
||||
# using qtbot which properly handles Qt widget initialization
|
||||
|
||||
@ -965,6 +965,222 @@ def test_pdf_exporter_image_downsampling():
|
||||
os.remove(img_path)
|
||||
|
||||
|
||||
def test_pdf_exporter_bleed_expands_page_size():
|
||||
"""Test that setting page_bleed_mm expands the PDF page dimensions"""
|
||||
import pdfplumber
|
||||
|
||||
MM_TO_POINTS = 2.834645669
|
||||
page_width_mm = 210.0
|
||||
page_height_mm = 297.0
|
||||
bleed_mm = 3.0
|
||||
|
||||
# Export WITHOUT bleed
|
||||
project_no_bleed = Project("No Bleed")
|
||||
project_no_bleed.page_size_mm = (page_width_mm, page_height_mm)
|
||||
project_no_bleed.page_bleed_mm = 0.0
|
||||
page_no_bleed = Page(page_number=1, is_double_spread=False)
|
||||
project_no_bleed.add_page(page_no_bleed)
|
||||
|
||||
# Export WITH bleed
|
||||
project_bleed = Project("With Bleed")
|
||||
project_bleed.page_size_mm = (page_width_mm, page_height_mm)
|
||||
project_bleed.page_bleed_mm = bleed_mm
|
||||
page_bleed = Page(page_number=1, is_double_spread=False)
|
||||
project_bleed.add_page(page_bleed)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f1:
|
||||
path_no_bleed = f1.name
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f2:
|
||||
path_bleed = f2.name
|
||||
|
||||
try:
|
||||
exporter_no_bleed = PDFExporter(project_no_bleed)
|
||||
success, _ = exporter_no_bleed.export(path_no_bleed)
|
||||
assert success
|
||||
|
||||
exporter_bleed = PDFExporter(project_bleed)
|
||||
success, _ = exporter_bleed.export(path_bleed)
|
||||
assert success
|
||||
|
||||
with pdfplumber.open(path_no_bleed) as pdf:
|
||||
w_no_bleed = pdf.pages[0].width
|
||||
h_no_bleed = pdf.pages[0].height
|
||||
|
||||
with pdfplumber.open(path_bleed) as pdf:
|
||||
w_bleed = pdf.pages[0].width
|
||||
h_bleed = pdf.pages[0].height
|
||||
|
||||
bleed_pt = bleed_mm * MM_TO_POINTS
|
||||
expected_w = page_width_mm * MM_TO_POINTS + 2 * bleed_pt
|
||||
expected_h = page_height_mm * MM_TO_POINTS + 2 * bleed_pt
|
||||
|
||||
assert abs(w_no_bleed - page_width_mm * MM_TO_POINTS) < 1.0, (
|
||||
f"No-bleed page width mismatch: {w_no_bleed:.2f} vs {page_width_mm * MM_TO_POINTS:.2f}"
|
||||
)
|
||||
assert abs(w_bleed - expected_w) < 1.0, (
|
||||
f"Bleed page width mismatch: {w_bleed:.2f} vs {expected_w:.2f}"
|
||||
)
|
||||
assert abs(h_bleed - expected_h) < 1.0, (
|
||||
f"Bleed page height mismatch: {h_bleed:.2f} vs {expected_h:.2f}"
|
||||
)
|
||||
assert w_bleed > w_no_bleed, "Bleed page should be wider than non-bleed page"
|
||||
assert h_bleed > h_no_bleed, "Bleed page should be taller than non-bleed page"
|
||||
|
||||
finally:
|
||||
if os.path.exists(path_no_bleed):
|
||||
os.remove(path_no_bleed)
|
||||
if os.path.exists(path_bleed):
|
||||
os.remove(path_bleed)
|
||||
|
||||
|
||||
def test_pdf_exporter_spread_bleed_expands_and_offsets():
|
||||
"""Test that double spread pages each get bleed on all 4 sides, same as single pages"""
|
||||
import pdfplumber
|
||||
from PIL import Image as PILImage
|
||||
|
||||
MM_TO_POINTS = 2.834645669
|
||||
page_width_mm = 210.0
|
||||
page_height_mm = 297.0
|
||||
bleed_mm = 3.0
|
||||
bleed_pt = bleed_mm * MM_TO_POINTS
|
||||
dpi = 96
|
||||
|
||||
img = PILImage.new("RGB", (200, 200), color="red")
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_f:
|
||||
img_path = img_f.name
|
||||
img.save(img_path)
|
||||
|
||||
try:
|
||||
project = Project("Spread Bleed")
|
||||
project.page_size_mm = (page_width_mm, page_height_mm)
|
||||
project.working_dpi = dpi
|
||||
project.page_bleed_mm = bleed_mm
|
||||
|
||||
spread = Page(page_number=1, is_double_spread=True)
|
||||
page_w_px = page_width_mm * dpi / 25.4
|
||||
page_h_px = page_height_mm * dpi / 25.4
|
||||
center_px = page_w_px
|
||||
|
||||
# Full-page element on left page (x=0 to center)
|
||||
left_img = ImageData(image_path=img_path, x=0, y=0, width=page_w_px, height=page_h_px)
|
||||
# Full-page element on right page (x=center to 2*center)
|
||||
right_img = ImageData(image_path=img_path, x=center_px, y=0, width=page_w_px, height=page_h_px)
|
||||
spread.layout.add_element(left_img)
|
||||
spread.layout.add_element(right_img)
|
||||
project.add_page(spread)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_f:
|
||||
pdf_path = pdf_f.name
|
||||
|
||||
try:
|
||||
exporter = PDFExporter(project)
|
||||
success, warnings = exporter.export(pdf_path)
|
||||
assert success, f"Export failed: {warnings}"
|
||||
|
||||
expected_page_w = page_width_mm * MM_TO_POINTS + 2 * bleed_pt
|
||||
expected_page_h = page_height_mm * MM_TO_POINTS + 2 * bleed_pt
|
||||
expected_img_x1 = page_width_mm * MM_TO_POINTS + bleed_pt
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
spread_pages = [pg for pg in pdf.pages if pg.images]
|
||||
assert len(spread_pages) == 2, f"Expected 2 pages with images, got {len(spread_pages)}"
|
||||
|
||||
for pg in spread_pages:
|
||||
# Page size includes bleed on all sides
|
||||
assert abs(pg.width - expected_page_w) < 1.0, (
|
||||
f"Page width {pg.width:.2f} != expected {expected_page_w:.2f}"
|
||||
)
|
||||
assert abs(pg.height - expected_page_h) < 1.0, (
|
||||
f"Page height {pg.height:.2f} != expected {expected_page_h:.2f}"
|
||||
)
|
||||
# Image starts at bleed_pt from left (3mm inner padding)
|
||||
img_obj = pg.images[0]
|
||||
assert abs(img_obj["x0"] - bleed_pt) < 1.0, (
|
||||
f"Image x0={img_obj['x0']:.2f} != bleed_pt={bleed_pt:.2f}"
|
||||
)
|
||||
# Image ends bleed_pt from right edge (3mm outer padding)
|
||||
assert abs(img_obj["x1"] - expected_img_x1) < 1.0, (
|
||||
f"Image x1={img_obj['x1']:.2f} != {expected_img_x1:.2f}"
|
||||
)
|
||||
|
||||
finally:
|
||||
if os.path.exists(pdf_path):
|
||||
os.remove(pdf_path)
|
||||
finally:
|
||||
if os.path.exists(img_path):
|
||||
os.remove(img_path)
|
||||
|
||||
|
||||
def test_pdf_exporter_bleed_offsets_content():
|
||||
"""Test that bleed shifts image content by bleed_pt so it appears at correct position"""
|
||||
import pdfplumber
|
||||
from PIL import Image as PILImage
|
||||
|
||||
MM_TO_POINTS = 2.834645669
|
||||
page_width_mm = 210.0
|
||||
page_height_mm = 297.0
|
||||
bleed_mm = 3.0
|
||||
bleed_pt = bleed_mm * MM_TO_POINTS
|
||||
|
||||
# Create a solid-color test image
|
||||
test_img = PILImage.new("RGB", (200, 200), color="red")
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_f:
|
||||
img_path = img_f.name
|
||||
test_img.save(img_path)
|
||||
|
||||
try:
|
||||
# Place image at page position (50mm, 50mm)
|
||||
element_x_mm = 50.0
|
||||
element_y_mm = 50.0
|
||||
element_w_mm = 40.0
|
||||
element_h_mm = 40.0
|
||||
dpi = 96
|
||||
element_x_px = element_x_mm * dpi / 25.4
|
||||
element_y_px = element_y_mm * dpi / 25.4
|
||||
element_w_px = element_w_mm * dpi / 25.4
|
||||
element_h_px = element_h_mm * dpi / 25.4
|
||||
|
||||
project = Project("Bleed Offset")
|
||||
project.page_size_mm = (page_width_mm, page_height_mm)
|
||||
project.working_dpi = dpi
|
||||
project.page_bleed_mm = bleed_mm
|
||||
|
||||
page = Page(page_number=1, is_double_spread=False)
|
||||
image = ImageData(image_path=img_path, x=element_x_px, y=element_y_px,
|
||||
width=element_w_px, height=element_h_px)
|
||||
page.layout.add_element(image)
|
||||
project.add_page(page)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_f:
|
||||
pdf_path = pdf_f.name
|
||||
|
||||
try:
|
||||
exporter = PDFExporter(project)
|
||||
success, warnings = exporter.export(pdf_path)
|
||||
assert success, f"Export failed: {warnings}"
|
||||
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
pg = pdf.pages[0]
|
||||
images = pg.images
|
||||
assert len(images) > 0, "No images found in PDF"
|
||||
|
||||
img_obj = images[0]
|
||||
# x0 should be element_x_pt + bleed_pt from left edge of expanded page
|
||||
expected_x0 = element_x_mm * MM_TO_POINTS + bleed_pt
|
||||
actual_x0 = img_obj["x0"]
|
||||
assert abs(actual_x0 - expected_x0) < 2.0, (
|
||||
f"Image X offset mismatch: expected {expected_x0:.2f}, got {actual_x0:.2f}"
|
||||
)
|
||||
|
||||
finally:
|
||||
if os.path.exists(pdf_path):
|
||||
os.remove(pdf_path)
|
||||
|
||||
finally:
|
||||
if os.path.exists(img_path):
|
||||
os.remove(img_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running PDF export tests...\n")
|
||||
|
||||
@ -984,6 +1200,8 @@ if __name__ == "__main__":
|
||||
test_pdf_exporter_varying_aspect_ratios()
|
||||
test_pdf_exporter_rotated_image()
|
||||
test_pdf_exporter_image_downsampling()
|
||||
test_pdf_exporter_bleed_expands_page_size()
|
||||
test_pdf_exporter_bleed_offsets_content()
|
||||
|
||||
print("\n✓ All tests passed!")
|
||||
|
||||
|
||||
@ -48,6 +48,52 @@ class TestProject:
|
||||
assert project.working_dpi == 300
|
||||
assert project.page_size_mm == (140, 140) # Default 14cm x 14cm square
|
||||
|
||||
def test_print_guide_defaults(self):
|
||||
"""Test that print guide settings have correct defaults"""
|
||||
project = Project()
|
||||
|
||||
assert project.page_bleed_mm == 0.0
|
||||
assert project.page_safe_area_mm == 5.0
|
||||
assert project.show_print_guides is False
|
||||
|
||||
def test_print_guide_serialization_roundtrip(self):
|
||||
"""Test that print guide settings are serialized and deserialized correctly"""
|
||||
project = Project()
|
||||
project.page_bleed_mm = 3.0
|
||||
project.page_safe_area_mm = 8.0
|
||||
project.show_print_guides = True
|
||||
|
||||
data = project.serialize()
|
||||
|
||||
assert data["page_bleed_mm"] == 3.0
|
||||
assert data["page_safe_area_mm"] == 8.0
|
||||
assert data["show_print_guides"] is True
|
||||
|
||||
# Deserialize into a new project
|
||||
restored = Project()
|
||||
restored.deserialize(data)
|
||||
|
||||
assert restored.page_bleed_mm == 3.0
|
||||
assert restored.page_safe_area_mm == 8.0
|
||||
assert restored.show_print_guides is True
|
||||
|
||||
def test_print_guide_deserialization_defaults(self):
|
||||
"""Test that missing print guide fields in old data fall back to defaults"""
|
||||
project = Project()
|
||||
data = project.serialize()
|
||||
|
||||
# Simulate old project file without these keys
|
||||
del data["page_bleed_mm"]
|
||||
del data["page_safe_area_mm"]
|
||||
del data["show_print_guides"]
|
||||
|
||||
restored = Project()
|
||||
restored.deserialize(data)
|
||||
|
||||
assert restored.page_bleed_mm == 0.0
|
||||
assert restored.page_safe_area_mm == 5.0
|
||||
assert restored.show_print_guides is False
|
||||
|
||||
def test_initialization_with_name(self):
|
||||
"""Test Project initialization with custom name"""
|
||||
project = Project(name="My Album")
|
||||
|
||||
@ -27,6 +27,7 @@ class TestViewWindow(ViewOperationsMixin, QMainWindow):
|
||||
self.project.snap_to_edges = True
|
||||
self.project.snap_to_guides = True
|
||||
self.project.show_snap_lines = True
|
||||
self.project.show_print_guides = False
|
||||
self.project.grid_size_mm = 10.0
|
||||
self.project.snap_threshold_mm = 5.0
|
||||
self._update_view_called = False
|
||||
@ -125,6 +126,44 @@ class TestZoomOperations:
|
||||
assert not window._update_view_called
|
||||
|
||||
|
||||
class TestPrintGuidesToggle:
|
||||
"""Test print guides toggle operation"""
|
||||
|
||||
def test_toggle_print_guides_show(self, qtbot):
|
||||
window = TestViewWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.show_print_guides = False
|
||||
|
||||
window.toggle_print_guides()
|
||||
|
||||
assert window.project.show_print_guides is True
|
||||
assert "visible" in window._status_message.lower()
|
||||
assert window._update_view_called
|
||||
|
||||
def test_toggle_print_guides_hide(self, qtbot):
|
||||
window = TestViewWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.show_print_guides = True
|
||||
|
||||
window.toggle_print_guides()
|
||||
|
||||
assert window.project.show_print_guides is False
|
||||
assert "hidden" in window._status_message.lower()
|
||||
assert window._update_view_called
|
||||
|
||||
def test_toggle_print_guides_no_project(self, qtbot):
|
||||
window = TestViewWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project = None
|
||||
|
||||
window.toggle_print_guides()
|
||||
|
||||
assert not window._update_view_called
|
||||
|
||||
|
||||
class TestSnappingToggles:
|
||||
"""Test snapping toggle operations"""
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user