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

This commit is contained in:
Duncan Tourolle 2026-04-09 21:39:20 +02:00
parent f0aa005d8c
commit f96200c799
19 changed files with 953 additions and 233 deletions

View File

@ -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"]

View File

@ -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(),
}

View 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(),
}

View File

@ -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()

View File

@ -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:

View File

@ -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)

View File

@ -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"""

View File

@ -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

View File

@ -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"""

View File

@ -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):

View File

@ -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", {})

View File

@ -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

View File

@ -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):

View File

@ -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"""

View File

@ -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)

View File

@ -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

View File

@ -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!")

View File

@ -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")

View File

@ -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"""