Many improvements and stability fixes
Some checks failed
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
Some checks failed
Lint / lint (push) Successful in 1m47s
Tests / test (3.11) (push) Successful in 57s
Tests / test (3.12) (push) Successful in 55s
Tests / test (3.13) (push) Successful in 56s
Tests / test (3.14) (push) Successful in 1m3s
Python CI / test (push) Failing after 6m1s
This commit is contained in:
parent
f0aa005d8c
commit
f96200c799
@ -6,5 +6,6 @@ UI presentation logic separately from business logic.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .page_setup_dialog import PageSetupDialog
|
from .page_setup_dialog import PageSetupDialog
|
||||||
|
from .print_settings_dialog import PrintSettingsDialog
|
||||||
|
|
||||||
__all__ = ["PageSetupDialog"]
|
__all__ = ["PageSetupDialog", "PrintSettingsDialog"]
|
||||||
|
|||||||
@ -18,6 +18,8 @@ from PyQt6.QtWidgets import (
|
|||||||
QGroupBox,
|
QGroupBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
|
QRadioButton,
|
||||||
|
QButtonGroup,
|
||||||
)
|
)
|
||||||
from pyPhotoAlbum.project import Project
|
from pyPhotoAlbum.project import Project
|
||||||
|
|
||||||
@ -166,10 +168,23 @@ class PageSetupDialog(QDialog):
|
|||||||
height_layout.addWidget(self.height_spinbox)
|
height_layout.addWidget(self.height_spinbox)
|
||||||
layout.addLayout(height_layout)
|
layout.addLayout(height_layout)
|
||||||
|
|
||||||
# Set as default checkbox
|
# Apply scope radio buttons
|
||||||
self.set_default_checkbox = QCheckBox("Set as default for new pages")
|
scope_label = QLabel("Apply to:")
|
||||||
self.set_default_checkbox.setToolTip("Update project default page size for future pages")
|
layout.addWidget(scope_label)
|
||||||
layout.addWidget(self.set_default_checkbox)
|
|
||||||
|
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)
|
group.setLayout(layout)
|
||||||
return group
|
return group
|
||||||
@ -274,7 +289,10 @@ class PageSetupDialog(QDialog):
|
|||||||
is_cover = selected_page.is_cover
|
is_cover = selected_page.is_cover
|
||||||
self.width_spinbox.setEnabled(not is_cover)
|
self.width_spinbox.setEnabled(not is_cover)
|
||||||
self.height_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):
|
def _update_spine_info(self):
|
||||||
"""Update the spine information display."""
|
"""Update the spine information display."""
|
||||||
@ -318,5 +336,5 @@ class PageSetupDialog(QDialog):
|
|||||||
"height_mm": self.height_spinbox.value(),
|
"height_mm": self.height_spinbox.value(),
|
||||||
"working_dpi": self.working_dpi_spinbox.value(),
|
"working_dpi": self.working_dpi_spinbox.value(),
|
||||||
"export_dpi": self.export_dpi_spinbox.value(),
|
"export_dpi": self.export_dpi_spinbox.value(),
|
||||||
"set_as_default": self.set_default_checkbox.isChecked(),
|
"apply_scope": self._apply_scope_group.checkedId(),
|
||||||
}
|
}
|
||||||
|
|||||||
90
pyPhotoAlbum/dialogs/print_settings_dialog.py
Normal file
90
pyPhotoAlbum/dialogs/print_settings_dialog.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Print Settings Dialog for pyPhotoAlbum
|
||||||
|
|
||||||
|
Project-level bleed and safe-area configuration applied uniformly to all pages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QPushButton,
|
||||||
|
QGroupBox,
|
||||||
|
)
|
||||||
|
from pyPhotoAlbum.project import Project
|
||||||
|
|
||||||
|
|
||||||
|
class PrintSettingsDialog(QDialog):
|
||||||
|
"""Dialog for configuring project-wide print settings (bleed and safe area)."""
|
||||||
|
|
||||||
|
def __init__(self, parent, project: Project):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.project = project
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
self.setWindowTitle("Print Settings")
|
||||||
|
self.setMinimumWidth(340)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
group = QGroupBox("Bleed && Safe Area (applied to all pages)")
|
||||||
|
group_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# Bleed
|
||||||
|
bleed_layout = QHBoxLayout()
|
||||||
|
bleed_layout.addWidget(QLabel("Bleed Margin:"))
|
||||||
|
self.bleed_spinbox = QDoubleSpinBox()
|
||||||
|
self.bleed_spinbox.setRange(0.0, 20.0)
|
||||||
|
self.bleed_spinbox.setSingleStep(0.5)
|
||||||
|
self.bleed_spinbox.setDecimals(1)
|
||||||
|
self.bleed_spinbox.setSuffix(" mm")
|
||||||
|
self.bleed_spinbox.setValue(self.project.page_bleed_mm)
|
||||||
|
self.bleed_spinbox.setToolTip(
|
||||||
|
"Extra white space added around each page in the exported PDF.\n"
|
||||||
|
"The printer cuts here — 3 mm is standard."
|
||||||
|
)
|
||||||
|
bleed_layout.addWidget(self.bleed_spinbox)
|
||||||
|
group_layout.addLayout(bleed_layout)
|
||||||
|
|
||||||
|
# Safe area
|
||||||
|
safe_layout = QHBoxLayout()
|
||||||
|
safe_layout.addWidget(QLabel("Safe Area:"))
|
||||||
|
self.safe_spinbox = QDoubleSpinBox()
|
||||||
|
self.safe_spinbox.setRange(0.0, 50.0)
|
||||||
|
self.safe_spinbox.setSingleStep(0.5)
|
||||||
|
self.safe_spinbox.setDecimals(1)
|
||||||
|
self.safe_spinbox.setSuffix(" mm")
|
||||||
|
self.safe_spinbox.setValue(self.project.page_safe_area_mm)
|
||||||
|
self.safe_spinbox.setToolTip(
|
||||||
|
"Keep text and important content inside this distance from the cut/trim line.\n"
|
||||||
|
"Shown as a red guide in the editor."
|
||||||
|
)
|
||||||
|
safe_layout.addWidget(self.safe_spinbox)
|
||||||
|
group_layout.addLayout(safe_layout)
|
||||||
|
|
||||||
|
group.setLayout(group_layout)
|
||||||
|
layout.addWidget(group)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
ok_btn = QPushButton("OK")
|
||||||
|
ok_btn.clicked.connect(self.accept)
|
||||||
|
ok_btn.setDefault(True)
|
||||||
|
btn_layout.addStretch()
|
||||||
|
btn_layout.addWidget(cancel_btn)
|
||||||
|
btn_layout.addWidget(ok_btn)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def get_values(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"page_bleed_mm": self.bleed_spinbox.value(),
|
||||||
|
"page_safe_area_mm": self.safe_spinbox.value(),
|
||||||
|
}
|
||||||
@ -387,14 +387,14 @@ class MainWindow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if reply == QMessageBox.StandardButton.Save:
|
if reply == QMessageBox.StandardButton.Save:
|
||||||
# Trigger save
|
# Save is async — ignore event and let on_complete trigger close
|
||||||
self.save_project()
|
self._pending_close = True
|
||||||
|
save_started = self.save_project()
|
||||||
# Check if save was successful (project should be clean now)
|
if not save_started:
|
||||||
if self.project.is_dirty():
|
# User cancelled the file dialog
|
||||||
# User cancelled save dialog or save failed
|
self._pending_close = False
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
elif reply == QMessageBox.StandardButton.Cancel:
|
elif reply == QMessageBox.StandardButton.Cancel:
|
||||||
# User cancelled exit
|
# User cancelled exit
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|||||||
@ -5,6 +5,7 @@ File operations mixin for pyPhotoAlbum
|
|||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QDialog,
|
QDialog,
|
||||||
@ -32,6 +33,16 @@ from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSI
|
|||||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
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:
|
class FileOperationsMixin:
|
||||||
"""Mixin providing file-related operations"""
|
"""Mixin providing file-related operations"""
|
||||||
|
|
||||||
@ -274,8 +285,16 @@ class FileOperationsMixin:
|
|||||||
print(error_msg)
|
print(error_msg)
|
||||||
|
|
||||||
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
|
@ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")
|
||||||
def save_project(self):
|
def save_project(self) -> bool:
|
||||||
"""Save the current project asynchronously with progress feedback"""
|
"""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
|
# 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
|
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 (*)"
|
self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if file_path:
|
if not file_path:
|
||||||
print(f"Saving project to: {file_path}")
|
return False
|
||||||
|
|
||||||
# Create loading widget if not exists
|
self._save_in_progress = True
|
||||||
if not hasattr(self, "_loading_widget"):
|
print(f"Saving project to: {file_path}")
|
||||||
self._loading_widget = LoadingWidget(self)
|
|
||||||
|
|
||||||
# Show loading widget
|
# Create loading widget if not exists
|
||||||
self._loading_widget.show_loading("Saving project...")
|
if not hasattr(self, "_loading_widget"):
|
||||||
|
self._loading_widget = LoadingWidget(self)
|
||||||
|
|
||||||
# Define callbacks for async save
|
# Show loading widget
|
||||||
def on_progress(progress: int, message: str):
|
self._loading_widget.show_loading("Saving project...")
|
||||||
"""Update progress display"""
|
|
||||||
if hasattr(self, "_loading_widget"):
|
# 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_progress(progress, 100)
|
||||||
self._loading_widget.set_status(message)
|
self._loading_widget.set_status(message)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_complete(success: bool, error: str):
|
def _on_finished(success: bool, error: str):
|
||||||
"""Handle save completion"""
|
self._save_in_progress = False
|
||||||
# Hide loading widget
|
try:
|
||||||
if hasattr(self, "_loading_widget"):
|
if hasattr(self, "_loading_widget"):
|
||||||
self._loading_widget.hide_loading()
|
self._loading_widget.hide_loading()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.project.file_path = file_path
|
self.project.file_path = file_path
|
||||||
self.project.mark_clean()
|
self.project.mark_clean()
|
||||||
self.show_status(f"Project saved: {file_path}")
|
self.show_status(f"Project saved: {file_path}")
|
||||||
print(f"Successfully saved project to: {file_path}")
|
print(f"Successfully saved project to: {file_path}")
|
||||||
else:
|
if getattr(self, "_pending_close", False):
|
||||||
error_msg = f"Failed to save project: {error}"
|
self._pending_close = False
|
||||||
self.show_status(error_msg)
|
self.close()
|
||||||
self.show_error("Save Failed", error_msg)
|
else:
|
||||||
print(error_msg)
|
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
|
bridge.progress.connect(_on_progress)
|
||||||
save_to_zip_async(
|
bridge.finished.connect(_on_finished)
|
||||||
self.project,
|
|
||||||
file_path,
|
|
||||||
on_complete=on_complete,
|
|
||||||
on_progress=on_progress
|
|
||||||
)
|
|
||||||
|
|
||||||
# Show immediate feedback
|
# Start async save — callbacks emit signals (thread-safe)
|
||||||
self.show_status("Saving project in background...", 2000)
|
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")
|
@ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")
|
||||||
def heal_assets(self):
|
def heal_assets(self):
|
||||||
@ -456,16 +493,24 @@ class FileOperationsMixin:
|
|||||||
scaling_group = None
|
scaling_group = None
|
||||||
scaling_buttons = None
|
scaling_buttons = None
|
||||||
|
|
||||||
|
scope_buttons = None
|
||||||
if self.project.pages:
|
if self.project.pages:
|
||||||
scaling_group = QGroupBox("Apply to Existing Pages")
|
scaling_group = QGroupBox("Apply to Existing Pages")
|
||||||
scaling_layout = QVBoxLayout()
|
scaling_layout = QVBoxLayout()
|
||||||
|
|
||||||
info_label = QLabel(
|
# Scope: which pages to update
|
||||||
"How should existing content be adjusted?\n(Pages with manual sizing will not be affected)"
|
scaling_layout.addWidget(QLabel("Pages to update:"))
|
||||||
)
|
scope_buttons = QButtonGroup()
|
||||||
info_label.setWordWrap(True)
|
scope_non_manual = QRadioButton("Non-manual pages only")
|
||||||
scaling_layout.addWidget(info_label)
|
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()
|
scaling_buttons = QButtonGroup()
|
||||||
|
|
||||||
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
|
||||||
@ -515,12 +560,15 @@ class FileOperationsMixin:
|
|||||||
new_working_dpi = working_dpi_spinbox.value()
|
new_working_dpi = working_dpi_spinbox.value()
|
||||||
new_export_dpi = export_dpi_spinbox.value()
|
new_export_dpi = export_dpi_spinbox.value()
|
||||||
|
|
||||||
# Determine scaling mode
|
# Determine scaling mode and scope
|
||||||
scaling_mode = "none"
|
scaling_mode = "none"
|
||||||
|
include_manual = False
|
||||||
if scaling_buttons:
|
if scaling_buttons:
|
||||||
selected_id = scaling_buttons.checkedId()
|
selected_id = scaling_buttons.checkedId()
|
||||||
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}
|
||||||
scaling_mode = modes.get(selected_id, "none")
|
scaling_mode = modes.get(selected_id, "none")
|
||||||
|
if scope_buttons:
|
||||||
|
include_manual = scope_buttons.checkedId() == 1
|
||||||
|
|
||||||
# Apply settings
|
# Apply settings
|
||||||
old_size = self.project.page_size_mm
|
old_size = self.project.page_size_mm
|
||||||
@ -528,22 +576,23 @@ class FileOperationsMixin:
|
|||||||
self.project.working_dpi = new_working_dpi
|
self.project.working_dpi = new_working_dpi
|
||||||
self.project.export_dpi = new_export_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):
|
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.update_view()
|
||||||
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
|
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}")
|
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:
|
Args:
|
||||||
old_size: Old page size (width, height) in mm
|
old_size: Old page size (width, height) in mm
|
||||||
new_size: New page size (width, height) in mm
|
new_size: New page size (width, height) in mm
|
||||||
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
|
||||||
|
include_manual: If True, also resize manually-sized pages
|
||||||
"""
|
"""
|
||||||
old_width, old_height = old_size
|
old_width, old_height = old_size
|
||||||
new_width, new_height = new_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
|
height_ratio = new_height / old_height if old_height > 0 else 1.0
|
||||||
|
|
||||||
for page in self.project.pages:
|
for page in self.project.pages:
|
||||||
# Skip manually sized pages
|
if page.is_cover:
|
||||||
if page.manually_sized:
|
continue
|
||||||
|
if page.manually_sized and not include_manual:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update page size
|
# Update page size
|
||||||
@ -634,7 +684,7 @@ class FileOperationsMixin:
|
|||||||
file_path += ".pdf"
|
file_path += ".pdf"
|
||||||
|
|
||||||
# Use async PDF export (non-blocking, UI stays responsive)
|
# 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:
|
if success:
|
||||||
self.show_status("PDF export started...", 2000)
|
self.show_status("PDF export started...", 2000)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -148,11 +148,26 @@ class PageOperationsMixin:
|
|||||||
self.project.working_dpi = values["working_dpi"]
|
self.project.working_dpi = values["working_dpi"]
|
||||||
self.project.export_dpi = values["export_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)
|
self.project.page_size_mm = (width_mm, height_mm)
|
||||||
print(f"Project default page size set to {width_mm}×{height_mm} 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()
|
self.update_view()
|
||||||
|
|
||||||
# Build status message
|
# Build status message
|
||||||
@ -161,7 +176,7 @@ class PageOperationsMixin:
|
|||||||
status_msg = f"{page_name} updated"
|
status_msg = f"{page_name} updated"
|
||||||
else:
|
else:
|
||||||
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
|
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)"
|
status_msg += " (set as default)"
|
||||||
self.show_status(status_msg, 2000)
|
self.show_status(status_msg, 2000)
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ View operations mixin for pyPhotoAlbum
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pyPhotoAlbum.decorators import ribbon_action
|
from pyPhotoAlbum.decorators import ribbon_action
|
||||||
|
from pyPhotoAlbum.dialogs import PrintSettingsDialog
|
||||||
|
|
||||||
|
|
||||||
class ViewOperationsMixin:
|
class ViewOperationsMixin:
|
||||||
@ -118,6 +119,34 @@ class ViewOperationsMixin:
|
|||||||
self.show_status(f"Grid {status}", 2000)
|
self.show_status(f"Grid {status}", 2000)
|
||||||
print(f"Grid {status}")
|
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")
|
@ribbon_action(label="Show Guides", tooltip="Toggle visibility of guide lines", tab="Insert", group="Snapping")
|
||||||
def toggle_snap_lines(self):
|
def toggle_snap_lines(self):
|
||||||
"""Toggle guide lines visibility"""
|
"""Toggle guide lines visibility"""
|
||||||
|
|||||||
@ -93,6 +93,10 @@ class RenderingMixin:
|
|||||||
page.layout._parent_widget = self
|
page.layout._parent_widget = self
|
||||||
page.layout.render(dpi=dpi, project=project)
|
page.layout.render(dpi=dpi, project=project)
|
||||||
renderer.end_render()
|
renderer.end_render()
|
||||||
|
|
||||||
|
# Draw bleed/cut/safe-area guides for this page
|
||||||
|
self._draw_page_print_guides(renderer, project)
|
||||||
|
|
||||||
pages_rendered += 1
|
pages_rendered += 1
|
||||||
|
|
||||||
elif page_type == "ghost":
|
elif page_type == "ghost":
|
||||||
@ -326,3 +330,77 @@ class RenderingMixin:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
def _draw_page_print_guides(self, renderer, project):
|
||||||
|
"""
|
||||||
|
Draw bleed/cut/safe-area guide lines around a page using OpenGL.
|
||||||
|
|
||||||
|
- Green dashed rectangle: bleed boundary (extend backgrounds to here)
|
||||||
|
- Magenta rectangle: cut/trim line (finished page edge, only shown when bleed > 0)
|
||||||
|
- Red rectangle: safe area (keep text and logos inside)
|
||||||
|
|
||||||
|
Lines are only drawn when the corresponding project settings are non-zero.
|
||||||
|
"""
|
||||||
|
if not getattr(project, "show_print_guides", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
bleed_mm = getattr(project, "page_bleed_mm", 0.0)
|
||||||
|
safe_mm = getattr(project, "page_safe_area_mm", 0.0)
|
||||||
|
|
||||||
|
if bleed_mm <= 0.0 and safe_mm <= 0.0:
|
||||||
|
return
|
||||||
|
|
||||||
|
dpi = project.working_dpi
|
||||||
|
zoom = renderer.zoom
|
||||||
|
sx = renderer.screen_x
|
||||||
|
sy = renderer.screen_y
|
||||||
|
sw = renderer.screen_width
|
||||||
|
sh = renderer.screen_height
|
||||||
|
|
||||||
|
# Convert mm to screen pixels
|
||||||
|
bleed_screen = bleed_mm * dpi / 25.4 * zoom
|
||||||
|
safe_screen = safe_mm * dpi / 25.4 * zoom
|
||||||
|
|
||||||
|
glLineWidth(1.0)
|
||||||
|
|
||||||
|
def _draw_rect_outline(x, y, w, h, r, g, b, dashed=False):
|
||||||
|
glColor3f(r, g, b)
|
||||||
|
if dashed:
|
||||||
|
glEnable(GL_LINE_STIPPLE)
|
||||||
|
glLineStipple(1, 0x00FF)
|
||||||
|
else:
|
||||||
|
glDisable(GL_LINE_STIPPLE)
|
||||||
|
glBegin(GL_LINE_LOOP)
|
||||||
|
glVertex2f(x, y)
|
||||||
|
glVertex2f(x + w, y)
|
||||||
|
glVertex2f(x + w, y + h)
|
||||||
|
glVertex2f(x, y + h)
|
||||||
|
glEnd()
|
||||||
|
glDisable(GL_LINE_STIPPLE)
|
||||||
|
|
||||||
|
# Bleed boundary – green dashed rectangle outside the page
|
||||||
|
if bleed_screen > 0:
|
||||||
|
_draw_rect_outline(
|
||||||
|
sx - bleed_screen,
|
||||||
|
sy - bleed_screen,
|
||||||
|
sw + 2 * bleed_screen,
|
||||||
|
sh + 2 * bleed_screen,
|
||||||
|
0.0, 0.67, 0.0,
|
||||||
|
dashed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cut/trim line – magenta rectangle at the page edge (only meaningful when bleed > 0)
|
||||||
|
if bleed_screen > 0:
|
||||||
|
_draw_rect_outline(sx, sy, sw, sh, 0.8, 0.0, 0.8)
|
||||||
|
|
||||||
|
# Safe area – red rectangle inside the page
|
||||||
|
if safe_screen > 0:
|
||||||
|
_draw_rect_outline(
|
||||||
|
sx + safe_screen,
|
||||||
|
sy + safe_screen,
|
||||||
|
sw - 2 * safe_screen,
|
||||||
|
sh - 2 * safe_screen,
|
||||||
|
0.8, 0.0, 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
glColor3f(1.0, 1.0, 1.0) # Reset colour
|
||||||
|
|||||||
@ -58,8 +58,7 @@ class PageLayout:
|
|||||||
|
|
||||||
def remove_element(self, element: BaseLayoutElement):
|
def remove_element(self, element: BaseLayoutElement):
|
||||||
"""Remove a layout element from the page"""
|
"""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"):
|
def set_grid_layout(self, grid: "GridLayout"):
|
||||||
"""Set a grid layout for the page"""
|
"""Set a grid layout for the page"""
|
||||||
|
|||||||
@ -122,9 +122,15 @@ def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional
|
|||||||
if task.corner_radius > 0:
|
if task.corner_radius > 0:
|
||||||
cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
|
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()
|
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)
|
return (task.task_id, buffer.getvalue(), None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -216,10 +222,18 @@ class PDFExporter:
|
|||||||
# Get page dimensions from project (in mm)
|
# Get page dimensions from project (in mm)
|
||||||
page_width_mm, page_height_mm = self.project.page_size_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_width_pt = page_width_mm * self.MM_TO_POINTS
|
||||||
page_height_pt = page_height_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
|
# Phase 1: Collect all image tasks and process in parallel
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(0, total_pages, "Collecting images for processing...")
|
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)
|
self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
|
||||||
|
|
||||||
# Phase 2: Build PDF using pre-processed images
|
# 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
|
pages_processed = 0
|
||||||
for page in self.project.pages:
|
for page in self.project.pages:
|
||||||
@ -251,10 +265,10 @@ class PDFExporter:
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(pages_processed, total_pages, "Inserting blank page for alignment...")
|
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
|
pages_processed += 2
|
||||||
else:
|
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
|
pages_processed += 1
|
||||||
|
|
||||||
c.save()
|
c.save()
|
||||||
@ -514,8 +528,6 @@ class PDFExporter:
|
|||||||
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
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")
|
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
|
c.showPage() # Finish cover page
|
||||||
self.current_pdf_page += 1
|
self.current_pdf_page += 1
|
||||||
@ -523,51 +535,24 @@ class PDFExporter:
|
|||||||
# Reset page size for content pages
|
# Reset page size for content pages
|
||||||
c.setPageSize((page_width_pt, page_height_pt))
|
c.setPageSize((page_width_pt, page_height_pt))
|
||||||
|
|
||||||
def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float):
|
def _export_single_page(
|
||||||
"""Draw guide lines for cover zones (front/spine/back)"""
|
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
|
||||||
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):
|
|
||||||
"""Export a single page to PDF"""
|
"""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):
|
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
|
c.showPage() # Finish this page
|
||||||
self.current_pdf_page += 1
|
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"""
|
"""Export a double-page spread as two PDF pages"""
|
||||||
# Get center line position in mm
|
# Get center line position in mm
|
||||||
page_width_mm = self.project.page_size_mm[0]
|
page_width_mm = self.project.page_size_mm[0]
|
||||||
@ -580,7 +565,11 @@ class PDFExporter:
|
|||||||
# Calculate threshold for tiny elements (1:500) in pixels
|
# Calculate threshold for tiny elements (1:500) in pixels
|
||||||
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
|
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
|
# 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):
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||||
element_x_px, element_y_px = element.position
|
element_x_px, element_y_px = element.position
|
||||||
element_width_px, element_height_px = element.size
|
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)
|
# 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:
|
if element_x_px + element_width_px <= center_px + threshold_px:
|
||||||
# Entirely on left page
|
# 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:
|
elif element_x_px >= center_px - threshold_px:
|
||||||
# Skip for now, will render on right page
|
# Skip for now, will render on right page
|
||||||
pass
|
pass
|
||||||
@ -604,12 +593,13 @@ class PDFExporter:
|
|||||||
page_number=page.page_number,
|
page_number=page.page_number,
|
||||||
side="left",
|
side="left",
|
||||||
)
|
)
|
||||||
self._render_split_element(params)
|
self._render_split_element(params, bleed_pt)
|
||||||
|
|
||||||
c.showPage() # Finish left page
|
c.showPage() # Finish left page
|
||||||
self.current_pdf_page += 1
|
self.current_pdf_page += 1
|
||||||
|
|
||||||
# Process elements for right page
|
# 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):
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
||||||
element_x_px, element_y_px = element.position
|
element_x_px, element_y_px = element.position
|
||||||
element_width_px, element_height_px = element.size
|
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)
|
# 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:
|
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
|
# 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:
|
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
|
||||||
# Spanning element - render right portion
|
# Spanning element - render right portion
|
||||||
params = SplitRenderParams(
|
params = SplitRenderParams(
|
||||||
@ -630,7 +622,7 @@ class PDFExporter:
|
|||||||
page_number=page.page_number + 1,
|
page_number=page.page_number + 1,
|
||||||
side="right",
|
side="right",
|
||||||
)
|
)
|
||||||
self._render_split_element(params)
|
self._render_split_element(params, bleed_pt)
|
||||||
|
|
||||||
c.showPage() # Finish right page
|
c.showPage() # Finish right page
|
||||||
self.current_pdf_page += 1
|
self.current_pdf_page += 1
|
||||||
@ -643,6 +635,7 @@ class PDFExporter:
|
|||||||
page_width_pt: float,
|
page_width_pt: float,
|
||||||
page_height_pt: float,
|
page_height_pt: float,
|
||||||
page_number: Union[int, str],
|
page_number: Union[int, str],
|
||||||
|
bleed_pt: float = 0.0,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Render a single element on the PDF canvas.
|
Render a single element on the PDF canvas.
|
||||||
@ -651,9 +644,10 @@ class PDFExporter:
|
|||||||
c: ReportLab canvas
|
c: ReportLab canvas
|
||||||
element: The layout element to render
|
element: The layout element to render
|
||||||
x_offset_mm: X offset in mm (for right page of spread)
|
x_offset_mm: X offset in mm (for right page of spread)
|
||||||
page_width_pt: Page width in points
|
page_width_pt: Page width in points (cut/trim size, excluding bleed)
|
||||||
page_height_pt: Page height in points
|
page_height_pt: Page height in points (cut/trim size, excluding bleed)
|
||||||
page_number: Current page number (for error messages)
|
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
|
# Skip placeholders
|
||||||
if isinstance(element, PlaceholderData):
|
if isinstance(element, PlaceholderData):
|
||||||
@ -673,9 +667,10 @@ class PDFExporter:
|
|||||||
# Adjust x position for offset (now in mm)
|
# Adjust x position for offset (now in mm)
|
||||||
adjusted_x_mm = element_x_mm - x_offset_mm
|
adjusted_x_mm = element_x_mm - x_offset_mm
|
||||||
|
|
||||||
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left)
|
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left).
|
||||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
# bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
|
||||||
y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
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
|
width_pt = element_width_mm * self.MM_TO_POINTS
|
||||||
height_pt = element_height_mm * self.MM_TO_POINTS
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
||||||
|
|
||||||
@ -693,12 +688,13 @@ class PDFExporter:
|
|||||||
elif isinstance(element, TextBoxData):
|
elif isinstance(element, TextBoxData):
|
||||||
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
|
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).
|
Render a split element (only the portion on one side of the split line).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
params: SplitRenderParams containing all rendering parameters
|
params: SplitRenderParams containing all rendering parameters
|
||||||
|
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
|
||||||
"""
|
"""
|
||||||
# Skip placeholders
|
# Skip placeholders
|
||||||
if isinstance(params.element, PlaceholderData):
|
if isinstance(params.element, PlaceholderData):
|
||||||
@ -731,9 +727,9 @@ class PDFExporter:
|
|||||||
# Adjust render position for offset
|
# Adjust render position for offset
|
||||||
adjusted_x_mm = render_x_mm - params.x_offset_mm
|
adjusted_x_mm = render_x_mm - params.x_offset_mm
|
||||||
|
|
||||||
# Convert to points
|
# Convert to points (bleed_pt shifts content inside the expanded PDF page)
|
||||||
x_pt = adjusted_x_mm * self.MM_TO_POINTS
|
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
|
||||||
y_pt = params.page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
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
|
width_pt = crop_width_mm * self.MM_TO_POINTS
|
||||||
height_pt = element_height_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_width_pt,
|
||||||
params.page_height_pt,
|
params.page_height_pt,
|
||||||
params.page_number,
|
params.page_number,
|
||||||
|
bleed_pt,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_image(self, ctx: RenderContext):
|
def _render_image(self, ctx: RenderContext):
|
||||||
|
|||||||
@ -166,6 +166,11 @@ class Project:
|
|||||||
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
|
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
|
||||||
self.binding_type = "saddle_stitch" # Binding type for spine calculation
|
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
|
# Embedded templates - templates that travel with the project
|
||||||
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
|
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
@ -404,6 +409,9 @@ class Project:
|
|||||||
"paper_thickness_mm": self.paper_thickness_mm,
|
"paper_thickness_mm": self.paper_thickness_mm,
|
||||||
"cover_bleed_mm": self.cover_bleed_mm,
|
"cover_bleed_mm": self.cover_bleed_mm,
|
||||||
"binding_type": self.binding_type,
|
"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,
|
"embedded_templates": self.embedded_templates,
|
||||||
"snap_to_grid": self.snap_to_grid,
|
"snap_to_grid": self.snap_to_grid,
|
||||||
"snap_to_edges": self.snap_to_edges,
|
"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.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
|
||||||
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
|
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
|
||||||
self.binding_type = data.get("binding_type", "saddle_stitch")
|
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
|
# Deserialize embedded templates
|
||||||
self.embedded_templates = data.get("embedded_templates", {})
|
self.embedded_templates = data.get("embedded_templates", {})
|
||||||
|
|||||||
@ -197,76 +197,63 @@ def save_to_zip_async(
|
|||||||
Returns:
|
Returns:
|
||||||
The background thread (already started)
|
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():
|
def _background_save():
|
||||||
"""Background thread function to create the ZIP file."""
|
"""Background thread: write ZIP file from pre-serialized data."""
|
||||||
temp_dir = None
|
temp_dir = None
|
||||||
try:
|
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_")
|
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")
|
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:
|
if on_progress:
|
||||||
on_progress(25, f"Creating ZIP archive ({total_files} files)...")
|
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:
|
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||||
# Write project.json
|
zipf.writestr("project.json", project_json_str)
|
||||||
zipf.write(temp_project_json, "project.json")
|
|
||||||
|
|
||||||
# Add all assets with progress reporting
|
|
||||||
if asset_files:
|
if asset_files:
|
||||||
# Progress from 25% to 90% for assets
|
|
||||||
progress_range = 90 - 25
|
progress_range = 90 - 25
|
||||||
for idx, (root, file) in enumerate(asset_files):
|
for idx, (file_path, arcname) in enumerate(asset_files):
|
||||||
file_path = os.path.join(root, file)
|
|
||||||
arcname = os.path.relpath(file_path, project.folder_path)
|
|
||||||
zipf.write(file_path, arcname)
|
zipf.write(file_path, arcname)
|
||||||
|
|
||||||
# Report progress every 10 files or at end
|
|
||||||
if idx % 10 == 0 or idx == len(asset_files) - 1:
|
if idx % 10 == 0 or idx == len(asset_files) - 1:
|
||||||
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
|
progress = 25 + int((idx + 1) / len(asset_files) * progress_range)
|
||||||
if on_progress:
|
if on_progress:
|
||||||
@ -275,18 +262,12 @@ def save_to_zip_async(
|
|||||||
f"Adding assets... ({idx + 1}/{len(asset_files)})"
|
f"Adding assets... ({idx + 1}/{len(asset_files)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Atomic move: move temp ZIP to final location
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
on_progress(95, "Finalizing save...")
|
on_progress(95, "Finalizing save...")
|
||||||
|
|
||||||
# Ensure parent directory exists
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(final_zip_path)), exist_ok=True)
|
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):
|
if os.path.exists(final_zip_path):
|
||||||
os.remove(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)
|
shutil.move(temp_zip_path, final_zip_path)
|
||||||
|
|
||||||
if on_progress:
|
if on_progress:
|
||||||
@ -294,30 +275,24 @@ def save_to_zip_async(
|
|||||||
|
|
||||||
print(f"Project saved to {final_zip_path}")
|
print(f"Project saved to {final_zip_path}")
|
||||||
|
|
||||||
# Call completion callback with success
|
|
||||||
if on_complete:
|
if on_complete:
|
||||||
on_complete(True, None)
|
on_complete(True, None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error saving project: {str(e)}"
|
error_msg = f"Error saving project: {str(e)}"
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
|
|
||||||
# Call completion callback with error
|
|
||||||
if on_complete:
|
if on_complete:
|
||||||
on_complete(False, error_msg)
|
on_complete(False, error_msg)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temp directory
|
|
||||||
if temp_dir and os.path.exists(temp_dir):
|
if temp_dir and os.path.exists(temp_dir):
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(temp_dir)
|
shutil.rmtree(temp_dir)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Ignore cleanup errors
|
pass
|
||||||
|
|
||||||
# Start background thread
|
|
||||||
save_thread = threading.Thread(target=_background_save, daemon=True)
|
save_thread = threading.Thread(target=_background_save, daemon=True)
|
||||||
save_thread.start()
|
save_thread.start()
|
||||||
|
|
||||||
return save_thread
|
return save_thread
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -809,13 +809,14 @@ class TestExportPdf:
|
|||||||
page = Page(layout=layout, page_number=1)
|
page = Page(layout=layout, page_number=1)
|
||||||
window.project.pages = [page]
|
window.project.pages = [page]
|
||||||
|
|
||||||
|
window.project.export_dpi = 150
|
||||||
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
||||||
window.gl_widget.export_pdf_async.return_value = True
|
window.gl_widget.export_pdf_async.return_value = True
|
||||||
|
|
||||||
window.export_pdf()
|
window.export_pdf()
|
||||||
|
|
||||||
# Verify export was called
|
# 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=300)
|
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
|
assert "PDF export started" in window._status_message
|
||||||
|
|
||||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||||
@ -833,8 +834,10 @@ class TestExportPdf:
|
|||||||
|
|
||||||
window.export_pdf()
|
window.export_pdf()
|
||||||
|
|
||||||
# Verify .pdf was added
|
# 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=300)
|
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")
|
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||||
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
|
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
|
||||||
|
|||||||
@ -75,7 +75,7 @@ class TestFrameDefinition:
|
|||||||
frame_type=FrameType.FULL,
|
frame_type=FrameType.FULL,
|
||||||
)
|
)
|
||||||
assert frame.description == ""
|
assert frame.description == ""
|
||||||
assert frame.assets == {}
|
assert frame.asset_path is None
|
||||||
assert frame.colorizable is True
|
assert frame.colorizable is True
|
||||||
assert frame.default_thickness == 5.0
|
assert frame.default_thickness == 5.0
|
||||||
|
|
||||||
@ -144,19 +144,20 @@ class TestFrameManager:
|
|||||||
assert isinstance(names, list)
|
assert isinstance(names, list)
|
||||||
assert "simple_line" in names
|
assert "simple_line" in names
|
||||||
assert "double_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):
|
def test_bundled_frames_exist(self, frame_manager):
|
||||||
"""Test that expected bundled frames exist"""
|
"""Test that expected bundled frames exist"""
|
||||||
expected_frames = [
|
expected_frames = [
|
||||||
"simple_line",
|
"simple_line",
|
||||||
"double_line",
|
"double_line",
|
||||||
"rounded_modern",
|
|
||||||
"geometric_corners",
|
"geometric_corners",
|
||||||
"leafy_corners",
|
"floral_corner",
|
||||||
"ornate_flourish",
|
"floral_flourish",
|
||||||
"victorian",
|
"ornate_corner",
|
||||||
"art_nouveau",
|
"simple_corner",
|
||||||
|
"corner_decoration",
|
||||||
|
"corner_ornament",
|
||||||
]
|
]
|
||||||
for name in expected_frames:
|
for name in expected_frames:
|
||||||
frame = frame_manager.get_frame(name)
|
frame = frame_manager.get_frame(name)
|
||||||
@ -164,15 +165,15 @@ class TestFrameManager:
|
|||||||
|
|
||||||
def test_modern_frames_are_full_type(self, frame_manager):
|
def test_modern_frames_are_full_type(self, frame_manager):
|
||||||
"""Test that modern frames are FULL type"""
|
"""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:
|
for name in modern_frames:
|
||||||
frame = frame_manager.get_frame(name)
|
frame = frame_manager.get_frame(name)
|
||||||
assert frame is not None
|
assert frame is not None
|
||||||
assert frame.frame_type == FrameType.FULL
|
assert frame.frame_type == FrameType.FULL
|
||||||
|
|
||||||
def test_leafy_corners_is_corners_type(self, frame_manager):
|
def test_geometric_corners_is_corners_type(self, frame_manager):
|
||||||
"""Test that leafy_corners is CORNERS type"""
|
"""Test that geometric_corners is CORNERS type"""
|
||||||
frame = frame_manager.get_frame("leafy_corners")
|
frame = frame_manager.get_frame("geometric_corners")
|
||||||
assert frame is not None
|
assert frame is not None
|
||||||
assert frame.frame_type == FrameType.CORNERS
|
assert frame.frame_type == FrameType.CORNERS
|
||||||
|
|
||||||
@ -207,7 +208,7 @@ class TestFrameCategories:
|
|||||||
def test_modern_category_not_empty(self, frame_manager):
|
def test_modern_category_not_empty(self, frame_manager):
|
||||||
"""Test MODERN category has frames"""
|
"""Test MODERN category has frames"""
|
||||||
frames = frame_manager.get_frames_by_category(FrameCategory.MODERN)
|
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):
|
def test_vintage_category_not_empty(self, frame_manager):
|
||||||
"""Test VINTAGE category has frames"""
|
"""Test VINTAGE category has frames"""
|
||||||
@ -238,9 +239,9 @@ class TestFrameDescriptions:
|
|||||||
frame = frame_manager.get_frame("simple_line")
|
frame = frame_manager.get_frame("simple_line")
|
||||||
assert frame.description != ""
|
assert frame.description != ""
|
||||||
|
|
||||||
def test_leafy_corners_has_description(self, frame_manager):
|
def test_floral_corner_has_description(self, frame_manager):
|
||||||
"""Test leafy_corners has a description"""
|
"""Test floral_corner has a description"""
|
||||||
frame = frame_manager.get_frame("leafy_corners")
|
frame = frame_manager.get_frame("floral_corner")
|
||||||
assert frame.description != ""
|
assert frame.description != ""
|
||||||
|
|
||||||
def test_all_frames_have_descriptions(self, frame_manager):
|
def test_all_frames_have_descriptions(self, frame_manager):
|
||||||
@ -268,11 +269,11 @@ class TestFrameThickness:
|
|||||||
|
|
||||||
def test_vintage_frames_are_thicker(self, frame_manager):
|
def test_vintage_frames_are_thicker(self, frame_manager):
|
||||||
"""Test vintage frames have thicker default"""
|
"""Test vintage frames have thicker default"""
|
||||||
leafy = frame_manager.get_frame("leafy_corners")
|
floral = frame_manager.get_frame("floral_corner")
|
||||||
victorian = frame_manager.get_frame("victorian")
|
ornate = frame_manager.get_frame("ornate_corner")
|
||||||
|
|
||||||
assert leafy.default_thickness >= 8.0
|
assert floral.default_thickness >= 8.0
|
||||||
assert victorian.default_thickness >= 10.0
|
assert ornate.default_thickness >= 8.0
|
||||||
|
|
||||||
def test_all_thicknesses_positive(self, frame_manager):
|
def test_all_thicknesses_positive(self, frame_manager):
|
||||||
"""Test all frames have positive thickness"""
|
"""Test all frames have positive thickness"""
|
||||||
|
|||||||
@ -110,7 +110,8 @@ class TestPageSetupDialog:
|
|||||||
# Size editing should be disabled for covers
|
# Size editing should be disabled for covers
|
||||||
assert not dialog.width_spinbox.isEnabled()
|
assert not dialog.width_spinbox.isEnabled()
|
||||||
assert not dialog.height_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):
|
def test_dialog_double_spread_width_calculation(self, qtbot):
|
||||||
"""Test double spread shows per-page width, not total width"""
|
"""Test double spread shows per-page width, not total width"""
|
||||||
@ -182,7 +183,7 @@ class TestPageSetupDialog:
|
|||||||
dialog.height_spinbox.setValue(280)
|
dialog.height_spinbox.setValue(280)
|
||||||
dialog.working_dpi_spinbox.setValue(150)
|
dialog.working_dpi_spinbox.setValue(150)
|
||||||
dialog.export_dpi_spinbox.setValue(600)
|
dialog.export_dpi_spinbox.setValue(600)
|
||||||
dialog.set_default_checkbox.setChecked(True)
|
dialog.scope_all_pages.setChecked(True)
|
||||||
dialog.cover_checkbox.setChecked(True)
|
dialog.cover_checkbox.setChecked(True)
|
||||||
dialog.thickness_spinbox.setValue(0.15)
|
dialog.thickness_spinbox.setValue(0.15)
|
||||||
dialog.bleed_spinbox.setValue(5.0)
|
dialog.bleed_spinbox.setValue(5.0)
|
||||||
@ -199,7 +200,7 @@ class TestPageSetupDialog:
|
|||||||
assert values["height_mm"] == 280
|
assert values["height_mm"] == 280
|
||||||
assert values["working_dpi"] == 150
|
assert values["working_dpi"] == 150
|
||||||
assert values["export_dpi"] == 600
|
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):
|
def test_dialog_page_change_updates_values(self, qtbot):
|
||||||
"""Test changing selected page updates displayed values"""
|
"""Test changing selected page updates displayed values"""
|
||||||
@ -552,7 +553,7 @@ class TestPageSetupIntegration:
|
|||||||
"height_mm": 280,
|
"height_mm": 280,
|
||||||
"working_dpi": 150,
|
"working_dpi": 150,
|
||||||
"export_dpi": 600,
|
"export_dpi": 600,
|
||||||
"set_as_default": True,
|
"apply_scope": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Access the unwrapped function to test business logic directly
|
# 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.cover_bleed_mm == 5.0
|
||||||
assert window.project.working_dpi == 150
|
assert window.project.working_dpi == 150
|
||||||
assert window.project.export_dpi == 600
|
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
|
# Check page size updated
|
||||||
assert window.project.pages[0].layout.size == (200, 280)
|
assert window.project.pages[0].layout.size == (200, 280)
|
||||||
@ -644,7 +645,7 @@ class TestPageSetupIntegration:
|
|||||||
"height_mm": 297,
|
"height_mm": 297,
|
||||||
"working_dpi": 96,
|
"working_dpi": 96,
|
||||||
"export_dpi": 300,
|
"export_dpi": 300,
|
||||||
"set_as_default": False,
|
"apply_scope": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the undecorated method
|
# Get the undecorated method
|
||||||
@ -716,7 +717,7 @@ class TestPageSetupIntegration:
|
|||||||
"height_mm": 280, # New height
|
"height_mm": 280, # New height
|
||||||
"working_dpi": 96,
|
"working_dpi": 96,
|
||||||
"export_dpi": 300,
|
"export_dpi": 300,
|
||||||
"set_as_default": False,
|
"apply_scope": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
from pyPhotoAlbum.mixins.operations import page_ops
|
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.base_width == 200
|
||||||
assert window.project.pages[0].layout.size == (400, 280) # Double width
|
assert window.project.pages[0].layout.size == (400, 280) # Double width
|
||||||
assert window.project.pages[0].manually_sized is True
|
assert window.project.pages[0].manually_sized is True
|
||||||
|
|
||||||
|
def test_set_as_default_updates_double_spread_pages(self, qtbot):
|
||||||
|
"""Test set_as_default updates existing double spread pages with doubled width"""
|
||||||
|
from PyQt6.QtWidgets import QMainWindow
|
||||||
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||||
|
|
||||||
|
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._project = Project(name="Test")
|
||||||
|
self._project.page_size_mm = (210, 297)
|
||||||
|
self._project.working_dpi = 96
|
||||||
|
self._project.export_dpi = 300
|
||||||
|
self._project.paper_thickness_mm = 0.1
|
||||||
|
self._project.cover_bleed_mm = 3.0
|
||||||
|
|
||||||
|
# Normal page being edited
|
||||||
|
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
# Double spread page (not manually sized - should be updated)
|
||||||
|
page2 = Page(layout=PageLayout(width=420, height=297), page_number=2)
|
||||||
|
page2.is_double_spread = True
|
||||||
|
page2.layout.base_width = 210
|
||||||
|
page2.layout.is_facing_page = True
|
||||||
|
# Manually sized page (should NOT be updated)
|
||||||
|
page3 = Page(layout=PageLayout(width=150, height=200), page_number=3)
|
||||||
|
page3.manually_sized = True
|
||||||
|
self._project.pages = [page1, page2, page3]
|
||||||
|
|
||||||
|
self._gl_widget = Mock()
|
||||||
|
self._gl_widget._page_renderers = []
|
||||||
|
self._status_bar = Mock()
|
||||||
|
self._update_view_called = False
|
||||||
|
|
||||||
|
def _get_most_visible_page_index(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def update_view(self):
|
||||||
|
self._update_view_called = True
|
||||||
|
|
||||||
|
def show_status(self, message, timeout=0):
|
||||||
|
pass
|
||||||
|
|
||||||
|
window = TestWindow()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"selected_index": 0,
|
||||||
|
"selected_page": window.project.pages[0],
|
||||||
|
"is_cover": False,
|
||||||
|
"paper_thickness_mm": 0.1,
|
||||||
|
"cover_bleed_mm": 3.0,
|
||||||
|
"width_mm": 200,
|
||||||
|
"height_mm": 280,
|
||||||
|
"working_dpi": 96,
|
||||||
|
"export_dpi": 300,
|
||||||
|
"apply_scope": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
from pyPhotoAlbum.mixins.operations import page_ops
|
||||||
|
|
||||||
|
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
|
||||||
|
while hasattr(undecorated_page_setup, "__wrapped__"):
|
||||||
|
undecorated_page_setup = undecorated_page_setup.__wrapped__
|
||||||
|
|
||||||
|
undecorated_page_setup(window, values)
|
||||||
|
|
||||||
|
# Project default updated
|
||||||
|
assert window.project.page_size_mm == (200, 280)
|
||||||
|
# Double spread page updated with doubled width
|
||||||
|
assert window.project.pages[1].layout.base_width == 200
|
||||||
|
assert window.project.pages[1].layout.size == (400, 280)
|
||||||
|
# Manually sized page NOT updated
|
||||||
|
assert window.project.pages[2].layout.size == (150, 200)
|
||||||
|
|
||||||
|
def test_apply_scope_all_pages_overrides_manual_sizing(self, qtbot):
|
||||||
|
"""Test apply_scope=2 updates all pages including manually sized ones"""
|
||||||
|
from PyQt6.QtWidgets import QMainWindow
|
||||||
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
|
from pyPhotoAlbum.mixins.operations.page_ops import PageOperationsMixin
|
||||||
|
|
||||||
|
class TestWindow(PageOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._project = Project(name="Test")
|
||||||
|
self._project.page_size_mm = (210, 297)
|
||||||
|
self._project.working_dpi = 96
|
||||||
|
self._project.export_dpi = 300
|
||||||
|
self._project.paper_thickness_mm = 0.1
|
||||||
|
self._project.cover_bleed_mm = 3.0
|
||||||
|
|
||||||
|
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||||
|
page2 = Page(layout=PageLayout(width=420, height=297), page_number=2)
|
||||||
|
page2.is_double_spread = True
|
||||||
|
page2.layout.base_width = 210
|
||||||
|
page2.layout.is_facing_page = True
|
||||||
|
page3 = Page(layout=PageLayout(width=150, height=200), page_number=3)
|
||||||
|
page3.manually_sized = True
|
||||||
|
self._project.pages = [page1, page2, page3]
|
||||||
|
|
||||||
|
self._gl_widget = Mock()
|
||||||
|
self._gl_widget._page_renderers = []
|
||||||
|
self._status_bar = Mock()
|
||||||
|
self._update_view_called = False
|
||||||
|
|
||||||
|
def _get_most_visible_page_index(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def update_view(self):
|
||||||
|
self._update_view_called = True
|
||||||
|
|
||||||
|
def show_status(self, message, timeout=0):
|
||||||
|
pass
|
||||||
|
|
||||||
|
window = TestWindow()
|
||||||
|
qtbot.addWidget(window)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"selected_index": 0,
|
||||||
|
"selected_page": window.project.pages[0],
|
||||||
|
"is_cover": False,
|
||||||
|
"paper_thickness_mm": 0.1,
|
||||||
|
"cover_bleed_mm": 3.0,
|
||||||
|
"width_mm": 200,
|
||||||
|
"height_mm": 280,
|
||||||
|
"working_dpi": 96,
|
||||||
|
"export_dpi": 300,
|
||||||
|
"apply_scope": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
from pyPhotoAlbum.mixins.operations import page_ops
|
||||||
|
|
||||||
|
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
|
||||||
|
while hasattr(undecorated_page_setup, "__wrapped__"):
|
||||||
|
undecorated_page_setup = undecorated_page_setup.__wrapped__
|
||||||
|
|
||||||
|
undecorated_page_setup(window, values)
|
||||||
|
|
||||||
|
# Project default updated
|
||||||
|
assert window.project.page_size_mm == (200, 280)
|
||||||
|
# Double spread updated
|
||||||
|
assert window.project.pages[1].layout.size == (400, 280)
|
||||||
|
# Manually sized page IS updated when scope=2
|
||||||
|
assert window.project.pages[2].layout.size == (200, 280)
|
||||||
|
|||||||
@ -53,7 +53,9 @@ class TestPageSetupDialogWithMocks:
|
|||||||
dialog.cover_checkbox = Mock()
|
dialog.cover_checkbox = Mock()
|
||||||
dialog.width_spinbox = Mock()
|
dialog.width_spinbox = Mock()
|
||||||
dialog.height_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
|
# Mock the update spine info method
|
||||||
dialog._update_spine_info = Mock()
|
dialog._update_spine_info = Mock()
|
||||||
@ -187,8 +189,8 @@ class TestPageSetupDialogWithMocks:
|
|||||||
dialog.export_dpi_spinbox = Mock()
|
dialog.export_dpi_spinbox = Mock()
|
||||||
dialog.export_dpi_spinbox.value.return_value = 600
|
dialog.export_dpi_spinbox.value.return_value = 600
|
||||||
|
|
||||||
dialog.set_default_checkbox = Mock()
|
dialog._apply_scope_group = Mock()
|
||||||
dialog.set_default_checkbox.isChecked.return_value = True
|
dialog._apply_scope_group.checkedId.return_value = 2
|
||||||
|
|
||||||
# Get values
|
# Get values
|
||||||
values = dialog.get_values()
|
values = dialog.get_values()
|
||||||
@ -203,7 +205,7 @@ class TestPageSetupDialogWithMocks:
|
|||||||
assert values["height_mm"] == 280.0
|
assert values["height_mm"] == 280.0
|
||||||
assert values["working_dpi"] == 150
|
assert values["working_dpi"] == 150
|
||||||
assert values["export_dpi"] == 600
|
assert values["export_dpi"] == 600
|
||||||
assert values["set_as_default"] is True
|
assert values["apply_scope"] == 2
|
||||||
|
|
||||||
def test_cover_page_width_display(self):
|
def test_cover_page_width_display(self):
|
||||||
"""Test cover page shows full width, not base width"""
|
"""Test cover page shows full width, not base width"""
|
||||||
@ -224,7 +226,9 @@ class TestPageSetupDialogWithMocks:
|
|||||||
dialog.cover_checkbox = Mock()
|
dialog.cover_checkbox = Mock()
|
||||||
dialog.width_spinbox = Mock()
|
dialog.width_spinbox = Mock()
|
||||||
dialog.height_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()
|
dialog._update_spine_info = Mock()
|
||||||
|
|
||||||
# Call _on_page_changed for cover page
|
# Call _on_page_changed for cover page
|
||||||
@ -238,7 +242,8 @@ class TestPageSetupDialogWithMocks:
|
|||||||
# Verify widgets were disabled for cover
|
# Verify widgets were disabled for cover
|
||||||
dialog.width_spinbox.setEnabled.assert_called_with(False)
|
dialog.width_spinbox.setEnabled.assert_called_with(False)
|
||||||
dialog.height_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
|
# Note: Additional widget state tests are covered in test_page_setup_dialog.py
|
||||||
# using qtbot which properly handles Qt widget initialization
|
# using qtbot which properly handles Qt widget initialization
|
||||||
|
|||||||
@ -965,6 +965,222 @@ def test_pdf_exporter_image_downsampling():
|
|||||||
os.remove(img_path)
|
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__":
|
if __name__ == "__main__":
|
||||||
print("Running PDF export tests...\n")
|
print("Running PDF export tests...\n")
|
||||||
|
|
||||||
@ -984,6 +1200,8 @@ if __name__ == "__main__":
|
|||||||
test_pdf_exporter_varying_aspect_ratios()
|
test_pdf_exporter_varying_aspect_ratios()
|
||||||
test_pdf_exporter_rotated_image()
|
test_pdf_exporter_rotated_image()
|
||||||
test_pdf_exporter_image_downsampling()
|
test_pdf_exporter_image_downsampling()
|
||||||
|
test_pdf_exporter_bleed_expands_page_size()
|
||||||
|
test_pdf_exporter_bleed_offsets_content()
|
||||||
|
|
||||||
print("\n✓ All tests passed!")
|
print("\n✓ All tests passed!")
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,52 @@ class TestProject:
|
|||||||
assert project.working_dpi == 300
|
assert project.working_dpi == 300
|
||||||
assert project.page_size_mm == (140, 140) # Default 14cm x 14cm square
|
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):
|
def test_initialization_with_name(self):
|
||||||
"""Test Project initialization with custom name"""
|
"""Test Project initialization with custom name"""
|
||||||
project = Project(name="My Album")
|
project = Project(name="My Album")
|
||||||
|
|||||||
@ -27,6 +27,7 @@ class TestViewWindow(ViewOperationsMixin, QMainWindow):
|
|||||||
self.project.snap_to_edges = True
|
self.project.snap_to_edges = True
|
||||||
self.project.snap_to_guides = True
|
self.project.snap_to_guides = True
|
||||||
self.project.show_snap_lines = True
|
self.project.show_snap_lines = True
|
||||||
|
self.project.show_print_guides = False
|
||||||
self.project.grid_size_mm = 10.0
|
self.project.grid_size_mm = 10.0
|
||||||
self.project.snap_threshold_mm = 5.0
|
self.project.snap_threshold_mm = 5.0
|
||||||
self._update_view_called = False
|
self._update_view_called = False
|
||||||
@ -125,6 +126,44 @@ class TestZoomOperations:
|
|||||||
assert not window._update_view_called
|
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:
|
class TestSnappingToggles:
|
||||||
"""Test snapping toggle operations"""
|
"""Test snapping toggle operations"""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user