Add cover page settings

This commit is contained in:
Duncan Tourolle 2025-10-29 20:50:05 +01:00
parent 4bfaa63aae
commit aa02506d4c
4 changed files with 362 additions and 38 deletions

View File

@ -994,9 +994,6 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
if not hasattr(main_window, 'project'): if not hasattr(main_window, 'project'):
return [] return []
# Get page layout with ghosts from project
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
dpi = main_window.project.working_dpi dpi = main_window.project.working_dpi
# Use project's page_spacing_mm setting (default is 10mm = 1cm) # Use project's page_spacing_mm setting (default is 10mm = 1cm)
@ -1011,6 +1008,22 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
result = [] result = []
current_y = top_margin_px # Initial top offset in pixels (not screen pixels) current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
# First, render cover if it exists
for page in main_window.project.pages:
if page.is_cover:
result.append(('page', page, current_y))
# Calculate cover height in pixels
page_height_mm = page.layout.size[1]
page_height_px = page_height_mm * dpi / 25.4
# Move to next position (add height + spacing)
current_y += page_height_px + spacing_px
break # Only one cover allowed
# Get page layout with ghosts from project (this excludes cover)
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
for page_type, page_obj, logical_pos in layout_with_ghosts: for page_type, page_obj, logical_pos in layout_with_ghosts:
if page_type == 'page': if page_type == 'page':
# Regular page (single or double spread) # Regular page (single or double spread)

View File

@ -62,9 +62,10 @@ class PageOperationsMixin:
page_combo = QComboBox() page_combo = QComboBox()
for i, page in enumerate(self.project.pages): for i, page in enumerate(self.project.pages):
page_label = f"Page {page.page_number}" # Use display name helper
if page.is_double_spread: page_label = self.project.get_page_display_name(page)
page_label += f" (Double Spread: {page.page_number}-{page.page_number + 1})" if page.is_double_spread and not page.is_cover:
page_label += f" (Double Spread)"
if page.manually_sized: if page.manually_sized:
page_label += " *" page_label += " *"
page_combo.addItem(page_label, i) page_combo.addItem(page_label, i)
@ -78,6 +79,48 @@ class PageOperationsMixin:
page_select_group.setLayout(page_select_layout) page_select_group.setLayout(page_select_layout)
layout.addWidget(page_select_group) layout.addWidget(page_select_group)
# Cover settings group (only show if first page is selected)
cover_group = QGroupBox("Cover Settings")
cover_layout = QVBoxLayout()
# Cover checkbox
cover_checkbox = QCheckBox("Designate as Cover")
cover_checkbox.setToolTip("Mark this page as the book cover with wrap-around front/spine/back")
cover_layout.addWidget(cover_checkbox)
# Paper thickness
thickness_layout = QHBoxLayout()
thickness_layout.addWidget(QLabel("Paper Thickness:"))
thickness_spinbox = QDoubleSpinBox()
thickness_spinbox.setRange(0.05, 1.0)
thickness_spinbox.setSingleStep(0.05)
thickness_spinbox.setValue(self.project.paper_thickness_mm)
thickness_spinbox.setSuffix(" mm")
thickness_spinbox.setToolTip("Thickness of paper for spine calculation")
thickness_layout.addWidget(thickness_spinbox)
cover_layout.addLayout(thickness_layout)
# Bleed margin
bleed_layout = QHBoxLayout()
bleed_layout.addWidget(QLabel("Bleed Margin:"))
bleed_spinbox = QDoubleSpinBox()
bleed_spinbox.setRange(0, 10)
bleed_spinbox.setSingleStep(0.5)
bleed_spinbox.setValue(self.project.cover_bleed_mm)
bleed_spinbox.setSuffix(" mm")
bleed_spinbox.setToolTip("Extra margin around cover for printing bleed")
bleed_layout.addWidget(bleed_spinbox)
cover_layout.addLayout(bleed_layout)
# Calculated spine width display
spine_info_label = QLabel()
spine_info_label.setStyleSheet("font-size: 9pt; color: #0066cc; padding: 5px;")
spine_info_label.setWordWrap(True)
cover_layout.addWidget(spine_info_label)
cover_group.setLayout(cover_layout)
layout.addWidget(cover_group)
# Page size group # Page size group
size_group = QGroupBox("Page Size") size_group = QGroupBox("Page Size")
size_layout = QVBoxLayout() size_layout = QVBoxLayout()
@ -136,14 +179,62 @@ class PageOperationsMixin:
# Function to update displayed values when page selection changes # Function to update displayed values when page selection changes
def on_page_changed(index): def on_page_changed(index):
selected_page = self.project.pages[index] selected_page = self.project.pages[index]
# Get base width (accounting for double spreads)
if selected_page.is_double_spread: # Show/hide cover settings based on page selection
is_first_page = (index == 0)
cover_group.setVisible(is_first_page)
# Update cover checkbox
if is_first_page:
cover_checkbox.setChecked(selected_page.is_cover)
update_spine_info()
# Get base width (accounting for double spreads and covers)
if selected_page.is_cover:
# For covers, show the full calculated width
display_width = selected_page.layout.size[0]
elif selected_page.is_double_spread:
display_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2 display_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
else: else:
display_width = selected_page.layout.size[0] display_width = selected_page.layout.size[0]
width_spinbox.setValue(display_width) width_spinbox.setValue(display_width)
height_spinbox.setValue(selected_page.layout.size[1]) height_spinbox.setValue(selected_page.layout.size[1])
# Disable size editing for covers (auto-calculated)
if selected_page.is_cover:
width_spinbox.setEnabled(False)
height_spinbox.setEnabled(False)
set_default_checkbox.setEnabled(False)
else:
width_spinbox.setEnabled(True)
height_spinbox.setEnabled(True)
set_default_checkbox.setEnabled(True)
def update_spine_info():
"""Update the spine information display"""
if cover_checkbox.isChecked():
# Calculate spine width with current settings
content_pages = sum(p.get_page_count() for p in self.project.pages if not p.is_cover)
import math
sheets = math.ceil(content_pages / 4)
spine_width = sheets * thickness_spinbox.value() * 2
page_width = self.project.page_size_mm[0]
total_width = (page_width * 2) + spine_width + (bleed_spinbox.value() * 2)
spine_info_label.setText(
f"Cover Layout: Front ({page_width:.0f}mm) + Spine ({spine_width:.2f}mm) + "
f"Back ({page_width:.0f}mm) + Bleed ({bleed_spinbox.value():.1f}mm × 2)\n"
f"Total Width: {total_width:.1f}mm | Content Pages: {content_pages} | Sheets: {sheets}"
)
else:
spine_info_label.setText("")
# Connect signals
cover_checkbox.stateChanged.connect(lambda: update_spine_info())
thickness_spinbox.valueChanged.connect(lambda: update_spine_info())
bleed_spinbox.valueChanged.connect(lambda: update_spine_info())
# Connect page selection change # Connect page selection change
page_combo.currentIndexChanged.connect(on_page_changed) page_combo.currentIndexChanged.connect(on_page_changed)
@ -172,33 +263,57 @@ class PageOperationsMixin:
selected_index = page_combo.currentData() selected_index = page_combo.currentData()
selected_page = self.project.pages[selected_index] selected_page = self.project.pages[selected_index]
# Update project cover settings
self.project.paper_thickness_mm = thickness_spinbox.value()
self.project.cover_bleed_mm = bleed_spinbox.value()
# Handle cover designation (only for first page)
if selected_index == 0:
was_cover = selected_page.is_cover
is_cover = cover_checkbox.isChecked()
if was_cover != is_cover:
selected_page.is_cover = is_cover
self.project.has_cover = is_cover
if is_cover:
# Calculate and set cover dimensions
self.project.update_cover_dimensions()
print(f"Page 1 designated as cover")
else:
# Restore normal page size
selected_page.layout.size = self.project.page_size_mm
print(f"Cover removed from page 1")
# Get new values # Get new values
width_mm = width_spinbox.value() width_mm = width_spinbox.value()
height_mm = height_spinbox.value() height_mm = height_spinbox.value()
# Check if size actually changed # Don't allow manual size changes for covers
# For double spreads, compare with base width if not selected_page.is_cover:
if selected_page.is_double_spread: # Check if size actually changed
old_base_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2 # For double spreads, compare with base width
old_height = selected_page.layout.size[1] if selected_page.is_double_spread:
size_changed = (old_base_width != width_mm or old_height != height_mm) old_base_width = selected_page.layout.base_width if hasattr(selected_page.layout, 'base_width') else selected_page.layout.size[0] / 2
old_height = selected_page.layout.size[1]
if size_changed: size_changed = (old_base_width != width_mm or old_height != height_mm)
# Update double spread
selected_page.layout.base_width = width_mm if size_changed:
selected_page.layout.size = (width_mm * 2, height_mm) # Update double spread
selected_page.manually_sized = True selected_page.layout.base_width = width_mm
print(f"Page {selected_page.page_number} (double spread) updated to {width_mm}×{height_mm} mm per page") selected_page.layout.size = (width_mm * 2, height_mm)
else: selected_page.manually_sized = True
old_size = selected_page.layout.size print(f"{self.project.get_page_display_name(selected_page)} (double spread) updated to {width_mm}×{height_mm} mm per page")
size_changed = (old_size != (width_mm, height_mm)) else:
old_size = selected_page.layout.size
if size_changed: size_changed = (old_size != (width_mm, height_mm))
# Update single page
selected_page.layout.size = (width_mm, height_mm) if size_changed:
selected_page.layout.base_width = width_mm # Update single page
selected_page.manually_sized = True selected_page.layout.size = (width_mm, height_mm)
print(f"Page {selected_page.page_number} updated to {width_mm}×{height_mm} mm") selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(f"{self.project.get_page_display_name(selected_page)} updated to {width_mm}×{height_mm} mm")
# Update DPI settings # Update DPI settings
self.project.working_dpi = working_dpi_spinbox.value() self.project.working_dpi = working_dpi_spinbox.value()
@ -212,9 +327,13 @@ class PageOperationsMixin:
self.update_view() self.update_view()
# Build status message # Build status message
status_msg = f"Page {selected_page.page_number} size: {width_mm}×{height_mm} mm" page_name = self.project.get_page_display_name(selected_page)
if set_default_checkbox.isChecked(): if selected_page.is_cover:
status_msg += " (set as default)" status_msg = f"{page_name} updated"
else:
status_msg = f"{page_name} size: {width_mm}×{height_mm} mm"
if set_default_checkbox.isChecked():
status_msg += " (set as default)"
self.show_status(status_msg, 2000) self.show_status(status_msg, 2000)
@ribbon_action( @ribbon_action(

View File

@ -48,8 +48,11 @@ class PDFExporter:
self.current_pdf_page = 1 self.current_pdf_page = 1
try: try:
# Calculate total pages for progress # Calculate total pages for progress (cover counts as 1)
total_pages = sum(2 if page.is_double_spread else 1 for page in self.project.pages) total_pages = sum(
1 if page.is_cover else (2 if page.is_double_spread else 1)
for page in self.project.pages
)
# 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
@ -64,11 +67,18 @@ class PDFExporter:
# Process each page # Process each page
pages_processed = 0 pages_processed = 0
for page in self.project.pages: for page in self.project.pages:
# Get display name for progress
page_name = self.project.get_page_display_name(page)
if progress_callback: if progress_callback:
progress_callback(pages_processed, total_pages, progress_callback(pages_processed, total_pages,
f"Exporting page {page.page_number}...") f"Exporting {page_name}...")
if page.is_double_spread: if page.is_cover:
# Export cover as single page with wrap-around design
self._export_cover(c, page, page_width_pt, page_height_pt)
pages_processed += 1
elif page.is_double_spread:
# Ensure spread starts on even page (left page of facing pair) # Ensure spread starts on even page (left page of facing pair)
if self.current_pdf_page % 2 == 1: if self.current_pdf_page % 2 == 1:
# Insert blank page # Insert blank page
@ -98,6 +108,70 @@ class PDFExporter:
self.warnings.append(f"Export failed: {str(e)}") self.warnings.append(f"Export failed: {str(e)}")
return False, self.warnings return False, self.warnings
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
"""
Export a cover page to PDF.
Cover has different dimensions (wrap-around: front + spine + back + bleed).
"""
# Get cover dimensions (already calculated in page.layout.size)
cover_width_mm, cover_height_mm = page.layout.size
# Convert to PDF points
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
# Create a new page with cover dimensions
c.setPageSize((cover_width_pt, cover_height_pt))
# Render all elements on the cover
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
# Draw guide lines for front/spine/back zones
self._draw_cover_guides(c, cover_width_pt, cover_height_pt)
c.showPage() # Finish cover page
self.current_pdf_page += 1
# Reset page size for content pages
c.setPageSize((page_width_pt, page_height_pt))
def _draw_cover_guides(self, c: canvas.Canvas, cover_width_pt: float, cover_height_pt: float):
"""Draw guide lines for cover zones (front/spine/back)"""
from reportlab.lib.colors import lightgrey
# Calculate zone boundaries
bleed_pt = self.project.cover_bleed_mm * self.MM_TO_POINTS
page_width_pt = self.project.page_size_mm[0] * self.MM_TO_POINTS
spine_width_pt = self.project.calculate_spine_width() * self.MM_TO_POINTS
# Zone boundaries (from left to right)
# Bleed | Back | Spine | Front | Bleed
back_start = bleed_pt
spine_start = bleed_pt + page_width_pt
front_start = bleed_pt + page_width_pt + spine_width_pt
front_end = bleed_pt + page_width_pt + spine_width_pt + page_width_pt
# Draw dashed lines at zone boundaries
c.saveState()
c.setStrokeColor(lightgrey)
c.setDash(3, 3)
c.setLineWidth(0.5)
# Back/Spine boundary
c.line(spine_start, 0, spine_start, cover_height_pt)
# Spine/Front boundary
c.line(front_start, 0, front_start, cover_height_pt)
# Bleed boundaries (outer edges)
if bleed_pt > 0:
c.line(back_start, 0, back_start, cover_height_pt)
c.line(front_end, 0, front_end, cover_height_pt)
c.restoreState()
def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float, def _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float): page_height_pt: float):
"""Export a single page to PDF""" """Export a single page to PDF"""

View File

@ -3,6 +3,7 @@ Project and page management for pyPhotoAlbum
""" """
import os import os
import math
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.commands import CommandHistory
@ -109,6 +110,12 @@ class Project:
self.export_dpi = 300 # Default export DPI self.export_dpi = 300 # Default export DPI
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm) self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
# Cover configuration
self.has_cover = False # Whether project has a cover
self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm)
self.cover_bleed_mm = 0.0 # Bleed margin for cover (default 0mm)
self.binding_type = "saddle_stitch" # Binding type for spine calculation
# 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]] = {}
@ -122,14 +129,113 @@ class Project:
def add_page(self, page: Page): def add_page(self, page: Page):
"""Add a page to the project""" """Add a page to the project"""
self.pages.append(page) self.pages.append(page)
# Update cover dimensions if we have a cover
if self.has_cover and self.pages:
self.update_cover_dimensions()
def remove_page(self, page: Page): def remove_page(self, page: Page):
"""Remove a page from the project""" """Remove a page from the project"""
self.pages.remove(page) self.pages.remove(page)
# Update cover dimensions if we have a cover
if self.has_cover and self.pages:
self.update_cover_dimensions()
def calculate_spine_width(self) -> float:
"""
Calculate spine width based on page count and paper thickness.
For saddle stitch binding:
- Each sheet = 4 pages (2 pages per side when folded)
- Spine width = (Number of sheets × Paper thickness × 2)
Returns:
Spine width in mm
"""
if not self.has_cover:
return 0.0
# Count content pages (excluding cover)
content_page_count = sum(
page.get_page_count()
for page in self.pages
if not page.is_cover
)
if self.binding_type == "saddle_stitch":
# Calculate number of sheets (each sheet = 4 pages)
sheets = math.ceil(content_page_count / 4)
# Spine width = sheets × paper thickness × 2 (folded)
spine_width = sheets * self.paper_thickness_mm * 2
return spine_width
return 0.0
def update_cover_dimensions(self):
"""
Update cover page dimensions based on current page count and settings.
Calculates: Front width + Spine width + Back width + Bleed margins
"""
if not self.has_cover or not self.pages:
return
# Find cover page (should be first page)
cover_page = None
for page in self.pages:
if page.is_cover:
cover_page = page
break
if not cover_page:
return
# Get standard page dimensions
page_width_mm, page_height_mm = self.page_size_mm
# Calculate spine width
spine_width = self.calculate_spine_width()
# Calculate cover dimensions
# Cover = Front + Spine + Back + Bleed on all sides
cover_width = (page_width_mm * 2) + spine_width + (self.cover_bleed_mm * 2)
cover_height = page_height_mm + (self.cover_bleed_mm * 2)
# Update cover page layout
cover_page.layout.size = (cover_width, cover_height)
cover_page.layout.base_width = page_width_mm # Store base width for reference
cover_page.manually_sized = True # Mark as manually sized
print(f"Cover dimensions updated: {cover_width:.1f} × {cover_height:.1f} mm "
f"(Front: {page_width_mm}, Spine: {spine_width:.2f}, Back: {page_width_mm}, "
f"Bleed: {self.cover_bleed_mm})")
def get_page_display_name(self, page: Page) -> str:
"""
Get display name for a page.
Args:
page: The page to get the display name for
Returns:
Display name like "Cover", "Page 1", "Pages 1-2", etc.
"""
if page.is_cover:
return "Cover"
# Calculate adjusted page number (excluding cover from count)
adjusted_num = page.page_number
if self.has_cover:
# Subtract 1 to account for cover
adjusted_num = page.page_number - 1
if page.is_double_spread:
return f"Pages {adjusted_num}-{adjusted_num + 1}"
else:
return f"Page {adjusted_num}"
def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]: def calculate_page_layout_with_ghosts(self) -> List[Tuple[str, Any, int]]:
""" """
Calculate page layout including ghost pages for alignment. Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations.
Returns: Returns:
List of tuples (page_type, page_or_ghost, logical_position) List of tuples (page_type, page_or_ghost, logical_position)
@ -143,6 +249,10 @@ class Project:
current_position = 1 # Start at position 1 (right page) current_position = 1 # Start at position 1 (right page)
for page in self.pages: for page in self.pages:
# Skip cover in spread calculations
if page.is_cover:
# Cover is rendered separately, doesn't participate in spreads
continue
# Check if we need a ghost page for alignment # Check if we need a ghost page for alignment
# Ghost pages are needed when a single page would appear on the left # Ghost pages are needed when a single page would appear on the left
# but should be on the right (odd positions) # but should be on the right (odd positions)
@ -198,6 +308,10 @@ class Project:
"working_dpi": self.working_dpi, "working_dpi": self.working_dpi,
"export_dpi": self.export_dpi, "export_dpi": self.export_dpi,
"page_spacing_mm": self.page_spacing_mm, "page_spacing_mm": self.page_spacing_mm,
"has_cover": self.has_cover,
"paper_thickness_mm": self.paper_thickness_mm,
"cover_bleed_mm": self.cover_bleed_mm,
"binding_type": self.binding_type,
"embedded_templates": self.embedded_templates, "embedded_templates": self.embedded_templates,
"pages": [page.serialize() for page in self.pages], "pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(), "history": self.history.serialize(),
@ -215,6 +329,10 @@ class Project:
self.working_dpi = data.get("working_dpi", 300) self.working_dpi = data.get("working_dpi", 300)
self.export_dpi = data.get("export_dpi", 300) self.export_dpi = data.get("export_dpi", 300)
self.page_spacing_mm = data.get("page_spacing_mm", 10.0) self.page_spacing_mm = data.get("page_spacing_mm", 10.0)
self.has_cover = data.get("has_cover", False)
self.paper_thickness_mm = data.get("paper_thickness_mm", 0.2)
self.cover_bleed_mm = data.get("cover_bleed_mm", 0.0)
self.binding_type = data.get("binding_type", "saddle_stitch")
# Deserialize embedded templates # Deserialize embedded templates
self.embedded_templates = data.get("embedded_templates", {}) self.embedded_templates = data.get("embedded_templates", {})