pyPhotoAlbum/pyPhotoAlbum/pdf_exporter.py
Duncan Tourolle 1fe44e7d8a
Some checks failed
Lint / lint (push) Successful in 20s
Tests / test (push) Successful in 10s
Python CI / test (push) Failing after 1m43s
More CI fixes and mypy fixes
2026-04-09 23:07:59 +02:00

1196 lines
47 KiB
Python

"""
PDF export functionality for pyPhotoAlbum
Uses multiprocessing to pre-process images in parallel for faster exports.
"""
import os
import threading
from typing import Any, List, Tuple, Optional, Union, Dict
from dataclasses import dataclass, field
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
import multiprocessing
import io
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, TA_JUSTIFY
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 ImageTask:
"""Parameters needed to process an image in a worker process."""
task_id: str
image_path: str
pil_rotation_90: int
crop_info: Tuple[float, float, float, float]
crop_left: float
crop_right: float
target_width: float
target_height: float
target_width_px: int
target_height_px: int
corner_radius: float
def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]:
"""
Process a single image task in a worker process.
This function runs in a separate process and handles all CPU-intensive
image operations: loading, rotation, cropping, resizing, and styling.
Args:
task: ImageTask with all parameters needed for processing
Returns:
Tuple of (task_id, processed_image_bytes or None, error_message or None)
"""
try:
# Validate image_path is a string before proceeding
if not isinstance(task.image_path, str):
return (task.task_id, None, f"Invalid image_path type: {type(task.image_path).__name__}, expected str")
# Import PIL first with basic load
from PIL import Image
# Try to open the image - this is the most likely failure point
try:
img: Image.Image = Image.open(task.image_path)
except Exception as open_err:
import traceback
return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}")
# Now import the rest
try:
from pyPhotoAlbum.image_utils import (
apply_pil_rotation,
convert_to_rgba,
calculate_center_crop_coords,
crop_image_to_coords,
apply_rounded_corners,
)
except Exception as import_err:
import traceback
return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}")
# Convert to RGBA
img = convert_to_rgba(img)
# Apply PIL-level rotation if needed
if task.pil_rotation_90 > 0:
img = apply_pil_rotation(img, task.pil_rotation_90)
# Calculate final crop bounds (combining element crop with split crop)
crop_x_min, crop_y_min, crop_x_max, crop_y_max = task.crop_info
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * task.crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * task.crop_right
# Calculate center crop coordinates
img_width, img_height = img.size
crop_coords = calculate_center_crop_coords(
img_width,
img_height,
task.target_width,
task.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 if needed
current_width, current_height = cropped_img.size
if current_width > task.target_width_px or current_height > task.target_height_px:
cropped_img = cropped_img.resize(
(task.target_width_px, task.target_height_px),
Image.Resampling.LANCZOS,
)
# Apply rounded corners if needed
if task.corner_radius > 0:
cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
# Serialize image: JPEG for photos (much smaller), PNG only when alpha is needed
buffer = io.BytesIO()
has_transparency = cropped_img.mode == "RGBA" and task.corner_radius > 0
if has_transparency:
cropped_img.save(buffer, format="PNG", optimize=False)
else:
# Convert to RGB for JPEG (drop alpha channel if present but unused)
rgb_img = cropped_img.convert("RGB")
rgb_img.save(buffer, format="JPEG", quality=92, optimize=True)
return (task.task_id, buffer.getvalue(), None)
except Exception as e:
import traceback
return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}")
@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, max_workers: Optional[int] = None):
"""
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
max_workers: Maximum number of worker processes for parallel image processing.
Defaults to number of CPU cores.
"""
self.project = project
self.export_dpi = export_dpi
self.warnings: List[str] = []
self.current_pdf_page = 1
self.max_workers = max_workers or multiprocessing.cpu_count()
self._processed_images: Dict[str, Image.Image] = {}
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
"""
Export the project to PDF.
Uses multiprocessing to pre-process all images in parallel, then renders
each page to its own PDF buffer in parallel (via threads), and finally
merges the per-page PDFs into the output file.
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._processed_images = {}
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
bleed_mm = self.project.page_bleed_mm
bleed_pt = bleed_mm * self.MM_TO_POINTS
page_width_pt = page_width_mm * self.MM_TO_POINTS
page_height_pt = page_height_mm * self.MM_TO_POINTS
# Phase 1: parallel image pre-processing (unchanged)
if progress_callback:
progress_callback(0, total_pages, "Collecting images for processing...")
image_tasks = self._collect_image_tasks(page_width_pt, page_height_pt)
if image_tasks:
if progress_callback:
progress_callback(0, total_pages, f"Processing {len(image_tasks)} images in parallel...")
self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
# Phase 2: determine ordered page sequence (inserts blank pages for spread alignment)
page_sequence = self._compute_page_sequence()
# Phase 3: render each page to its own PDF bytes in parallel
n = len(page_sequence)
if progress_callback:
progress_callback(0, total_pages, f"Rendering {n} pages in parallel...")
pdf_bytes_list: List[Optional[bytes]] = [None] * n
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_idx = {
executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i
for i, item in enumerate(page_sequence)
}
completed = 0
for future in as_completed(future_to_idx):
i = future_to_idx[future]
try:
pdf_bytes_list[i] = future.result()
except Exception as e:
self.warnings.append(f"Error rendering page: {str(e)}")
pdf_bytes_list[i] = self._make_blank_page_bytes(page_width_pt, page_height_pt, bleed_pt)
completed += 1
if progress_callback:
progress_callback(completed, n, f"Rendering pages: {completed}/{n}...")
# Phase 4: merge all per-page PDFs into the output file
if progress_callback:
progress_callback(n, total_pages, "Merging pages...")
self._merge_page_pdfs(pdf_bytes_list, output_path)
self._processed_images = {}
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 _compute_page_sequence(self) -> List[Tuple[str, Any]]:
"""
Build an ordered list of (page_type, page) items to render.
Inserts ('blank', None) entries before double-page spreads that would
otherwise start on an odd-numbered PDF page (spreads must start on even pages).
"""
sequence: List[Tuple[str, Any]] = []
pdf_page_num = 1
for page in self.project.pages:
if page.is_cover:
sequence.append(("cover", page))
pdf_page_num += 1
elif page.is_double_spread:
if pdf_page_num % 2 == 1:
sequence.append(("blank", None))
pdf_page_num += 1
sequence.append(("spread", page))
pdf_page_num += 2
else:
sequence.append(("single", page))
pdf_page_num += 1
return sequence
def _render_item_to_bytes(
self,
item: Tuple[str, Any],
page_width_pt: float,
page_height_pt: float,
bleed_pt: float,
) -> bytes:
"""
Render a single page item to a self-contained PDF (as bytes).
Each call creates its own Canvas / BytesIO so pages can be rendered
concurrently without sharing state.
"""
page_type, page = item
expanded_width_pt = page_width_pt + 2 * bleed_pt
expanded_height_pt = page_height_pt + 2 * bleed_pt
buf = io.BytesIO()
if page_type == "blank":
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
c.showPage()
c.save()
elif page_type == "cover":
cover_width_mm, cover_height_mm = page.layout.size
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
c = canvas.Canvas(buf, pagesize=(cover_width_pt, cover_height_pt))
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")
c.showPage()
c.save()
elif page_type == "single":
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
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, bleed_pt)
c.showPage()
c.save()
elif page_type == "spread":
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
self._export_spread(c, page, page_width_pt, page_height_pt, bleed_pt)
c.save()
return buf.getvalue()
def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> bytes:
"""Return a minimal single-blank-page PDF for use as an error placeholder."""
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt))
c.showPage()
c.save()
return buf.getvalue()
def _merge_page_pdfs(self, pdf_bytes_list: List[Optional[bytes]], output_path: str):
"""Merge a list of single-page PDF byte strings into one output file."""
from pypdf import PdfWriter, PdfReader
writer = PdfWriter()
for pdf_bytes in pdf_bytes_list:
if pdf_bytes is None:
continue
reader = PdfReader(io.BytesIO(pdf_bytes))
for page in reader.pages:
writer.add_page(page)
with open(output_path, "wb") as f:
writer.write(f)
def _make_task_id(
self,
element: ImageData,
crop_left: float = 0.0,
crop_right: float = 1.0,
width_pt: float = 0.0,
height_pt: float = 0.0,
) -> str:
"""Generate a unique task ID for an image element with specific render params."""
return f"{id(element)}_{crop_left:.4f}_{crop_right:.4f}_{width_pt:.2f}_{height_pt:.2f}"
def _collect_image_tasks(self, page_width_pt: float, page_height_pt: float) -> List[ImageTask]:
"""
Collect all image processing tasks from the project.
Scans all pages and elements to build a list of ImageTask objects
that can be processed in parallel.
"""
tasks: List[ImageTask] = []
dpi = self.project.working_dpi
for page in self.project.pages:
if page.is_cover:
cover_width_mm, cover_height_mm = page.layout.size
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
self._collect_page_tasks(tasks, page, 0, cover_width_pt, cover_height_pt)
elif page.is_double_spread:
# Collect tasks for both left and right pages of the spread
page_width_mm = self.project.page_size_mm[0]
center_mm = page_width_mm
self._collect_spread_tasks(tasks, page, page_width_pt, page_height_pt, center_mm)
else:
self._collect_page_tasks(tasks, page, 0, page_width_pt, page_height_pt)
return tasks
def _collect_page_tasks(
self,
tasks: List[ImageTask],
page,
x_offset_mm: float,
page_width_pt: float,
page_height_pt: float,
):
"""Collect image tasks from a single page."""
dpi = self.project.working_dpi
for element in page.layout.elements:
if not isinstance(element, ImageData):
continue
image_path = element.resolve_image_path()
if not image_path:
continue
# Calculate dimensions
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt)
task = ImageTask(
task_id=task_id,
image_path=image_path,
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
crop_info=element.crop_info,
crop_left=0.0,
crop_right=1.0,
target_width=width_pt,
target_height=height_pt,
target_width_px=max(1, target_width_px),
target_height_px=max(1, target_height_px),
corner_radius=element.style.corner_radius,
)
tasks.append(task)
def _collect_spread_tasks(
self,
tasks: List[ImageTask],
page,
page_width_pt: float,
page_height_pt: float,
center_mm: float,
):
"""Collect image tasks from a double-page spread, handling split elements."""
dpi = self.project.working_dpi
center_px = center_mm * dpi / 25.4
threshold_px = self.project.page_size_mm[0] * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
for element in page.layout.elements:
if not isinstance(element, ImageData):
continue
image_path = element.resolve_image_path()
if not image_path:
continue
element_x_px, element_y_px = element.position
element_width_px, element_height_px = element.size
element_x_mm = element_x_px * 25.4 / dpi
element_width_mm = element_width_px * 25.4 / dpi
element_height_mm = element_height_px * 25.4 / dpi
width_pt = element_width_mm * self.MM_TO_POINTS
height_pt = element_height_mm * self.MM_TO_POINTS
# Check if element spans the center
if element_x_px + element_width_px <= center_px + threshold_px:
# Entirely on left page
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
elif element_x_px >= center_px - threshold_px:
# Entirely on right page
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
else:
# Spanning element - create tasks for left and right portions
# Left portion
crop_width_mm_left = center_mm - element_x_mm
crop_right_left = crop_width_mm_left / element_width_mm
left_width_pt = crop_width_mm_left * self.MM_TO_POINTS
self._add_image_task(tasks, element, image_path, 0.0, crop_right_left, left_width_pt, height_pt)
# Right portion
crop_x_start_right = center_mm - element_x_mm
crop_left_right = crop_x_start_right / element_width_mm
right_width_pt = (element_width_mm - crop_x_start_right) * self.MM_TO_POINTS
self._add_image_task(tasks, element, image_path, crop_left_right, 1.0, right_width_pt, height_pt)
def _add_image_task(
self,
tasks: List[ImageTask],
element: ImageData,
image_path: str,
crop_left: float,
crop_right: float,
width_pt: float,
height_pt: float,
):
"""Helper to add an image task to the list."""
# Use original dimensions for aspect ratio calculation
original_width_pt = width_pt / (crop_right - crop_left) if crop_right != crop_left else width_pt
original_height_pt = height_pt
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt)
task = ImageTask(
task_id=task_id,
image_path=image_path,
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
crop_info=element.crop_info,
crop_left=crop_left,
crop_right=crop_right,
target_width=original_width_pt,
target_height=original_height_pt,
target_width_px=max(1, target_width_px),
target_height_px=max(1, target_height_px),
corner_radius=element.style.corner_radius,
)
tasks.append(task)
def _preprocess_images_parallel(
self,
tasks: List[ImageTask],
progress_callback,
total_pages: int,
):
"""
Process all image tasks in parallel using a process pool.
Results are stored in self._processed_images for use during PDF assembly.
"""
completed = 0
total_tasks = len(tasks)
with ProcessPoolExecutor(max_workers=self.max_workers) as executor:
future_to_task = {executor.submit(_process_image_task, task): task for task in tasks}
for future in as_completed(future_to_task):
task = future_to_task[future]
completed += 1
try:
task_id, image_bytes, error = future.result()
if error:
warning = f"Error processing image {task.image_path}: {error}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
elif image_bytes:
# Deserialize the image from bytes
buffer = io.BytesIO(image_bytes)
img = Image.open(buffer)
# Force load the image data into memory
img.load()
# Store both image and buffer reference to prevent garbage collection
# Some PIL operations may still reference the source buffer
img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
self._processed_images[task_id] = img
except Exception as e:
warning = f"Error processing image {task.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
if progress_callback and completed % 5 == 0:
progress_callback(
0,
total_pages,
f"Processing images: {completed}/{total_tasks}...",
)
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")
c.showPage() # Finish cover page
def _export_single_page(
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
):
"""Export a single page to PDF"""
expanded_width_pt = page_width_pt + 2 * bleed_pt
expanded_height_pt = page_height_pt + 2 * bleed_pt
c.setPageSize((expanded_width_pt, expanded_height_pt))
# Render all elements, offset by bleed
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, bleed_pt)
c.showPage() # Finish this page
def _export_spread(
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
):
"""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
expanded_width_pt = page_width_pt + 2 * bleed_pt
expanded_height_pt = page_height_pt + 2 * bleed_pt
# Process elements for left page
c.setPageSize((expanded_width_pt, expanded_height_pt))
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, bleed_pt)
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, bleed_pt)
c.showPage() # Finish left page
# Process elements for right page
c.setPageSize((expanded_width_pt, expanded_height_pt))
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, bleed_pt
)
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, bleed_pt)
c.showPage() # Finish right page
def _render_element(
self,
c: canvas.Canvas,
element,
x_offset_mm: float,
page_width_pt: float,
page_height_pt: float,
page_number: Union[int, str],
bleed_pt: float = 0.0,
):
"""
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 (cut/trim size, excluding bleed)
page_height_pt: Page height in points (cut/trim size, excluding bleed)
page_number: Current page number (for error messages)
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
"""
# 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).
# bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
y_pt = page_height_pt + bleed_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=int(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, bleed_pt: float = 0.0):
"""
Render a split element (only the portion on one side of the split line).
Args:
params: SplitRenderParams containing all rendering parameters
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
"""
# 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 (bleed_pt shifts content inside the expanded PDF page)
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
y_pt = (
params.page_height_pt
+ bleed_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,
bleed_pt,
)
def _render_image(self, ctx: RenderContext):
"""
Render an image element on the PDF canvas.
Uses pre-processed images from the cache when available (parallel processing),
otherwise falls back to processing the image on-demand.
Args:
ctx: RenderContext containing all rendering parameters
"""
# Check for pre-processed image in cache
task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt)
cropped_img = self._processed_images.get(task_id)
if cropped_img is None:
# Fallback: process image on-demand (for backwards compatibility or cache miss)
cropped_img = self._process_image_fallback(ctx)
if cropped_img is None:
return
try:
style = ctx.image_element.style
# Save state for transformations
ctx.canvas.saveState()
# Draw drop shadow first (behind image)
if style.shadow_enabled:
self._draw_shadow_pdf(ctx)
# 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,
)
# Draw border on top of image
if style.border_width > 0:
self._draw_border_pdf(ctx)
# Draw decorative frame if specified
if style.frame_style:
self._draw_frame_pdf(ctx)
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 _process_image_fallback(self, ctx: RenderContext) -> Optional[Image.Image]:
"""
Process an image on-demand when not found in the pre-processed cache.
This is a fallback for backwards compatibility or cache misses.
Returns:
Processed PIL Image or None if processing failed.
"""
# 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 None
try:
# Load image using resolved path
img: Image.Image = 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
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
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)
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
# Apply styling to image (rounded corners)
style = ctx.image_element.style
if style.corner_radius > 0:
from pyPhotoAlbum.image_utils import apply_rounded_corners
cropped_img = apply_rounded_corners(cropped_img, style.corner_radius)
return cropped_img
except Exception as e:
warning = f"Page {ctx.page_number}: Error processing image {ctx.image_element.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return None
def _draw_shadow_pdf(self, ctx: RenderContext):
"""Draw drop shadow in PDF output."""
style = ctx.image_element.style
# Convert shadow offset from mm to points
offset_x_pt = style.shadow_offset[0] * self.MM_TO_POINTS
offset_y_pt = -style.shadow_offset[1] * self.MM_TO_POINTS # Y is inverted in PDF
# Shadow color (normalize to 0-1)
r, g, b, a = style.shadow_color
shadow_alpha = a / 255.0
# Draw shadow rectangle
ctx.canvas.saveState()
ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha)
# Calculate corner radius in points
if style.corner_radius > 0:
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
ctx.canvas.roundRect(
ctx.x_pt + offset_x_pt,
ctx.y_pt + offset_y_pt,
ctx.width_pt,
ctx.height_pt,
radius_pt,
stroke=0,
fill=1,
)
else:
ctx.canvas.rect(
ctx.x_pt + offset_x_pt,
ctx.y_pt + offset_y_pt,
ctx.width_pt,
ctx.height_pt,
stroke=0,
fill=1,
)
ctx.canvas.restoreState()
def _draw_border_pdf(self, ctx: RenderContext):
"""Draw styled border in PDF output."""
style = ctx.image_element.style
# Border width in points
border_width_pt = style.border_width * self.MM_TO_POINTS
# Border color (normalize to 0-1)
r, g, b = style.border_color
ctx.canvas.saveState()
ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0)
ctx.canvas.setLineWidth(border_width_pt)
# Calculate corner radius in points
if style.corner_radius > 0:
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
ctx.canvas.roundRect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, radius_pt, stroke=1, fill=0)
else:
ctx.canvas.rect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, stroke=1, fill=0)
ctx.canvas.restoreState()
def _draw_frame_pdf(self, ctx: RenderContext):
"""Draw decorative frame in PDF output."""
from pyPhotoAlbum.frame_manager import get_frame_manager
style = ctx.image_element.style
frame_manager = get_frame_manager()
frame_manager.render_frame_pdf(
canvas=ctx.canvas,
frame_name=style.frame_style, # type: ignore[arg-type]
x_pt=ctx.x_pt,
y_pt=ctx.y_pt,
width_pt=ctx.width_pt,
height_pt=ctx.height_pt,
color=style.frame_color,
corners=style.frame_corners,
)
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,
"justify": TA_JUSTIFY,
}
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()