pyPhotoAlbum/pyPhotoAlbum/pdf_exporter.py
Duncan Tourolle fae9e5bd2b
Some checks failed
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m10s
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Additional refactoring
2025-11-27 21:57:57 +01:00

624 lines
26 KiB
Python

"""
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 <br/> tags
text_content = text_element.text_content
text_content = text_content.replace('&', '&amp;')
text_content = text_content.replace('<', '&lt;')
text_content = text_content.replace('>', '&gt;')
text_content = text_content.replace('\n', '<br/>')
# 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()