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'):
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
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
@ -1011,6 +1008,22 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
result = []
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:
if page_type == 'page':
# Regular page (single or double spread)

View File

@ -62,9 +62,10 @@ class PageOperationsMixin:
page_combo = QComboBox()
for i, page in enumerate(self.project.pages):
page_label = f"Page {page.page_number}"
if page.is_double_spread:
page_label += f" (Double Spread: {page.page_number}-{page.page_number + 1})"
# Use display name helper
page_label = self.project.get_page_display_name(page)
if page.is_double_spread and not page.is_cover:
page_label += f" (Double Spread)"
if page.manually_sized:
page_label += " *"
page_combo.addItem(page_label, i)
@ -78,6 +79,48 @@ class PageOperationsMixin:
page_select_group.setLayout(page_select_layout)
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
size_group = QGroupBox("Page Size")
size_layout = QVBoxLayout()
@ -136,8 +179,21 @@ class PageOperationsMixin:
# Function to update displayed values when page selection changes
def on_page_changed(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
else:
display_width = selected_page.layout.size[0]
@ -145,6 +201,41 @@ class PageOperationsMixin:
width_spinbox.setValue(display_width)
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
page_combo.currentIndexChanged.connect(on_page_changed)
@ -172,10 +263,34 @@ class PageOperationsMixin:
selected_index = page_combo.currentData()
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
width_mm = width_spinbox.value()
height_mm = height_spinbox.value()
# Don't allow manual size changes for covers
if not selected_page.is_cover:
# Check if size actually changed
# For double spreads, compare with base width
if selected_page.is_double_spread:
@ -188,7 +303,7 @@ class PageOperationsMixin:
selected_page.layout.base_width = width_mm
selected_page.layout.size = (width_mm * 2, height_mm)
selected_page.manually_sized = True
print(f"Page {selected_page.page_number} (double spread) updated to {width_mm}×{height_mm} mm per page")
print(f"{self.project.get_page_display_name(selected_page)} (double spread) updated to {width_mm}×{height_mm} mm per page")
else:
old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm))
@ -198,7 +313,7 @@ class PageOperationsMixin:
selected_page.layout.size = (width_mm, height_mm)
selected_page.layout.base_width = width_mm
selected_page.manually_sized = True
print(f"Page {selected_page.page_number} updated to {width_mm}×{height_mm} mm")
print(f"{self.project.get_page_display_name(selected_page)} updated to {width_mm}×{height_mm} mm")
# Update DPI settings
self.project.working_dpi = working_dpi_spinbox.value()
@ -212,7 +327,11 @@ class PageOperationsMixin:
self.update_view()
# 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 selected_page.is_cover:
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)

View File

@ -48,8 +48,11 @@ class PDFExporter:
self.current_pdf_page = 1
try:
# Calculate total pages for progress
total_pages = sum(2 if page.is_double_spread else 1 for page in self.project.pages)
# Calculate total pages for progress (cover counts as 1)
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)
page_width_mm, page_height_mm = self.project.page_size_mm
@ -64,11 +67,18 @@ class PDFExporter:
# Process each page
pages_processed = 0
for page in self.project.pages:
# Get display name for progress
page_name = self.project.get_page_display_name(page)
if progress_callback:
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)
if self.current_pdf_page % 2 == 1:
# Insert blank page
@ -98,6 +108,70 @@ class PDFExporter:
self.warnings.append(f"Export failed: {str(e)}")
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,
page_height_pt: float):
"""Export a single page to PDF"""

View File

@ -3,6 +3,7 @@ Project and page management for pyPhotoAlbum
"""
import os
import math
from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
@ -109,6 +110,12 @@ class Project:
self.export_dpi = 300 # Default export DPI
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
self.embedded_templates: Dict[str, Dict[str, Any]] = {}
@ -122,14 +129,113 @@ class Project:
def add_page(self, page: Page):
"""Add a page to the project"""
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):
"""Remove a page from the project"""
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]]:
"""
Calculate page layout including ghost pages for alignment.
Excludes cover from spread calculations.
Returns:
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)
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
# Ghost pages are needed when a single page would appear on the left
# but should be on the right (odd positions)
@ -198,6 +308,10 @@ class Project:
"working_dpi": self.working_dpi,
"export_dpi": self.export_dpi,
"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,
"pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(),
@ -215,6 +329,10 @@ class Project:
self.working_dpi = data.get("working_dpi", 300)
self.export_dpi = data.get("export_dpi", 300)
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
self.embedded_templates = data.get("embedded_templates", {})