Duncan Tourolle c0a6148f58
Some checks failed
Python CI / test (push) Successful in 1m8s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Failing after 54s
Tests / test (3.11) (push) Failing after 50s
Tests / test (3.9) (push) Failing after 54s
fixed installer, fixed issue with loading wrong paths. Added file schema versioning
2025-11-11 13:52:33 +01:00

592 lines
21 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
from pyPhotoAlbum.decorators import ribbon_action, numerical_input
from pyPhotoAlbum.project import Project
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"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QLineEdit
# 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()
# 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
from pyPhotoAlbum.models import 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"""
from pyPhotoAlbum.project_serializer import load_from_zip
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Project",
"",
"pyPhotoAlbum Projects (*.ppz);;All Files (*)"
)
if file_path:
print(f"Opening project: {file_path}")
# Load project from ZIP
project, error = load_from_zip(file_path)
if project:
self.project = project
self.current_page_index = 0 # Reset to first page
self.update_view()
self.show_status(f"Project opened: {file_path}")
print(f"Successfully loaded project: {project.name}")
else:
error_msg = f"Failed to open project: {error}"
self.show_status(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"""
from pyPhotoAlbum.project_serializer import save_to_zip
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"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
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"""
from PyQt6.QtWidgets import QProgressDialog
from PyQt6.QtCore import Qt
from pyPhotoAlbum.pdf_exporter import PDFExporter
# 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'
# Calculate total pages for progress
total_pages = sum(2 if page.is_double_spread else 1 for page in self.project.pages)
# Create progress dialog
progress = QProgressDialog("Exporting to PDF...", "Cancel", 0, total_pages, self)
progress.setWindowModality(Qt.WindowModality.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
# Progress callback
def update_progress(current, total, message):
progress.setLabelText(message)
progress.setValue(current)
if progress.wasCanceled():
return False
return True
# Export to PDF
exporter = PDFExporter(self.project)
success, warnings = exporter.export(file_path, update_progress)
progress.close()
if success:
message = f"PDF exported successfully to {file_path}"
if warnings:
message += f"\n\nWarnings:\n" + "\n".join(warnings)
self.show_status(message)
print(message)
else:
error_message = f"PDF export failed"
if warnings:
error_message += f":\n" + "\n".join(warnings)
self.show_status(error_message)
print(error_message)
@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"""
from PyQt6.QtWidgets import QTextEdit
from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
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()