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): class AlignElementsCommand(Command):
"""Command for aligning multiple elements""" """Command for aligning multiple elements"""
@ -717,6 +760,8 @@ class CommandHistory:
return ResizeElementsCommand.deserialize(data, project) return ResizeElementsCommand.deserialize(data, project)
elif cmd_type == "change_zorder": elif cmd_type == "change_zorder":
return ChangeZOrderCommand.deserialize(data, project) return ChangeZOrderCommand.deserialize(data, project)
elif cmd_type == "adjust_image_crop":
return AdjustImageCropCommand.deserialize(data, project)
else: else:
print(f"Warning: Unknown command type: {cmd_type}") print(f"Warning: Unknown command type: {cmd_type}")
return None return None

View File

@ -41,6 +41,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.rotation_start_angle = None # Starting rotation angle self.rotation_start_angle = None # Starting rotation angle
self.rotation_snap_angle = 15 # Default snap angle in degrees 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 # Zoom and pan state
self.zoom_level = 1.0 self.zoom_level = 1.0
self.pan_offset = [0, 0] self.pan_offset = [0, 0]
@ -209,6 +213,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
center_x = x + w / 2 center_x = x + w / 2
center_y = y + h / 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 # Draw selection border
if self.rotation_mode: if self.rotation_mode:
glColor3f(1.0, 0.5, 0.0) # Orange for 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) glVertex2f(hx, hy + handle_size)
glEnd() glEnd()
# Restore matrix if we applied rotation
if self.selected_element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self): def _render_text_overlays(self):
"""Render text content for TextBoxData elements using QPainter overlay""" """Render text content for TextBoxData elements using QPainter overlay"""
from PyQt6.QtGui import QPainter, QFont, QColor, QPen from PyQt6.QtGui import QPainter, QFont, QColor, QPen
@ -448,12 +466,25 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
element = self._get_element_at(x, y) element = self._get_element_at(x, y)
if element: 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: if element in self.selected_elements:
self.selected_elements.remove(element) self.selected_elements.remove(element)
else: else:
self.selected_elements.add(element) self.selected_elements.add(element)
else: else:
# Normal drag mode
self.selected_elements = {element} self.selected_elements = {element}
self.drag_start_pos = (x, y) self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position self.drag_start_element_pos = element.position
@ -493,7 +524,57 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
return return
if self.selected_element: 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 # Calculate rotation angle from mouse position relative to element center
import math import math
@ -528,6 +609,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.selected_element.rotation = angle self.selected_element.rotation = angle
# Show current angle in status bar # Show current angle in status bar
main_window = self.window()
if hasattr(main_window, 'show_status'): if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100) main_window.show_status(f"Rotation: {angle:.1f}°", 100)
@ -591,11 +673,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
self.drag_start_element_pos = None self.drag_start_element_pos = None
self.resize_handle = None self.resize_handle = None
self.rotation_start_angle = None self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = { self.snap_state = {
'is_snapped': False, 'is_snapped': False,
'last_position': None, 'last_position': None,
'last_size': None 'last_size': None
} }
self.setCursor(Qt.CursorShape.ArrowCursor) # Reset cursor
elif event.button() == Qt.MouseButton.MiddleButton: elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = False self.is_panning = False
@ -909,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)
@ -926,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)
@ -1000,13 +1098,12 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
if not hasattr(main_window, 'project'): if not hasattr(main_window, 'project'):
return False return False
# Get full layout with ghosts to determine insertion position # Get page positions which includes ghosts
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
page_positions = self._get_page_positions() page_positions = self._get_page_positions()
# Track which index in the page list corresponds to each position # Check each position for ghost pages
ghost_index = 0 for idx, (page_type, page_or_ghost, y_offset) in enumerate(page_positions):
for idx, ((page_type, page_obj_layout, logical_pos), (_, page_or_ghost, y_offset)) in enumerate(zip(layout_with_ghosts, page_positions)): # Skip non-ghost pages
if page_type != 'ghost': if page_type != 'ghost':
continue continue
@ -1031,8 +1128,8 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
# Check if click is anywhere on the ghost page (entire page is clickable) # Check if click is anywhere on the ghost page (entire page is clickable)
if renderer.is_point_in_page(x, y): if renderer.is_point_in_page(x, y):
# User clicked the ghost page! # User clicked the ghost page!
# Calculate the insertion index (count real pages before this ghost) # Calculate the insertion index (count real pages before this ghost in page_positions)
insert_index = sum(1 for i, (pt, _, _) in enumerate(layout_with_ghosts) if i < idx and pt == 'page') 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") 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_size = None
self._interaction_start_rotation = element.rotation 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): def _end_interaction(self):
""" """
End the current interaction and create appropriate undo/redo command. 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}°") 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 # Execute the command through history if one was created
if command: if command:
main_window.project.history.execute(command) main_window.project.history.execute(command)
@ -156,6 +197,8 @@ class UndoableInteractionMixin:
self._interaction_start_pos = None self._interaction_start_pos = None
self._interaction_start_size = None self._interaction_start_size = None
self._interaction_start_rotation = None self._interaction_start_rotation = None
if hasattr(self, '_interaction_start_crop_info'):
self._interaction_start_crop_info = None
def _cancel_interaction(self): def _cancel_interaction(self):
"""Cancel the current interaction without creating a command""" """Cancel the current interaction without creating a command"""

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,8 +179,21 @@ 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]
@ -145,6 +201,41 @@ class PageOperationsMixin:
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]
size_changed = (old_base_width != width_mm or old_height != height_mm)
if size_changed: if size_changed:
# Update double spread # Update double spread
selected_page.layout.base_width = width_mm selected_page.layout.base_width = width_mm
selected_page.layout.size = (width_mm * 2, height_mm) selected_page.layout.size = (width_mm * 2, height_mm)
selected_page.manually_sized = True 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: else:
old_size = selected_page.layout.size old_size = selected_page.layout.size
size_changed = (old_size != (width_mm, height_mm)) size_changed = (old_size != (width_mm, height_mm))
if size_changed: if size_changed:
# Update single page # Update single page
selected_page.layout.size = (width_mm, height_mm) selected_page.layout.size = (width_mm, height_mm)
selected_page.layout.base_width = width_mm selected_page.layout.base_width = width_mm
selected_page.manually_sized = True 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 # 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

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

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