diff --git a/pyPhotoAlbum/dialogs/__init__.py b/pyPhotoAlbum/dialogs/__init__.py index df46140..b71b311 100644 --- a/pyPhotoAlbum/dialogs/__init__.py +++ b/pyPhotoAlbum/dialogs/__init__.py @@ -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"] diff --git a/pyPhotoAlbum/dialogs/page_setup_dialog.py b/pyPhotoAlbum/dialogs/page_setup_dialog.py index 2244870..e2e9b07 100644 --- a/pyPhotoAlbum/dialogs/page_setup_dialog.py +++ b/pyPhotoAlbum/dialogs/page_setup_dialog.py @@ -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(), } diff --git a/pyPhotoAlbum/dialogs/print_settings_dialog.py b/pyPhotoAlbum/dialogs/print_settings_dialog.py new file mode 100644 index 0000000..2762b09 --- /dev/null +++ b/pyPhotoAlbum/dialogs/print_settings_dialog.py @@ -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(), + } diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 491fbfe..52b0ce5 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -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() diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 7bc96a8..ba5ce9c 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -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: diff --git a/pyPhotoAlbum/mixins/operations/page_ops.py b/pyPhotoAlbum/mixins/operations/page_ops.py index be0ad97..7d8f406 100644 --- a/pyPhotoAlbum/mixins/operations/page_ops.py +++ b/pyPhotoAlbum/mixins/operations/page_ops.py @@ -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) diff --git a/pyPhotoAlbum/mixins/operations/view_ops.py b/pyPhotoAlbum/mixins/operations/view_ops.py index b9aaf14..0c0c7eb 100644 --- a/pyPhotoAlbum/mixins/operations/view_ops.py +++ b/pyPhotoAlbum/mixins/operations/view_ops.py @@ -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""" diff --git a/pyPhotoAlbum/mixins/rendering.py b/pyPhotoAlbum/mixins/rendering.py index b6de704..e83dd71 100644 --- a/pyPhotoAlbum/mixins/rendering.py +++ b/pyPhotoAlbum/mixins/rendering.py @@ -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 diff --git a/pyPhotoAlbum/page_layout.py b/pyPhotoAlbum/page_layout.py index 9e11773..72f5800 100644 --- a/pyPhotoAlbum/page_layout.py +++ b/pyPhotoAlbum/page_layout.py @@ -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""" diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index b5ca275..cf247d7 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -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): diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index 9808cd3..4190542 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -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", {}) diff --git a/pyPhotoAlbum/project_serializer.py b/pyPhotoAlbum/project_serializer.py index 1efcba5..debfbad 100644 --- a/pyPhotoAlbum/project_serializer.py +++ b/pyPhotoAlbum/project_serializer.py @@ -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 diff --git a/tests/test_file_ops_mixin.py b/tests/test_file_ops_mixin.py index f7f447a..c2afd81 100644 --- a/tests/test_file_ops_mixin.py +++ b/tests/test_file_ops_mixin.py @@ -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): diff --git a/tests/test_frame_manager.py b/tests/test_frame_manager.py index cfb14ef..b792489 100644 --- a/tests/test_frame_manager.py +++ b/tests/test_frame_manager.py @@ -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""" diff --git a/tests/test_page_setup_dialog.py b/tests/test_page_setup_dialog.py index d780715..3f8c04a 100644 --- a/tests/test_page_setup_dialog.py +++ b/tests/test_page_setup_dialog.py @@ -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) diff --git a/tests/test_page_setup_dialog_mocked.py b/tests/test_page_setup_dialog_mocked.py index fcc7982..72b3115 100644 --- a/tests/test_page_setup_dialog_mocked.py +++ b/tests/test_page_setup_dialog_mocked.py @@ -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 diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py index 228f86b..8f275f4 100755 --- a/tests/test_pdf_export.py +++ b/tests/test_pdf_export.py @@ -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!") diff --git a/tests/test_project.py b/tests/test_project.py index f51d295..df278f9 100755 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -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") diff --git a/tests/test_view_ops_mixin.py b/tests/test_view_ops_mixin.py index 8d4d5f9..5b3f4c3 100755 --- a/tests/test_view_ops_mixin.py +++ b/tests/test_view_ops_mixin.py @@ -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"""