Coverage for pyPhotoAlbum/pdf_exporter.py: 67%
545 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2PDF export functionality for pyPhotoAlbum
4Uses multiprocessing to pre-process images in parallel for faster exports.
5"""
7import os
8import threading
9from typing import Any, List, Tuple, Optional, Union, Dict
10from dataclasses import dataclass, field
11from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
12import multiprocessing
13import io
14from reportlab.lib.pagesizes import A4
15from reportlab.pdfgen import canvas
16from reportlab.lib.utils import ImageReader
17from reportlab.platypus import Paragraph
18from reportlab.lib.styles import ParagraphStyle
19from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
20from PIL import Image
21import math
22from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
23from pyPhotoAlbum.image_utils import (
24 apply_pil_rotation,
25 convert_to_rgba,
26 calculate_center_crop_coords,
27 crop_image_to_coords,
28)
31@dataclass
32class ImageTask:
33 """Parameters needed to process an image in a worker process."""
35 task_id: str
36 image_path: str
37 pil_rotation_90: int
38 crop_info: Tuple[float, float, float, float]
39 crop_left: float
40 crop_right: float
41 target_width: float
42 target_height: float
43 target_width_px: int
44 target_height_px: int
45 corner_radius: float
48def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]:
49 """
50 Process a single image task in a worker process.
52 This function runs in a separate process and handles all CPU-intensive
53 image operations: loading, rotation, cropping, resizing, and styling.
55 Args:
56 task: ImageTask with all parameters needed for processing
58 Returns:
59 Tuple of (task_id, processed_image_bytes or None, error_message or None)
60 """
61 try:
62 # Validate image_path is a string before proceeding
63 if not isinstance(task.image_path, str):
64 return (task.task_id, None, f"Invalid image_path type: {type(task.image_path).__name__}, expected str")
66 # Import PIL first with basic load
67 from PIL import Image
69 # Try to open the image - this is the most likely failure point
70 try:
71 img: Image.Image = Image.open(task.image_path)
72 except Exception as open_err:
73 import traceback
75 return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}")
77 # Now import the rest
78 try:
79 from pyPhotoAlbum.image_utils import (
80 apply_pil_rotation,
81 convert_to_rgba,
82 calculate_center_crop_coords,
83 crop_image_to_coords,
84 apply_rounded_corners,
85 )
86 except Exception as import_err:
87 import traceback
89 return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}")
91 # Convert to RGBA
92 img = convert_to_rgba(img)
94 # Apply PIL-level rotation if needed
95 if task.pil_rotation_90 > 0:
96 img = apply_pil_rotation(img, task.pil_rotation_90)
98 # Calculate final crop bounds (combining element crop with split crop)
99 crop_x_min, crop_y_min, crop_x_max, crop_y_max = task.crop_info
100 final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * task.crop_left
101 final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * task.crop_right
103 # Calculate center crop coordinates
104 img_width, img_height = img.size
105 crop_coords = calculate_center_crop_coords(
106 img_width,
107 img_height,
108 task.target_width,
109 task.target_height,
110 (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
111 )
113 # Crop the image
114 cropped_img = crop_image_to_coords(img, crop_coords)
116 # Downsample if needed
117 current_width, current_height = cropped_img.size
118 if current_width > task.target_width_px or current_height > task.target_height_px:
119 cropped_img = cropped_img.resize(
120 (task.target_width_px, task.target_height_px),
121 Image.Resampling.LANCZOS,
122 )
124 # Apply rounded corners if needed
125 if task.corner_radius > 0:
126 cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
128 # Serialize image: JPEG for photos (much smaller), PNG only when alpha is needed
129 buffer = io.BytesIO()
130 has_transparency = cropped_img.mode == "RGBA" and task.corner_radius > 0
131 if has_transparency:
132 cropped_img.save(buffer, format="PNG", optimize=False)
133 else:
134 # Convert to RGB for JPEG (drop alpha channel if present but unused)
135 rgb_img = cropped_img.convert("RGB")
136 rgb_img.save(buffer, format="JPEG", quality=92, optimize=True)
137 return (task.task_id, buffer.getvalue(), None)
139 except Exception as e:
140 import traceback
142 return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}")
145@dataclass
146class RenderContext:
147 """Parameters for rendering an image element"""
149 canvas: canvas.Canvas
150 image_element: ImageData
151 x_pt: float
152 y_pt: float
153 width_pt: float
154 height_pt: float
155 page_number: int
156 crop_left: float = 0.0
157 crop_right: float = 1.0
158 original_width_pt: Optional[float] = None
159 original_height_pt: Optional[float] = None
162@dataclass
163class SplitRenderParams:
164 """Parameters for rendering a split element"""
166 canvas: canvas.Canvas
167 element: Any
168 x_offset_mm: float
169 split_line_mm: float
170 page_width_pt: float
171 page_height_pt: float
172 page_number: int
173 side: str
176class PDFExporter:
177 """Handles PDF export of photo album projects"""
179 # Conversion constants
180 MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
181 SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
183 def __init__(self, project, export_dpi: int = 300, max_workers: Optional[int] = None):
184 """
185 Initialize PDF exporter with a project.
187 Args:
188 project: The Project instance to export
189 export_dpi: Target DPI for images in the PDF (default 300 for print quality)
190 Use 300 for high-quality print, 150 for screen/draft
191 max_workers: Maximum number of worker processes for parallel image processing.
192 Defaults to number of CPU cores.
193 """
194 self.project = project
195 self.export_dpi = export_dpi
196 self.warnings: List[str] = []
197 self.current_pdf_page = 1
198 self.max_workers = max_workers or multiprocessing.cpu_count()
199 self._processed_images: Dict[str, Image.Image] = {}
201 def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
202 """
203 Export the project to PDF.
205 Uses multiprocessing to pre-process all images in parallel, then renders
206 each page to its own PDF buffer in parallel (via threads), and finally
207 merges the per-page PDFs into the output file.
209 Args:
210 output_path: Path where PDF should be saved
211 progress_callback: Optional callback(current, total, message) for progress updates
213 Returns:
214 Tuple of (success: bool, warnings: List[str])
215 """
216 self.warnings = []
217 self._processed_images = {}
219 try:
220 # Calculate total pages for progress (cover counts as 1)
221 total_pages = sum(
222 1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
223 )
225 # Get page dimensions from project (in mm)
226 page_width_mm, page_height_mm = self.project.page_size_mm
227 bleed_mm = self.project.page_bleed_mm
228 bleed_pt = bleed_mm * self.MM_TO_POINTS
229 page_width_pt = page_width_mm * self.MM_TO_POINTS
230 page_height_pt = page_height_mm * self.MM_TO_POINTS
232 # Phase 1: parallel image pre-processing (unchanged)
233 if progress_callback:
234 progress_callback(0, total_pages, "Collecting images for processing...")
235 image_tasks = self._collect_image_tasks(page_width_pt, page_height_pt)
236 if image_tasks:
237 if progress_callback:
238 progress_callback(0, total_pages, f"Processing {len(image_tasks)} images in parallel...")
239 self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
241 # Phase 2: determine ordered page sequence (inserts blank pages for spread alignment)
242 page_sequence = self._compute_page_sequence()
244 # Phase 3: render each page to its own PDF bytes in parallel
245 n = len(page_sequence)
246 if progress_callback:
247 progress_callback(0, total_pages, f"Rendering {n} pages in parallel...")
248 pdf_bytes_list: List[Optional[bytes]] = [None] * n
250 with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
251 future_to_idx = {
252 executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i
253 for i, item in enumerate(page_sequence)
254 }
255 completed = 0
256 for future in as_completed(future_to_idx):
257 i = future_to_idx[future]
258 try:
259 pdf_bytes_list[i] = future.result()
260 except Exception as e:
261 self.warnings.append(f"Error rendering page: {str(e)}")
262 pdf_bytes_list[i] = self._make_blank_page_bytes(page_width_pt, page_height_pt, bleed_pt)
263 completed += 1
264 if progress_callback:
265 progress_callback(completed, n, f"Rendering pages: {completed}/{n}...")
267 # Phase 4: merge all per-page PDFs into the output file
268 if progress_callback:
269 progress_callback(n, total_pages, "Merging pages...")
270 self._merge_page_pdfs(pdf_bytes_list, output_path)
272 self._processed_images = {}
273 if progress_callback:
274 progress_callback(total_pages, total_pages, "Export complete!")
275 return True, self.warnings
277 except Exception as e:
278 self.warnings.append(f"Export failed: {str(e)}")
279 return False, self.warnings
281 def _compute_page_sequence(self) -> List[Tuple[str, Any]]:
282 """
283 Build an ordered list of (page_type, page) items to render.
285 Inserts ('blank', None) entries before double-page spreads that would
286 otherwise start on an odd-numbered PDF page (spreads must start on even pages).
287 """
288 sequence: List[Tuple[str, Any]] = []
289 pdf_page_num = 1
290 for page in self.project.pages:
291 if page.is_cover:
292 sequence.append(("cover", page))
293 pdf_page_num += 1
294 elif page.is_double_spread:
295 if pdf_page_num % 2 == 1:
296 sequence.append(("blank", None))
297 pdf_page_num += 1
298 sequence.append(("spread", page))
299 pdf_page_num += 2
300 else:
301 sequence.append(("single", page))
302 pdf_page_num += 1
303 return sequence
305 def _render_item_to_bytes(
306 self,
307 item: Tuple[str, Any],
308 page_width_pt: float,
309 page_height_pt: float,
310 bleed_pt: float,
311 ) -> bytes:
312 """
313 Render a single page item to a self-contained PDF (as bytes).
315 Each call creates its own Canvas / BytesIO so pages can be rendered
316 concurrently without sharing state.
317 """
318 page_type, page = item
319 expanded_width_pt = page_width_pt + 2 * bleed_pt
320 expanded_height_pt = page_height_pt + 2 * bleed_pt
321 buf = io.BytesIO()
323 if page_type == "blank":
324 c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
325 c.showPage()
326 c.save()
328 elif page_type == "cover":
329 cover_width_mm, cover_height_mm = page.layout.size
330 cover_width_pt = cover_width_mm * self.MM_TO_POINTS
331 cover_height_pt = cover_height_mm * self.MM_TO_POINTS
332 c = canvas.Canvas(buf, pagesize=(cover_width_pt, cover_height_pt))
333 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
334 self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
335 c.showPage()
336 c.save()
338 elif page_type == "single":
339 c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
340 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
341 self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
342 c.showPage()
343 c.save()
345 elif page_type == "spread":
346 c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
347 self._export_spread(c, page, page_width_pt, page_height_pt, bleed_pt)
348 c.save()
350 return buf.getvalue()
352 def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> bytes:
353 """Return a minimal single-blank-page PDF for use as an error placeholder."""
354 buf = io.BytesIO()
355 c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt))
356 c.showPage()
357 c.save()
358 return buf.getvalue()
360 def _merge_page_pdfs(self, pdf_bytes_list: List[Optional[bytes]], output_path: str):
361 """Merge a list of single-page PDF byte strings into one output file."""
362 from pypdf import PdfWriter, PdfReader
364 writer = PdfWriter()
365 for pdf_bytes in pdf_bytes_list:
366 if pdf_bytes is None:
367 continue
368 reader = PdfReader(io.BytesIO(pdf_bytes))
369 for page in reader.pages:
370 writer.add_page(page)
371 with open(output_path, "wb") as f:
372 writer.write(f)
374 def _make_task_id(
375 self,
376 element: ImageData,
377 crop_left: float = 0.0,
378 crop_right: float = 1.0,
379 width_pt: float = 0.0,
380 height_pt: float = 0.0,
381 ) -> str:
382 """Generate a unique task ID for an image element with specific render params."""
383 return f"{id(element)}_{crop_left:.4f}_{crop_right:.4f}_{width_pt:.2f}_{height_pt:.2f}"
385 def _collect_image_tasks(self, page_width_pt: float, page_height_pt: float) -> List[ImageTask]:
386 """
387 Collect all image processing tasks from the project.
389 Scans all pages and elements to build a list of ImageTask objects
390 that can be processed in parallel.
391 """
392 tasks: List[ImageTask] = []
393 dpi = self.project.working_dpi
395 for page in self.project.pages:
396 if page.is_cover:
397 cover_width_mm, cover_height_mm = page.layout.size
398 cover_width_pt = cover_width_mm * self.MM_TO_POINTS
399 cover_height_pt = cover_height_mm * self.MM_TO_POINTS
400 self._collect_page_tasks(tasks, page, 0, cover_width_pt, cover_height_pt)
401 elif page.is_double_spread:
402 # Collect tasks for both left and right pages of the spread
403 page_width_mm = self.project.page_size_mm[0]
404 center_mm = page_width_mm
405 self._collect_spread_tasks(tasks, page, page_width_pt, page_height_pt, center_mm)
406 else:
407 self._collect_page_tasks(tasks, page, 0, page_width_pt, page_height_pt)
409 return tasks
411 def _collect_page_tasks(
412 self,
413 tasks: List[ImageTask],
414 page,
415 x_offset_mm: float,
416 page_width_pt: float,
417 page_height_pt: float,
418 ):
419 """Collect image tasks from a single page."""
420 dpi = self.project.working_dpi
422 for element in page.layout.elements:
423 if not isinstance(element, ImageData):
424 continue
426 image_path = element.resolve_image_path()
427 if not image_path:
428 continue
430 # Calculate dimensions
431 element_x_px, element_y_px = element.position
432 element_width_px, element_height_px = element.size
434 element_width_mm = element_width_px * 25.4 / dpi
435 element_height_mm = element_height_px * 25.4 / dpi
437 width_pt = element_width_mm * self.MM_TO_POINTS
438 height_pt = element_height_mm * self.MM_TO_POINTS
440 target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
441 target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
443 task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt)
445 task = ImageTask(
446 task_id=task_id,
447 image_path=image_path,
448 pil_rotation_90=getattr(element, "pil_rotation_90", 0),
449 crop_info=element.crop_info,
450 crop_left=0.0,
451 crop_right=1.0,
452 target_width=width_pt,
453 target_height=height_pt,
454 target_width_px=max(1, target_width_px),
455 target_height_px=max(1, target_height_px),
456 corner_radius=element.style.corner_radius,
457 )
458 tasks.append(task)
460 def _collect_spread_tasks(
461 self,
462 tasks: List[ImageTask],
463 page,
464 page_width_pt: float,
465 page_height_pt: float,
466 center_mm: float,
467 ):
468 """Collect image tasks from a double-page spread, handling split elements."""
469 dpi = self.project.working_dpi
470 center_px = center_mm * dpi / 25.4
471 threshold_px = self.project.page_size_mm[0] * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
473 for element in page.layout.elements:
474 if not isinstance(element, ImageData):
475 continue
477 image_path = element.resolve_image_path()
478 if not image_path:
479 continue
481 element_x_px, element_y_px = element.position
482 element_width_px, element_height_px = element.size
484 element_x_mm = element_x_px * 25.4 / dpi
485 element_width_mm = element_width_px * 25.4 / dpi
486 element_height_mm = element_height_px * 25.4 / dpi
488 width_pt = element_width_mm * self.MM_TO_POINTS
489 height_pt = element_height_mm * self.MM_TO_POINTS
491 # Check if element spans the center
492 if element_x_px + element_width_px <= center_px + threshold_px:
493 # Entirely on left page
494 self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
495 elif element_x_px >= center_px - threshold_px:
496 # Entirely on right page
497 self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
498 else:
499 # Spanning element - create tasks for left and right portions
500 # Left portion
501 crop_width_mm_left = center_mm - element_x_mm
502 crop_right_left = crop_width_mm_left / element_width_mm
503 left_width_pt = crop_width_mm_left * self.MM_TO_POINTS
504 self._add_image_task(tasks, element, image_path, 0.0, crop_right_left, left_width_pt, height_pt)
506 # Right portion
507 crop_x_start_right = center_mm - element_x_mm
508 crop_left_right = crop_x_start_right / element_width_mm
509 right_width_pt = (element_width_mm - crop_x_start_right) * self.MM_TO_POINTS
510 self._add_image_task(tasks, element, image_path, crop_left_right, 1.0, right_width_pt, height_pt)
512 def _add_image_task(
513 self,
514 tasks: List[ImageTask],
515 element: ImageData,
516 image_path: str,
517 crop_left: float,
518 crop_right: float,
519 width_pt: float,
520 height_pt: float,
521 ):
522 """Helper to add an image task to the list."""
523 # Use original dimensions for aspect ratio calculation
524 original_width_pt = width_pt / (crop_right - crop_left) if crop_right != crop_left else width_pt
525 original_height_pt = height_pt
527 target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
528 target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
530 task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt)
532 task = ImageTask(
533 task_id=task_id,
534 image_path=image_path,
535 pil_rotation_90=getattr(element, "pil_rotation_90", 0),
536 crop_info=element.crop_info,
537 crop_left=crop_left,
538 crop_right=crop_right,
539 target_width=original_width_pt,
540 target_height=original_height_pt,
541 target_width_px=max(1, target_width_px),
542 target_height_px=max(1, target_height_px),
543 corner_radius=element.style.corner_radius,
544 )
545 tasks.append(task)
547 def _preprocess_images_parallel(
548 self,
549 tasks: List[ImageTask],
550 progress_callback,
551 total_pages: int,
552 ):
553 """
554 Process all image tasks in parallel using a process pool.
556 Results are stored in self._processed_images for use during PDF assembly.
557 """
558 completed = 0
559 total_tasks = len(tasks)
561 with ProcessPoolExecutor(max_workers=self.max_workers) as executor:
562 future_to_task = {executor.submit(_process_image_task, task): task for task in tasks}
564 for future in as_completed(future_to_task):
565 task = future_to_task[future]
566 completed += 1
568 try:
569 task_id, image_bytes, error = future.result()
571 if error:
572 warning = f"Error processing image {task.image_path}: {error}"
573 print(f"WARNING: {warning}")
574 self.warnings.append(warning)
575 elif image_bytes:
576 # Deserialize the image from bytes
577 buffer = io.BytesIO(image_bytes)
578 img = Image.open(buffer)
579 # Force load the image data into memory
580 img.load()
581 # Store both image and buffer reference to prevent garbage collection
582 # Some PIL operations may still reference the source buffer
583 img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
584 self._processed_images[task_id] = img
586 except Exception as e:
587 warning = f"Error processing image {task.image_path}: {str(e)}"
588 print(f"WARNING: {warning}")
589 self.warnings.append(warning)
591 if progress_callback and completed % 5 == 0:
592 progress_callback(
593 0,
594 total_pages,
595 f"Processing images: {completed}/{total_tasks}...",
596 )
598 def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
599 """
600 Export a cover page to PDF.
601 Cover has different dimensions (wrap-around: front + spine + back + bleed).
602 """
603 # Get cover dimensions (already calculated in page.layout.size)
604 cover_width_mm, cover_height_mm = page.layout.size
606 # Convert to PDF points
607 cover_width_pt = cover_width_mm * self.MM_TO_POINTS
608 cover_height_pt = cover_height_mm * self.MM_TO_POINTS
610 # Create a new page with cover dimensions
611 c.setPageSize((cover_width_pt, cover_height_pt))
613 # Render all elements on the cover
614 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
615 self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
617 c.showPage() # Finish cover page
619 def _export_single_page(
620 self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
621 ):
622 """Export a single page to PDF"""
623 expanded_width_pt = page_width_pt + 2 * bleed_pt
624 expanded_height_pt = page_height_pt + 2 * bleed_pt
625 c.setPageSize((expanded_width_pt, expanded_height_pt))
627 # Render all elements, offset by bleed
628 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
629 self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
631 c.showPage() # Finish this page
633 def _export_spread(
634 self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
635 ):
636 """Export a double-page spread as two PDF pages"""
637 # Get center line position in mm
638 page_width_mm = self.project.page_size_mm[0]
639 center_mm = page_width_mm # Center of the spread (which is 2x width)
641 # Convert center line to pixels for comparison
642 dpi = self.project.working_dpi
643 center_px = center_mm * dpi / 25.4
645 # Calculate threshold for tiny elements (1:500) in pixels
646 threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
648 expanded_width_pt = page_width_pt + 2 * bleed_pt
649 expanded_height_pt = page_height_pt + 2 * bleed_pt
651 # Process elements for left page
652 c.setPageSize((expanded_width_pt, expanded_height_pt))
653 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
654 element_x_px, element_y_px = element.position
655 element_width_px, element_height_px = element.size
657 # Check if element is on left page, right page, or spanning (compare in pixels)
658 if element_x_px + element_width_px <= center_px + threshold_px:
659 # Entirely on left page
660 self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
661 elif element_x_px >= center_px - threshold_px:
662 # Skip for now, will render on right page
663 pass
664 else:
665 # Spanning element - render left portion
666 params = SplitRenderParams(
667 canvas=c,
668 element=element,
669 x_offset_mm=0,
670 split_line_mm=center_mm,
671 page_width_pt=page_width_pt,
672 page_height_pt=page_height_pt,
673 page_number=page.page_number,
674 side="left",
675 )
676 self._render_split_element(params, bleed_pt)
678 c.showPage() # Finish left page
680 # Process elements for right page
681 c.setPageSize((expanded_width_pt, expanded_height_pt))
682 for element in sorted(page.layout.elements, key=lambda x: x.z_index):
683 element_x_px, element_y_px = element.position
684 element_width_px, element_height_px = element.size
686 # Check if element is on right page or spanning (compare in pixels)
687 if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
688 # Entirely on right page or mostly on right
689 self._render_element(
690 c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1, bleed_pt
691 )
692 elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
693 # Spanning element - render right portion
694 params = SplitRenderParams(
695 canvas=c,
696 element=element,
697 x_offset_mm=center_mm,
698 split_line_mm=center_mm,
699 page_width_pt=page_width_pt,
700 page_height_pt=page_height_pt,
701 page_number=page.page_number + 1,
702 side="right",
703 )
704 self._render_split_element(params, bleed_pt)
706 c.showPage() # Finish right page
708 def _render_element(
709 self,
710 c: canvas.Canvas,
711 element,
712 x_offset_mm: float,
713 page_width_pt: float,
714 page_height_pt: float,
715 page_number: Union[int, str],
716 bleed_pt: float = 0.0,
717 ):
718 """
719 Render a single element on the PDF canvas.
721 Args:
722 c: ReportLab canvas
723 element: The layout element to render
724 x_offset_mm: X offset in mm (for right page of spread)
725 page_width_pt: Page width in points (cut/trim size, excluding bleed)
726 page_height_pt: Page height in points (cut/trim size, excluding bleed)
727 page_number: Current page number (for error messages)
728 bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
729 """
730 # Skip placeholders
731 if isinstance(element, PlaceholderData):
732 return
734 # Get element position and size (in PIXELS from OpenGL coordinates)
735 element_x_px, element_y_px = element.position
736 element_width_px, element_height_px = element.size
738 # Convert from pixels to mm using the working DPI
739 dpi = self.project.working_dpi
740 element_x_mm = element_x_px * 25.4 / dpi
741 element_y_mm = element_y_px * 25.4 / dpi
742 element_width_mm = element_width_px * 25.4 / dpi
743 element_height_mm = element_height_px * 25.4 / dpi
745 # Adjust x position for offset (now in mm)
746 adjusted_x_mm = element_x_mm - x_offset_mm
748 # Convert to PDF points and flip Y coordinate (PDF origin is bottom-left).
749 # bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
750 x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
751 y_pt = page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
752 width_pt = element_width_mm * self.MM_TO_POINTS
753 height_pt = element_height_mm * self.MM_TO_POINTS
755 if isinstance(element, ImageData):
756 ctx = RenderContext(
757 canvas=c,
758 image_element=element,
759 x_pt=x_pt,
760 y_pt=y_pt,
761 width_pt=width_pt,
762 height_pt=height_pt,
763 page_number=int(page_number),
764 )
765 self._render_image(ctx)
766 elif isinstance(element, TextBoxData):
767 self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
769 def _render_split_element(self, params: SplitRenderParams, bleed_pt: float = 0.0):
770 """
771 Render a split element (only the portion on one side of the split line).
773 Args:
774 params: SplitRenderParams containing all rendering parameters
775 bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
776 """
777 # Skip placeholders
778 if isinstance(params.element, PlaceholderData):
779 return
781 # Get element position and size in pixels
782 element_x_px, element_y_px = params.element.position
783 element_width_px, element_height_px = params.element.size
785 # Convert to mm
786 dpi = self.project.working_dpi
787 element_x_mm = element_x_px * 25.4 / dpi
788 element_y_mm = element_y_px * 25.4 / dpi
789 element_width_mm = element_width_px * 25.4 / dpi
790 element_height_mm = element_height_px * 25.4 / dpi
792 if isinstance(params.element, ImageData):
793 # Calculate which portion of the image to render
794 if params.side == "left":
795 # Render from element start to split line
796 crop_width_mm = params.split_line_mm - element_x_mm
797 crop_x_start = 0
798 render_x_mm = element_x_mm
799 else: # right
800 # Render from split line to element end
801 crop_width_mm = (element_x_mm + element_width_mm) - params.split_line_mm
802 crop_x_start = params.split_line_mm - element_x_mm
803 render_x_mm = params.split_line_mm # Start at split line in spread coordinates
805 # Adjust render position for offset
806 adjusted_x_mm = render_x_mm - params.x_offset_mm
808 # Convert to points (bleed_pt shifts content inside the expanded PDF page)
809 x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
810 y_pt = (
811 params.page_height_pt
812 + bleed_pt
813 - (element_y_mm * self.MM_TO_POINTS)
814 - (element_height_mm * self.MM_TO_POINTS)
815 )
816 width_pt = crop_width_mm * self.MM_TO_POINTS
817 height_pt = element_height_mm * self.MM_TO_POINTS
819 # Calculate original element dimensions in points (before splitting)
820 original_width_pt = element_width_mm * self.MM_TO_POINTS
821 original_height_pt = element_height_mm * self.MM_TO_POINTS
823 # Render cropped image with original dimensions for correct aspect ratio
824 ctx = RenderContext(
825 canvas=params.canvas,
826 image_element=params.element,
827 x_pt=x_pt,
828 y_pt=y_pt,
829 width_pt=width_pt,
830 height_pt=height_pt,
831 page_number=params.page_number,
832 crop_left=crop_x_start / element_width_mm,
833 crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
834 original_width_pt=original_width_pt,
835 original_height_pt=original_height_pt,
836 )
837 self._render_image(ctx)
839 elif isinstance(params.element, TextBoxData):
840 # For text boxes spanning the split, we'll render the whole text on the side
841 # where most of it appears (simpler than trying to split text)
842 element_center_mm = element_x_mm + element_width_mm / 2
843 if (params.side == "left" and element_center_mm < params.split_line_mm) or (
844 params.side == "right" and element_center_mm >= params.split_line_mm
845 ):
846 self._render_element(
847 params.canvas,
848 params.element,
849 params.x_offset_mm,
850 params.page_width_pt,
851 params.page_height_pt,
852 params.page_number,
853 bleed_pt,
854 )
856 def _render_image(self, ctx: RenderContext):
857 """
858 Render an image element on the PDF canvas.
860 Uses pre-processed images from the cache when available (parallel processing),
861 otherwise falls back to processing the image on-demand.
863 Args:
864 ctx: RenderContext containing all rendering parameters
865 """
866 # Check for pre-processed image in cache
867 task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt)
868 cropped_img = self._processed_images.get(task_id)
870 if cropped_img is None:
871 # Fallback: process image on-demand (for backwards compatibility or cache miss)
872 cropped_img = self._process_image_fallback(ctx)
873 if cropped_img is None:
874 return
876 try:
877 style = ctx.image_element.style
879 # Save state for transformations
880 ctx.canvas.saveState()
882 # Draw drop shadow first (behind image)
883 if style.shadow_enabled:
884 self._draw_shadow_pdf(ctx)
886 # Apply rotation to canvas if needed
887 if ctx.image_element.rotation != 0:
888 # Move to element center
889 center_x = ctx.x_pt + ctx.width_pt / 2
890 center_y = ctx.y_pt + ctx.height_pt / 2
891 ctx.canvas.translate(center_x, center_y)
892 ctx.canvas.rotate(-ctx.image_element.rotation)
893 ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
894 # Draw at origin after transformation
895 ctx.canvas.drawImage(
896 ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
897 )
898 else:
899 # Draw without rotation
900 ctx.canvas.drawImage(
901 ImageReader(cropped_img),
902 ctx.x_pt,
903 ctx.y_pt,
904 ctx.width_pt,
905 ctx.height_pt,
906 mask="auto",
907 preserveAspectRatio=False,
908 )
910 # Draw border on top of image
911 if style.border_width > 0:
912 self._draw_border_pdf(ctx)
914 # Draw decorative frame if specified
915 if style.frame_style:
916 self._draw_frame_pdf(ctx)
918 ctx.canvas.restoreState()
920 except Exception as e:
921 warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}"
922 print(f"WARNING: {warning}")
923 self.warnings.append(warning)
925 def _process_image_fallback(self, ctx: RenderContext) -> Optional[Image.Image]:
926 """
927 Process an image on-demand when not found in the pre-processed cache.
929 This is a fallback for backwards compatibility or cache misses.
931 Returns:
932 Processed PIL Image or None if processing failed.
933 """
934 # Resolve image path using ImageData's method
935 image_full_path = ctx.image_element.resolve_image_path()
937 # Check if image exists
938 if not image_full_path:
939 warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
940 print(f"WARNING: {warning}")
941 self.warnings.append(warning)
942 return None
944 try:
945 # Load image using resolved path
946 img: Image.Image = Image.open(image_full_path)
947 img = convert_to_rgba(img)
949 # Apply PIL-level rotation if needed
950 if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
951 img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
953 # Get element's crop_info and combine with split cropping if applicable
954 crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
955 final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
956 final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
958 # Determine target dimensions for aspect ratio
959 if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
960 target_width = ctx.original_width_pt
961 target_height = ctx.original_height_pt
962 else:
963 target_width = ctx.width_pt
964 target_height = ctx.height_pt
966 # Calculate center crop coordinates
967 img_width, img_height = img.size
968 crop_coords = calculate_center_crop_coords(
969 img_width,
970 img_height,
971 target_width,
972 target_height,
973 (final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
974 )
976 # Crop the image
977 cropped_img = crop_image_to_coords(img, crop_coords)
979 # Downsample image to target resolution based on export DPI
980 target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
981 target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
983 current_width, current_height = cropped_img.size
984 if current_width > target_width_px or current_height > target_height_px:
985 cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
987 # Apply styling to image (rounded corners)
988 style = ctx.image_element.style
989 if style.corner_radius > 0:
990 from pyPhotoAlbum.image_utils import apply_rounded_corners
992 cropped_img = apply_rounded_corners(cropped_img, style.corner_radius)
994 return cropped_img
996 except Exception as e:
997 warning = f"Page {ctx.page_number}: Error processing image {ctx.image_element.image_path}: {str(e)}"
998 print(f"WARNING: {warning}")
999 self.warnings.append(warning)
1000 return None
1002 def _draw_shadow_pdf(self, ctx: RenderContext):
1003 """Draw drop shadow in PDF output."""
1004 style = ctx.image_element.style
1006 # Convert shadow offset from mm to points
1007 offset_x_pt = style.shadow_offset[0] * self.MM_TO_POINTS
1008 offset_y_pt = -style.shadow_offset[1] * self.MM_TO_POINTS # Y is inverted in PDF
1010 # Shadow color (normalize to 0-1)
1011 r, g, b, a = style.shadow_color
1012 shadow_alpha = a / 255.0
1014 # Draw shadow rectangle
1015 ctx.canvas.saveState()
1016 ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha)
1018 # Calculate corner radius in points
1019 if style.corner_radius > 0:
1020 shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
1021 radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
1022 ctx.canvas.roundRect(
1023 ctx.x_pt + offset_x_pt,
1024 ctx.y_pt + offset_y_pt,
1025 ctx.width_pt,
1026 ctx.height_pt,
1027 radius_pt,
1028 stroke=0,
1029 fill=1,
1030 )
1031 else:
1032 ctx.canvas.rect(
1033 ctx.x_pt + offset_x_pt,
1034 ctx.y_pt + offset_y_pt,
1035 ctx.width_pt,
1036 ctx.height_pt,
1037 stroke=0,
1038 fill=1,
1039 )
1040 ctx.canvas.restoreState()
1042 def _draw_border_pdf(self, ctx: RenderContext):
1043 """Draw styled border in PDF output."""
1044 style = ctx.image_element.style
1046 # Border width in points
1047 border_width_pt = style.border_width * self.MM_TO_POINTS
1049 # Border color (normalize to 0-1)
1050 r, g, b = style.border_color
1052 ctx.canvas.saveState()
1053 ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0)
1054 ctx.canvas.setLineWidth(border_width_pt)
1056 # Calculate corner radius in points
1057 if style.corner_radius > 0:
1058 shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
1059 radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
1060 ctx.canvas.roundRect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, radius_pt, stroke=1, fill=0)
1061 else:
1062 ctx.canvas.rect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, stroke=1, fill=0)
1064 ctx.canvas.restoreState()
1066 def _draw_frame_pdf(self, ctx: RenderContext):
1067 """Draw decorative frame in PDF output."""
1068 from pyPhotoAlbum.frame_manager import get_frame_manager
1070 style = ctx.image_element.style
1071 frame_manager = get_frame_manager()
1073 frame_manager.render_frame_pdf(
1074 canvas=ctx.canvas,
1075 frame_name=style.frame_style, # type: ignore[arg-type]
1076 x_pt=ctx.x_pt,
1077 y_pt=ctx.y_pt,
1078 width_pt=ctx.width_pt,
1079 height_pt=ctx.height_pt,
1080 color=style.frame_color,
1081 corners=style.frame_corners,
1082 )
1084 def _render_textbox(
1085 self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
1086 ):
1087 """
1088 Render a text box element on the PDF canvas with transparent background.
1089 Text is word-wrapped to fit within the box boundaries.
1091 Args:
1092 c: ReportLab canvas
1093 text_element: TextBoxData instance
1094 x_pt, y_pt, width_pt, height_pt: Position and size in points
1095 """
1096 if not text_element.text_content:
1097 return
1099 # Get font settings
1100 font_family = text_element.font_settings.get("family", "Helvetica")
1101 font_size_px = text_element.font_settings.get("size", 12)
1102 font_color = text_element.font_settings.get("color", (0, 0, 0))
1104 # Convert font size from pixels to PDF points (same conversion as element dimensions)
1105 # Font size is stored in pixels at working_dpi, same as element position/size
1106 dpi = self.project.working_dpi
1107 font_size_mm = font_size_px * 25.4 / dpi
1108 font_size = font_size_mm * self.MM_TO_POINTS
1110 # Map common font names to ReportLab standard fonts
1111 font_map = {
1112 "Arial": "Helvetica",
1113 "Times New Roman": "Times-Roman",
1114 "Courier New": "Courier",
1115 }
1116 font_family = font_map.get(font_family, font_family)
1118 # Normalize color to hex for Paragraph style
1119 if all(isinstance(x, int) and x > 1 for x in font_color):
1120 color_hex = "#{:02x}{:02x}{:02x}".format(*font_color)
1121 else:
1122 # Convert 0-1 range to 0-255 then to hex
1123 color_hex = "#{:02x}{:02x}{:02x}".format(
1124 int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
1125 )
1127 # Map alignment to ReportLab constants
1128 alignment_map = {
1129 "left": TA_LEFT,
1130 "center": TA_CENTER,
1131 "right": TA_RIGHT,
1132 "justify": TA_JUSTIFY,
1133 }
1134 text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)
1136 # Create paragraph style with word wrapping
1137 style = ParagraphStyle(
1138 "textbox",
1139 fontName=font_family,
1140 fontSize=font_size,
1141 leading=font_size * 1.2, # Line spacing (120% of font size)
1142 textColor=color_hex,
1143 alignment=text_alignment,
1144 )
1146 # Escape special XML characters and convert newlines to <br/> tags
1147 text_content = text_element.text_content
1148 text_content = text_content.replace("&", "&")
1149 text_content = text_content.replace("<", "<")
1150 text_content = text_content.replace(">", ">")
1151 text_content = text_content.replace("\n", "<br/>")
1153 # Create paragraph with the text
1154 para = Paragraph(text_content, style)
1156 # Save state for transformations
1157 c.saveState()
1159 try:
1160 # Apply rotation if needed
1161 if text_element.rotation != 0:
1162 # Move to element center
1163 center_x = x_pt + width_pt / 2
1164 center_y = y_pt + height_pt / 2
1165 c.translate(center_x, center_y)
1166 c.rotate(text_element.rotation)
1168 # Wrap and draw paragraph relative to center
1169 # wrapOn calculates the actual height needed
1170 para_width, para_height = para.wrapOn(c, width_pt, height_pt)
1172 # Position at top-left of box (relative to center after rotation)
1173 draw_x = -width_pt / 2
1174 draw_y = height_pt / 2 - para_height
1176 para.drawOn(c, draw_x, draw_y)
1177 else:
1178 # No rotation - draw normally
1179 # wrapOn calculates the actual height needed for the wrapped text
1180 para_width, para_height = para.wrapOn(c, width_pt, height_pt)
1182 # drawOn draws from bottom-left of the paragraph
1183 # We want text at top of box, so: draw_y = box_top - para_height
1184 draw_x = x_pt
1185 draw_y = y_pt + height_pt - para_height
1187 para.drawOn(c, draw_x, draw_y)
1189 except Exception as e:
1190 warning = f"Error rendering text box: {str(e)}"
1191 print(f"WARNING: {warning}")
1192 self.warnings.append(warning)
1194 finally:
1195 c.restoreState()