Compare commits

...

3 Commits

Author SHA1 Message Date
375e87ec84 Fix ghost pages having no size
All checks were successful
Python CI / test (push) Successful in 59s
Lint / lint (push) Successful in 1m8s
Tests / test (3.10) (push) Successful in 45s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 44s
2025-10-29 20:50:33 +01:00
aa02506d4c Add cover page settings 2025-10-29 20:50:05 +01:00
4bfaa63aae Fix rotation 2025-10-29 20:30:57 +01:00
7 changed files with 596 additions and 50 deletions

View File

@ -348,6 +348,49 @@ class RotateElementCommand(Command):
)
class AdjustImageCropCommand(Command):
"""Command for adjusting image crop/pan within frame"""
def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple):
self.element = element
self.old_crop_info = old_crop_info
self.new_crop_info = new_crop_info
def execute(self):
"""Apply new crop info"""
self.element.crop_info = self.new_crop_info
def undo(self):
"""Restore old crop info"""
self.element.crop_info = self.old_crop_info
def redo(self):
"""Apply new crop info again"""
self.execute()
def serialize(self) -> Dict[str, Any]:
"""Serialize to dictionary"""
return {
"type": "adjust_image_crop",
"element": self.element.serialize(),
"old_crop_info": self.old_crop_info,
"new_crop_info": self.new_crop_info
}
@staticmethod
def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand':
"""Deserialize from dictionary"""
elem_data = data["element"]
element = ImageData()
element.deserialize(elem_data)
return AdjustImageCropCommand(
element,
tuple(data["old_crop_info"]),
tuple(data["new_crop_info"])
)
class AlignElementsCommand(Command):
"""Command for aligning multiple elements"""
@ -717,6 +760,8 @@ class CommandHistory:
return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else:
print(f"Warning: Unknown command type: {cmd_type}")
return None

View File

@ -41,6 +41,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.rotation_start_angle = None # Starting rotation angle
self.rotation_snap_angle = 15 # Default snap angle in degrees
# Image pan state (for panning image within frame with Control key)
self.image_pan_mode = False # True when Control+dragging an ImageData element
self.image_pan_start_crop = None # Starting crop_info when pan begins
# Zoom and pan state
self.zoom_level = 1.0
self.pan_offset = [0, 0]
@ -209,6 +213,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
center_x = x + w / 2
center_y = y + h / 2
# Apply rotation if element is rotated
from OpenGL.GL import glPushMatrix, glPopMatrix, glTranslatef, glRotatef
if self.selected_element.rotation != 0:
glPushMatrix()
glTranslatef(center_x, center_y, 0)
glRotatef(self.selected_element.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now draw as if at origin
x, y = 0, 0
# Draw selection border
if self.rotation_mode:
glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode
@ -292,6 +306,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
glVertex2f(hx, hy + handle_size)
glEnd()
# Restore matrix if we applied rotation
if self.selected_element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay"""
from PyQt6.QtGui import QPainter, QFont, QColor, QPen
@ -448,12 +466,25 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
element = self._get_element_at(x, y)
if element:
if ctrl_pressed:
# Check if Control is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor) # Show move cursor
print(f"Entered image pan mode for {element}")
elif ctrl_pressed:
# Multi-select mode (for non-ImageData or when not dragging)
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
else:
# Normal drag mode
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
@ -493,7 +524,57 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
return
if self.selected_element:
if self.rotation_mode:
if self.image_pan_mode:
# Image pan mode - adjust crop_info to pan image within frame
if not isinstance(self.selected_element, ImageData):
return
# Calculate mouse movement in screen pixels
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
# Get element size in page-local coordinates
elem_w, elem_h = self.selected_element.size
# Convert screen movement to normalized crop coordinates
# Negative because moving mouse right should pan image left (show more of right side)
# Scale by zoom level and element size
crop_dx = -screen_dx / (elem_w * self.zoom_level)
crop_dy = -screen_dy / (elem_h * self.zoom_level)
# Get starting crop info
start_crop = self.image_pan_start_crop
if not start_crop:
start_crop = (0, 0, 1, 1)
# Calculate new crop_info
crop_width = start_crop[2] - start_crop[0]
crop_height = start_crop[3] - start_crop[1]
new_x_min = start_crop[0] + crop_dx
new_y_min = start_crop[1] + crop_dy
new_x_max = new_x_min + crop_width
new_y_max = new_y_min + crop_height
# Clamp to valid range (0-1) to prevent panning beyond image boundaries
if new_x_min < 0:
new_x_min = 0
new_x_max = crop_width
if new_x_max > 1:
new_x_max = 1
new_x_min = 1 - crop_width
if new_y_min < 0:
new_y_min = 0
new_y_max = crop_height
if new_y_max > 1:
new_y_max = 1
new_y_min = 1 - crop_height
# Update element's crop_info
self.selected_element.crop_info = (new_x_min, new_y_min, new_x_max, new_y_max)
elif self.rotation_mode:
# Calculate rotation angle from mouse position relative to element center
import math
@ -528,6 +609,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.selected_element.rotation = angle
# Show current angle in status bar
main_window = self.window()
if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
@ -591,11 +673,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.drag_start_element_pos = None
self.resize_handle = None
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor) # Reset cursor
elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = False
@ -909,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)
@ -926,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)
@ -1000,13 +1098,12 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
if not hasattr(main_window, 'project'):
return False
# Get full layout with ghosts to determine insertion position
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
# Get page positions which includes ghosts
page_positions = self._get_page_positions()
# Track which index in the page list corresponds to each position
ghost_index = 0
for idx, ((page_type, page_obj_layout, logical_pos), (_, page_or_ghost, y_offset)) in enumerate(zip(layout_with_ghosts, page_positions)):
# Check each position for ghost pages
for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
# Skip non-ghost pages
if page_type != 'ghost':
continue
@ -1031,8 +1128,8 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
# Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y):
# User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost)
insert_index = sum(1 for i, (pt, _, _) in enumerate(layout_with_ghosts) if i < idx and pt == 'page')
# Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(page_positions) if i < idx and pt == 'page')
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")

View File

@ -64,6 +64,24 @@ class UndoableInteractionMixin:
self._interaction_start_size = None
self._interaction_start_rotation = element.rotation
def _begin_image_pan(self, element):
"""
Begin tracking an image pan operation.
Args:
element: The ImageData element being panned
"""
from pyPhotoAlbum.models import ImageData
if not isinstance(element, ImageData):
return
self._interaction_element = element
self._interaction_type = 'image_pan'
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
self._interaction_start_crop_info = element.crop_info
def _end_interaction(self):
"""
End the current interaction and create appropriate undo/redo command.
@ -142,6 +160,29 @@ class UndoableInteractionMixin:
)
print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°")
elif self._interaction_type == 'image_pan':
# Check if crop_info actually changed
from pyPhotoAlbum.models import ImageData
if isinstance(element, ImageData):
new_crop_info = element.crop_info
if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None:
# Check if crop changed significantly (more than 0.001 in any coordinate)
if new_crop_info != self._interaction_start_crop_info:
old_crop = self._interaction_start_crop_info
significant_change = any(
abs(new_crop_info[i] - old_crop[i]) > 0.001
for i in range(4)
)
if significant_change:
from pyPhotoAlbum.commands import AdjustImageCropCommand
command = AdjustImageCropCommand(
element,
self._interaction_start_crop_info,
new_crop_info
)
print(f"Image pan command created: {self._interaction_start_crop_info}{new_crop_info}")
# Execute the command through history if one was created
if command:
main_window.project.history.execute(command)
@ -156,6 +197,8 @@ class UndoableInteractionMixin:
self._interaction_start_pos = None
self._interaction_start_size = None
self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
def _cancel_interaction(self):
"""Cancel the current interaction without creating a command"""

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

@ -44,13 +44,25 @@ class ImageData(BaseLayoutElement):
glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f,
glGenTextures, glTexImage2D, GL_RGBA, GL_UNSIGNED_BYTE,
glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR,
glDeleteTextures)
glDeleteTextures, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
from PIL import Image
import os
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Try to load and render the actual image
texture_id = None
@ -192,6 +204,10 @@ class ImageData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary"""
return {
@ -223,11 +239,24 @@ class PlaceholderData(BaseLayoutElement):
def render(self):
"""Render the placeholder using OpenGL"""
from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple, glEnable, glDisable, GL_LINE_STIPPLE
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple,
glEnable, glDisable, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Draw a light gray rectangle as placeholder background
glColor3f(0.9, 0.9, 0.9) # Light gray
glBegin(GL_QUADS)
@ -249,6 +278,10 @@ class PlaceholderData(BaseLayoutElement):
glEnd()
glDisable(GL_LINE_STIPPLE)
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
def serialize(self) -> Dict[str, Any]:
"""Serialize placeholder data to dictionary"""
return {
@ -282,11 +315,24 @@ class TextBoxData(BaseLayoutElement):
def render(self):
"""Render the text box using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
glPushMatrix, glPopMatrix, glTranslatef, glRotatef)
x, y = self.position
w, h = self.size
# Apply rotation if needed
if self.rotation != 0:
glPushMatrix()
# Translate to center of element
center_x = x + w / 2
center_y = y + h / 2
glTranslatef(center_x, center_y, 0)
glRotatef(self.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now render at origin (rotation pivot is at element center)
x, y = 0, 0
# Enable alpha blending for transparency
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
@ -311,6 +357,10 @@ class TextBoxData(BaseLayoutElement):
glVertex2f(x, y + h)
glEnd()
# Pop matrix if we pushed for rotation
if self.rotation != 0:
glPopMatrix()
# Note: Text content is rendered using QPainter overlay in GLWidget.paintGL()
def serialize(self) -> Dict[str, Any]:

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", {})