""" PDF export functionality for pyPhotoAlbum """ import os from typing import List, Tuple, Optional from dataclasses import dataclass from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader from reportlab.platypus import Paragraph from reportlab.lib.styles import ParagraphStyle from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT from PIL import Image import math from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.image_utils import ( apply_pil_rotation, convert_to_rgba, calculate_center_crop_coords, crop_image_to_coords, ) @dataclass class RenderContext: """Parameters for rendering an image element""" canvas: canvas.Canvas image_element: ImageData x_pt: float y_pt: float width_pt: float height_pt: float page_number: int crop_left: float = 0.0 crop_right: float = 1.0 original_width_pt: Optional[float] = None original_height_pt: Optional[float] = None @dataclass class SplitRenderParams: """Parameters for rendering a split element""" canvas: canvas.Canvas element: any x_offset_mm: float split_line_mm: float page_width_pt: float page_height_pt: float page_number: int side: str class PDFExporter: """Handles PDF export of photo album projects""" # Conversion constants MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements def __init__(self, project, export_dpi: int = 300): """ Initialize PDF exporter with a project. Args: project: The Project instance to export export_dpi: Target DPI for images in the PDF (default 300 for print quality) Use 300 for high-quality print, 150 for screen/draft """ self.project = project self.export_dpi = export_dpi self.warnings = [] self.current_pdf_page = 1 def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: """ Export the project to PDF. Args: output_path: Path where PDF should be saved progress_callback: Optional callback(current, total, message) for progress updates Returns: Tuple of (success: bool, warnings: List[str]) """ self.warnings = [] self.current_pdf_page = 1 try: # 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 # Convert to PDF points page_width_pt = page_width_mm * self.MM_TO_POINTS page_height_pt = page_height_mm * self.MM_TO_POINTS # Create PDF canvas c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt)) # 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_name}...") 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 c.showPage() # Finish current page self.current_pdf_page += 1 if progress_callback: progress_callback(pages_processed, total_pages, f"Inserting blank page for alignment...") # Export spread as two pages self._export_spread(c, page, page_width_pt, page_height_pt) pages_processed += 2 else: # Export single page self._export_single_page(c, page, page_width_pt, page_height_pt) pages_processed += 1 # Save PDF c.save() if progress_callback: progress_callback(total_pages, total_pages, "Export complete!") return True, self.warnings except Exception as e: 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""" # Render all elements for element in sorted(page.layout.elements, key=lambda x: x.z_index): self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number) c.showPage() # Finish this page self.current_pdf_page += 1 def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float): """Export a double-page spread as two PDF pages""" # Get center line position in mm page_width_mm = self.project.page_size_mm[0] center_mm = page_width_mm # Center of the spread (which is 2x width) # Convert center line to pixels for comparison dpi = self.project.working_dpi center_px = center_mm * dpi / 25.4 # Calculate threshold for tiny elements (1:500) in pixels threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4 # Process elements for left page for element in sorted(page.layout.elements, key=lambda x: x.z_index): element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size # Check if element is on left page, right page, or spanning (compare in pixels) if element_x_px + element_width_px <= center_px + threshold_px: # Entirely on left page self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number) elif element_x_px >= center_px - threshold_px: # Skip for now, will render on right page pass else: # Spanning element - render left portion params = SplitRenderParams( canvas=c, element=element, x_offset_mm=0, split_line_mm=center_mm, page_width_pt=page_width_pt, page_height_pt=page_height_pt, page_number=page.page_number, side="left", ) self._render_split_element(params) c.showPage() # Finish left page self.current_pdf_page += 1 # Process elements for right page for element in sorted(page.layout.elements, key=lambda x: x.z_index): element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size # Check if element is on right page or spanning (compare in pixels) if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px: # Entirely on right page or mostly on right self._render_element(c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1) elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px: # Spanning element - render right portion params = SplitRenderParams( canvas=c, element=element, x_offset_mm=center_mm, split_line_mm=center_mm, page_width_pt=page_width_pt, page_height_pt=page_height_pt, page_number=page.page_number + 1, side="right", ) self._render_split_element(params) c.showPage() # Finish right page self.current_pdf_page += 1 def _render_element( self, c: canvas.Canvas, element, x_offset_mm: float, page_width_pt: float, page_height_pt: float, page_number: int, ): """ Render a single element on the PDF canvas. Args: c: ReportLab canvas element: The layout element to render x_offset_mm: X offset in mm (for right page of spread) page_width_pt: Page width in points page_height_pt: Page height in points page_number: Current page number (for error messages) """ # Skip placeholders if isinstance(element, PlaceholderData): return # Get element position and size (in PIXELS from OpenGL coordinates) element_x_px, element_y_px = element.position element_width_px, element_height_px = element.size # Convert from pixels to mm using the working DPI dpi = self.project.working_dpi element_x_mm = element_x_px * 25.4 / dpi element_y_mm = element_y_px * 25.4 / dpi element_width_mm = element_width_px * 25.4 / dpi element_height_mm = element_height_px * 25.4 / dpi # Adjust x position for offset (now in mm) adjusted_x_mm = element_x_mm - x_offset_mm # Convert to PDF points and flip Y coordinate (PDF origin is bottom-left) x_pt = adjusted_x_mm * self.MM_TO_POINTS y_pt = page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS) width_pt = element_width_mm * self.MM_TO_POINTS height_pt = element_height_mm * self.MM_TO_POINTS if isinstance(element, ImageData): ctx = RenderContext( canvas=c, image_element=element, x_pt=x_pt, y_pt=y_pt, width_pt=width_pt, height_pt=height_pt, page_number=page_number, ) self._render_image(ctx) elif isinstance(element, TextBoxData): self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt) def _render_split_element(self, params: SplitRenderParams): """ Render a split element (only the portion on one side of the split line). Args: params: SplitRenderParams containing all rendering parameters """ # Skip placeholders if isinstance(params.element, PlaceholderData): return # Get element position and size in pixels element_x_px, element_y_px = params.element.position element_width_px, element_height_px = params.element.size # Convert to mm dpi = self.project.working_dpi element_x_mm = element_x_px * 25.4 / dpi element_y_mm = element_y_px * 25.4 / dpi element_width_mm = element_width_px * 25.4 / dpi element_height_mm = element_height_px * 25.4 / dpi if isinstance(params.element, ImageData): # Calculate which portion of the image to render if params.side == "left": # Render from element start to split line crop_width_mm = params.split_line_mm - element_x_mm crop_x_start = 0 render_x_mm = element_x_mm else: # right # Render from split line to element end crop_width_mm = (element_x_mm + element_width_mm) - params.split_line_mm crop_x_start = params.split_line_mm - element_x_mm render_x_mm = params.split_line_mm # Start at split line in spread coordinates # Adjust render position for offset adjusted_x_mm = render_x_mm - params.x_offset_mm # Convert to points x_pt = adjusted_x_mm * self.MM_TO_POINTS y_pt = params.page_height_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS) width_pt = crop_width_mm * self.MM_TO_POINTS height_pt = element_height_mm * self.MM_TO_POINTS # Calculate original element dimensions in points (before splitting) original_width_pt = element_width_mm * self.MM_TO_POINTS original_height_pt = element_height_mm * self.MM_TO_POINTS # Render cropped image with original dimensions for correct aspect ratio ctx = RenderContext( canvas=params.canvas, image_element=params.element, x_pt=x_pt, y_pt=y_pt, width_pt=width_pt, height_pt=height_pt, page_number=params.page_number, crop_left=crop_x_start / element_width_mm, crop_right=(crop_x_start + crop_width_mm) / element_width_mm, original_width_pt=original_width_pt, original_height_pt=original_height_pt, ) self._render_image(ctx) elif isinstance(params.element, TextBoxData): # For text boxes spanning the split, we'll render the whole text on the side # where most of it appears (simpler than trying to split text) element_center_mm = element_x_mm + element_width_mm / 2 if (params.side == "left" and element_center_mm < params.split_line_mm) or ( params.side == "right" and element_center_mm >= params.split_line_mm ): self._render_element( params.canvas, params.element, params.x_offset_mm, params.page_width_pt, params.page_height_pt, params.page_number, ) def _render_image(self, ctx: RenderContext): """ Render an image element on the PDF canvas. Args: ctx: RenderContext containing all rendering parameters """ # Resolve image path using ImageData's method image_full_path = ctx.image_element.resolve_image_path() # Check if image exists if not image_full_path: warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}" print(f"WARNING: {warning}") self.warnings.append(warning) return try: # Load image using resolved path img = Image.open(image_full_path) img = convert_to_rgba(img) # Apply PIL-level rotation if needed if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0: img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90) # Get element's crop_info and combine with split cropping if applicable crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right # Determine target dimensions for aspect ratio # Use original dimensions for split images to prevent stretching if ctx.original_width_pt is not None and ctx.original_height_pt is not None: target_width = ctx.original_width_pt target_height = ctx.original_height_pt else: target_width = ctx.width_pt target_height = ctx.height_pt # Calculate center crop coordinates img_width, img_height = img.size crop_coords = calculate_center_crop_coords( img_width, img_height, target_width, target_height, (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max), ) # Crop the image cropped_img = crop_image_to_coords(img, crop_coords) # Downsample image to target resolution based on export DPI # This prevents embedding huge images and reduces PDF file size # Calculate target dimensions in pixels based on physical size and export DPI target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4) # Only downsample if current image is larger than target # Don't upscale small images as that would reduce quality current_width, current_height = cropped_img.size if current_width > target_width_px or current_height > target_height_px: # Use LANCZOS resampling for high quality downsampling cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS) # Note: Rotation is applied at the canvas level (below), not here # to avoid double-rotation issues # Save state for transformations ctx.canvas.saveState() # Apply rotation to canvas if needed if ctx.image_element.rotation != 0: # Move to element center center_x = ctx.x_pt + ctx.width_pt / 2 center_y = ctx.y_pt + ctx.height_pt / 2 ctx.canvas.translate(center_x, center_y) ctx.canvas.rotate(-ctx.image_element.rotation) ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2) # Draw at origin after transformation ctx.canvas.drawImage( ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False ) else: # Draw without rotation ctx.canvas.drawImage( ImageReader(cropped_img), ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False, ) ctx.canvas.restoreState() except Exception as e: warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}" print(f"WARNING: {warning}") self.warnings.append(warning) def _render_textbox( self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float ): """ Render a text box element on the PDF canvas with transparent background. Text is word-wrapped to fit within the box boundaries. Args: c: ReportLab canvas text_element: TextBoxData instance x_pt, y_pt, width_pt, height_pt: Position and size in points """ if not text_element.text_content: return # Get font settings font_family = text_element.font_settings.get("family", "Helvetica") font_size_px = text_element.font_settings.get("size", 12) font_color = text_element.font_settings.get("color", (0, 0, 0)) # Convert font size from pixels to PDF points (same conversion as element dimensions) # Font size is stored in pixels at working_dpi, same as element position/size dpi = self.project.working_dpi font_size_mm = font_size_px * 25.4 / dpi font_size = font_size_mm * self.MM_TO_POINTS # Map common font names to ReportLab standard fonts font_map = { "Arial": "Helvetica", "Times New Roman": "Times-Roman", "Courier New": "Courier", } font_family = font_map.get(font_family, font_family) # Normalize color to hex for Paragraph style if all(isinstance(x, int) and x > 1 for x in font_color): color_hex = "#{:02x}{:02x}{:02x}".format(*font_color) else: # Convert 0-1 range to 0-255 then to hex color_hex = "#{:02x}{:02x}{:02x}".format( int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255) ) # Map alignment to ReportLab constants alignment_map = { "left": TA_LEFT, "center": TA_CENTER, "right": TA_RIGHT, } text_alignment = alignment_map.get(text_element.alignment, TA_LEFT) # Create paragraph style with word wrapping style = ParagraphStyle( "textbox", fontName=font_family, fontSize=font_size, leading=font_size * 1.2, # Line spacing (120% of font size) textColor=color_hex, alignment=text_alignment, ) # Escape special XML characters and convert newlines to
tags text_content = text_element.text_content text_content = text_content.replace("&", "&") text_content = text_content.replace("<", "<") text_content = text_content.replace(">", ">") text_content = text_content.replace("\n", "
") # Create paragraph with the text para = Paragraph(text_content, style) # Save state for transformations c.saveState() try: # Apply rotation if needed if text_element.rotation != 0: # Move to element center center_x = x_pt + width_pt / 2 center_y = y_pt + height_pt / 2 c.translate(center_x, center_y) c.rotate(text_element.rotation) # Wrap and draw paragraph relative to center # wrapOn calculates the actual height needed para_width, para_height = para.wrapOn(c, width_pt, height_pt) # Position at top-left of box (relative to center after rotation) draw_x = -width_pt / 2 draw_y = height_pt / 2 - para_height para.drawOn(c, draw_x, draw_y) else: # No rotation - draw normally # wrapOn calculates the actual height needed for the wrapped text para_width, para_height = para.wrapOn(c, width_pt, height_pt) # drawOn draws from bottom-left of the paragraph # We want text at top of box, so: draw_y = box_top - para_height draw_x = x_pt draw_y = y_pt + height_pt - para_height para.drawOn(c, draw_x, draw_y) except Exception as e: warning = f"Error rendering text box: {str(e)}" print(f"WARNING: {warning}") self.warnings.append(warning) finally: c.restoreState()