Duncan Tourolle 950aa6bee9
Some checks failed
Python CI / test (push) Successful in 1m21s
Lint / lint (push) Successful in 1m48s
Tests / test (3.10) (push) Failing after 2m16s
Tests / test (3.11) (push) Failing after 1m30s
Tests / test (3.9) (push) Failing after 2m13s
Async loading and use of temp file
2025-11-21 21:13:37 +01:00

602 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
File operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import (
QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton,
QButtonGroup, QLineEdit, QTextEdit
)
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
from pyPhotoAlbum.loading_widget import LoadingWidget
from pyPhotoAlbum.project_serializer import save_to_zip
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
class FileOperationsMixin:
"""Mixin providing file-related operations"""
@ribbon_action(
label="New",
tooltip="Create a new project",
tab="Home",
group="File",
shortcut="Ctrl+N"
)
def new_project(self):
"""Create a new project with initial setup dialog"""
# Create new project setup dialog
dialog = QDialog(self)
dialog.setWindowTitle("New Project Setup")
dialog.setMinimumWidth(450)
layout = QVBoxLayout()
# Project name group
name_group = QGroupBox("Project Name")
name_layout = QVBoxLayout()
name_input = QLineEdit()
name_input.setText("New Project")
name_input.selectAll()
name_layout.addWidget(name_input)
name_group.setLayout(name_layout)
layout.addWidget(name_group)
# Default page size group
size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout()
info_label = QLabel("This will be the default size for all new pages in this project.")
info_label.setWordWrap(True)
info_label.setStyleSheet("font-size: 9pt; color: gray;")
size_layout.addWidget(info_label)
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
width_spinbox = QDoubleSpinBox()
width_spinbox.setRange(10, 1000)
width_spinbox.setValue(140) # Default 14cm
width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout)
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
height_spinbox = QDoubleSpinBox()
height_spinbox.setRange(10, 1000)
height_spinbox.setValue(140) # Default 14cm
height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
# Add common size presets
presets_layout = QHBoxLayout()
presets_layout.addWidget(QLabel("Presets:"))
def set_preset(w, h):
width_spinbox.setValue(w)
height_spinbox.setValue(h)
preset_a4 = QPushButton("A4 (210×297)")
preset_a4.clicked.connect(lambda: set_preset(210, 297))
presets_layout.addWidget(preset_a4)
preset_a5 = QPushButton("A5 (148×210)")
preset_a5.clicked.connect(lambda: set_preset(148, 210))
presets_layout.addWidget(preset_a5)
preset_square = QPushButton("Square (200×200)")
preset_square.clicked.connect(lambda: set_preset(200, 200))
presets_layout.addWidget(preset_square)
presets_layout.addStretch()
size_layout.addLayout(presets_layout)
size_group.setLayout(size_layout)
layout.addWidget(size_group)
# DPI settings group
dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout()
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
working_dpi_spinbox = QSpinBox()
working_dpi_spinbox.setRange(72, 1200)
working_dpi_spinbox.setValue(300)
working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout)
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
export_dpi_spinbox = QSpinBox()
export_dpi_spinbox.setRange(72, 1200)
export_dpi_spinbox.setValue(300)
export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout)
dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group)
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
create_btn = QPushButton("Create Project")
create_btn.clicked.connect(dialog.accept)
create_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(create_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
# Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get values
project_name = name_input.text().strip() or "New Project"
width_mm = width_spinbox.value()
height_mm = height_spinbox.value()
working_dpi = working_dpi_spinbox.value()
export_dpi = export_dpi_spinbox.value()
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Create project with custom settings
self.project = Project(project_name)
self.project.page_size_mm = (width_mm, height_mm)
self.project.working_dpi = working_dpi
self.project.export_dpi = export_dpi
# Set asset resolution context
set_asset_resolution_context(self.project.folder_path)
# Update view
self.update_view()
self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")
print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")
else:
# User cancelled - keep current project
print("New project creation cancelled")
@ribbon_action(
label="Open",
tooltip="Open an existing project",
tab="Home",
group="File",
shortcut="Ctrl+O"
)
def open_project(self):
"""Open an existing project with async loading and progress bar"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Create loading widget if not exists
if not hasattr(self, '_loading_widget'):
self._loading_widget = LoadingWidget(self)
# Show loading widget
self._loading_widget.show_loading("Opening project...")
# Create and configure async loader
self._project_loader = AsyncProjectLoader(file_path)
# Connect signals
self._project_loader.progress_updated.connect(self._on_load_progress)
self._project_loader.load_complete.connect(self._on_load_complete)
self._project_loader.load_failed.connect(self._on_load_failed)
# Start async loading
self._project_loader.start()
def _on_load_progress(self, current: int, total: int, message: str):
"""Handle loading progress updates"""
if hasattr(self, '_loading_widget'):
self._loading_widget.set_progress(current, total)
self._loading_widget.set_status(message)
def _on_load_complete(self, project):
"""Handle successful project load"""
# Cleanup old project if it exists
if hasattr(self, 'project') and self.project:
self.project.cleanup()
# Set new project
self.project = project
self.gl_widget.current_page_index = 0 # Reset to first page
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
# Update view (this will trigger progressive image loading)
self.update_view()
self.show_status(f"Project opened: {project.name}")
print(f"Successfully loaded project: {project.name}")
def _on_load_failed(self, error_msg: str):
"""Handle project load failure"""
# Hide loading widget
if hasattr(self, '_loading_widget'):
self._loading_widget.hide_loading()
error_msg = f"Failed to open project: {error_msg}"
self.show_status(error_msg)
self.show_error("Load Failed", error_msg)
print(error_msg)
@ribbon_action(
label="Save",
tooltip="Save the current project",
tab="Home",
group="File",
shortcut="Ctrl+S"
)
def save_project(self):
"""Save the current project"""
file_path, _ = QFileDialog.getSaveFileName(
self,
"Save Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Saving project to: {file_path}")
# Save project to ZIP
success, error = save_to_zip(self.project, file_path)
if success:
self.show_status(f"Project saved: {file_path}")
print(f"Successfully saved project to: {file_path}")
else:
error_msg = f"Failed to save project: {error}"
self.show_status(error_msg)
print(error_msg)
@ribbon_action(
label="Heal Assets",
tooltip="Reconnect missing image assets",
tab="Home",
group="File"
)
def heal_assets(self):
"""Open the asset healing dialog to reconnect missing images"""
dialog = AssetHealDialog(self.project, self)
dialog.exec()
# Update the view to reflect any changes
self.update_view()
@ribbon_action(
label="Project Settings",
tooltip="Configure project-wide page size and defaults",
tab="Home",
group="File"
)
@numerical_input(
fields=[
('width', 'Width', 'mm', 10, 1000),
('height', 'Height', 'mm', 10, 1000)
]
)
def project_settings(self):
"""Configure project-wide settings including default page size"""
# Create dialog
dialog = QDialog(self)
dialog.setWindowTitle("Project Settings")
dialog.setMinimumWidth(500)
layout = QVBoxLayout()
# Page size group
size_group = QGroupBox("Default Page Size")
size_layout = QVBoxLayout()
# Width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Width:"))
width_spinbox = QDoubleSpinBox()
width_spinbox.setRange(10, 1000)
width_spinbox.setValue(self.project.page_size_mm[0])
width_spinbox.setSuffix(" mm")
width_layout.addWidget(width_spinbox)
size_layout.addLayout(width_layout)
# Height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
height_spinbox = QDoubleSpinBox()
height_spinbox.setRange(10, 1000)
height_spinbox.setValue(self.project.page_size_mm[1])
height_spinbox.setSuffix(" mm")
height_layout.addWidget(height_spinbox)
size_layout.addLayout(height_layout)
size_group.setLayout(size_layout)
layout.addWidget(size_group)
# DPI settings group
dpi_group = QGroupBox("DPI Settings")
dpi_layout = QVBoxLayout()
# Working DPI
working_dpi_layout = QHBoxLayout()
working_dpi_layout.addWidget(QLabel("Working DPI:"))
working_dpi_spinbox = QSpinBox()
working_dpi_spinbox.setRange(72, 1200)
working_dpi_spinbox.setValue(self.project.working_dpi)
working_dpi_layout.addWidget(working_dpi_spinbox)
dpi_layout.addLayout(working_dpi_layout)
# Export DPI
export_dpi_layout = QHBoxLayout()
export_dpi_layout.addWidget(QLabel("Export DPI:"))
export_dpi_spinbox = QSpinBox()
export_dpi_spinbox.setRange(72, 1200)
export_dpi_spinbox.setValue(self.project.export_dpi)
export_dpi_layout.addWidget(export_dpi_spinbox)
dpi_layout.addLayout(export_dpi_layout)
dpi_group.setLayout(dpi_layout)
layout.addWidget(dpi_group)
# Content scaling options (only if pages exist and size is changing)
scaling_group = None
scaling_buttons = None
if self.project.pages:
scaling_group = QGroupBox("Apply to Existing Pages")
scaling_layout = QVBoxLayout()
info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)")
info_label.setWordWrap(True)
scaling_layout.addWidget(info_label)
scaling_buttons = QButtonGroup()
proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")
proportional_radio.setToolTip("Scale content uniformly to fit the new page size")
scaling_buttons.addButton(proportional_radio, 0)
scaling_layout.addWidget(proportional_radio)
stretch_radio = QRadioButton("Resize on both axes (stretch)")
stretch_radio.setToolTip("Scale width and height independently")
scaling_buttons.addButton(stretch_radio, 1)
scaling_layout.addWidget(stretch_radio)
reposition_radio = QRadioButton("Keep content size, reposition to center")
reposition_radio.setToolTip("Maintain element sizes but center them on new page")
scaling_buttons.addButton(reposition_radio, 2)
scaling_layout.addWidget(reposition_radio)
none_radio = QRadioButton("Don't adjust content (page size only)")
none_radio.setToolTip("Only change page size, leave content as-is")
none_radio.setChecked(True) # Default
scaling_buttons.addButton(none_radio, 3)
scaling_layout.addWidget(none_radio)
scaling_group.setLayout(scaling_layout)
layout.addWidget(scaling_group)
# Buttons
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(ok_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
# Show dialog
if dialog.exec() == QDialog.DialogCode.Accepted:
# Get new values
new_width = width_spinbox.value()
new_height = height_spinbox.value()
new_working_dpi = working_dpi_spinbox.value()
new_export_dpi = export_dpi_spinbox.value()
# Determine scaling mode
scaling_mode = 'none'
if scaling_buttons:
selected_id = scaling_buttons.checkedId()
modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'}
scaling_mode = modes.get(selected_id, 'none')
# Apply settings
old_size = self.project.page_size_mm
self.project.page_size_mm = (new_width, new_height)
self.project.working_dpi = new_working_dpi
self.project.export_dpi = new_export_dpi
# Update existing pages (exclude manually sized ones)
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.update_view()
self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)
print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")
def _apply_page_size_to_project(self, old_size, new_size, scaling_mode):
"""
Apply new page size to all non-manually-sized pages
Args:
old_size: Old page size (width, height) in mm
new_size: New page size (width, height) in mm
scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'
"""
old_width, old_height = old_size
new_width, new_height = new_size
width_ratio = new_width / old_width if old_width > 0 else 1.0
height_ratio = new_height / old_height if old_height > 0 else 1.0
for page in self.project.pages:
# Skip manually sized pages
if page.manually_sized:
continue
# Update page size
old_page_width, old_page_height = page.layout.size
# For double spreads, maintain the 2x multiplier
if page.is_double_spread:
page.layout.size = (new_width * 2, new_height)
else:
page.layout.size = (new_width, new_height)
# Apply content scaling based on mode
if scaling_mode == 'proportional':
# Use smallest ratio to fit content
scale = min(width_ratio, height_ratio)
self._scale_page_elements(page, scale, scale)
elif scaling_mode == 'stretch':
# Scale independently on each axis
self._scale_page_elements(page, width_ratio, height_ratio)
elif scaling_mode == 'reposition':
# Keep size, center content
self._reposition_page_elements(page, old_size, new_size)
# 'none' - do nothing to elements
def _scale_page_elements(self, page, x_scale, y_scale):
"""
Scale all elements on a page
Args:
page: Page object
x_scale: Horizontal scale factor
y_scale: Vertical scale factor
"""
for element in page.layout.elements:
# Scale position
x, y = element.position
element.position = (x * x_scale, y * y_scale)
# Scale size
width, height = element.size
element.size = (width * x_scale, height * y_scale)
def _reposition_page_elements(self, page, old_size, new_size):
"""
Reposition elements to center them on the new page size
Args:
page: Page object
old_size: Old page size (width, height) in mm
new_size: New page size (width, height) in mm
"""
old_width, old_height = old_size
new_width, new_height = new_size
x_offset = (new_width - old_width) / 2.0
y_offset = (new_height - old_height) / 2.0
for element in page.layout.elements:
x, y = element.position
element.position = (x + x_offset, y + y_offset)
@ribbon_action(
label="Export PDF",
tooltip="Export project to PDF",
tab="Export",
group="Export"
)
def export_pdf(self):
"""Export project to PDF using async backend (non-blocking)"""
# Check if we have pages to export
if not self.project or not self.project.pages:
self.show_status("No pages to export")
return
# Show file save dialog
file_path, _ = QFileDialog.getSaveFileName(
self,
"Export to PDF",
"",
"PDF Files (*.pdf);;All Files (*)"
)
if not file_path:
return
# Ensure .pdf extension
if not file_path.lower().endswith('.pdf'):
file_path += '.pdf'
# Use async PDF export (non-blocking, UI stays responsive)
success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300)
if success:
self.show_status("PDF export started...", 2000)
else:
self.show_status("PDF export failed to start", 3000)
@ribbon_action(
label="About",
tooltip="About pyPhotoAlbum and data format version",
tab="Home",
group="File"
)
def show_about(self):
"""Show about dialog with version information"""
dialog = QDialog(self)
dialog.setWindowTitle("About pyPhotoAlbum")
dialog.setMinimumWidth(600)
dialog.setMinimumHeight(400)
layout = QVBoxLayout()
# Application info
app_info = QLabel("<h2>pyPhotoAlbum</h2>")
app_info.setWordWrap(True)
layout.addWidget(app_info)
description = QLabel(
"A photo album layout and design application with advanced "
"page composition features and PDF export capabilities."
)
description.setWordWrap(True)
layout.addWidget(description)
# Version information
version_text = QTextEdit()
version_text.setReadOnly(True)
version_text.setPlainText(format_version_info())
layout.addWidget(version_text)
# Close button
close_button = QPushButton("Close")
close_button.clicked.connect(dialog.accept)
layout.addWidget(close_button)
dialog.setLayout(layout)
dialog.exec()