diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 25f188a..e887f07 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -994,9 +994,6 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): if not hasattr(main_window, 'project'): return [] - # Get page layout with ghosts from project - layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() - dpi = main_window.project.working_dpi # Use project's page_spacing_mm setting (default is 10mm = 1cm) @@ -1011,6 +1008,22 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): result = [] current_y = top_margin_px # Initial top offset in pixels (not screen pixels) + # First, render cover if it exists + for page in main_window.project.pages: + if page.is_cover: + result.append(('page', page, current_y)) + + # Calculate cover height in pixels + page_height_mm = page.layout.size[1] + page_height_px = page_height_mm * dpi / 25.4 + + # Move to next position (add height + spacing) + current_y += page_height_px + spacing_px + break # Only one cover allowed + + # Get page layout with ghosts from project (this excludes cover) + layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts() + for page_type, page_obj, logical_pos in layout_with_ghosts: if page_type == 'page': # Regular page (single or double spread) diff --git a/pyPhotoAlbum/mixins/operations/page_ops.py b/pyPhotoAlbum/mixins/operations/page_ops.py index e4f9d8e..675a60e 100644 --- a/pyPhotoAlbum/mixins/operations/page_ops.py +++ b/pyPhotoAlbum/mixins/operations/page_ops.py @@ -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,14 +179,62 @@ 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] 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,33 +263,57 @@ 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() - # Check if size actually changed - # For double spreads, compare with base width - if selected_page.is_double_spread: - 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: - # Update double spread - 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") - else: - old_size = selected_page.layout.size - size_changed = (old_size != (width_mm, height_mm)) - - if size_changed: - # Update single page - 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") + # 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: + 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: + # Update double spread + selected_page.layout.base_width = width_mm + selected_page.layout.size = (width_mm * 2, height_mm) + selected_page.manually_sized = True + 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)) + + if size_changed: + # Update single page + selected_page.layout.size = (width_mm, height_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 self.project.working_dpi = working_dpi_spinbox.value() @@ -212,9 +327,13 @@ class PageOperationsMixin: self.update_view() # Build status message - status_msg = f"Page {selected_page.page_number} size: {width_mm}×{height_mm} mm" - if set_default_checkbox.isChecked(): - status_msg += " (set as default)" + 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) @ribbon_action( diff --git a/pyPhotoAlbum/pdf_exporter.py b/pyPhotoAlbum/pdf_exporter.py index 081951e..7378362 100644 --- a/pyPhotoAlbum/pdf_exporter.py +++ b/pyPhotoAlbum/pdf_exporter.py @@ -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""" diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index 22edb73..9d5df63 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -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", {})