Compare commits
3 Commits
3e3b604d2f
...
375e87ec84
| Author | SHA1 | Date | |
|---|---|---|---|
| 375e87ec84 | |||
| aa02506d4c | |||
| 4bfaa63aae |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -291,6 +305,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
|
|||||||
glVertex2f(hx + handle_size, hy + handle_size)
|
glVertex2f(hx + handle_size, hy + handle_size)
|
||||||
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"""
|
||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
@ -191,6 +203,10 @@ class ImageData(BaseLayoutElement):
|
|||||||
glVertex2f(x + w, y + h)
|
glVertex2f(x + w, y + h)
|
||||||
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"""
|
||||||
@ -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)
|
||||||
@ -248,6 +277,10 @@ class PlaceholderData(BaseLayoutElement):
|
|||||||
glVertex2f(x, y + h)
|
glVertex2f(x, y + h)
|
||||||
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"""
|
||||||
@ -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]:
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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", {})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user