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

1""" 

2PDF export functionality for pyPhotoAlbum 

3 

4Uses multiprocessing to pre-process images in parallel for faster exports. 

5""" 

6 

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) 

29 

30 

31@dataclass 

32class ImageTask: 

33 """Parameters needed to process an image in a worker process.""" 

34 

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 

46 

47 

48def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]: 

49 """ 

50 Process a single image task in a worker process. 

51 

52 This function runs in a separate process and handles all CPU-intensive 

53 image operations: loading, rotation, cropping, resizing, and styling. 

54 

55 Args: 

56 task: ImageTask with all parameters needed for processing 

57 

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") 

65 

66 # Import PIL first with basic load 

67 from PIL import Image 

68 

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 

74 

75 return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}") 

76 

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 

88 

89 return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}") 

90 

91 # Convert to RGBA 

92 img = convert_to_rgba(img) 

93 

94 # Apply PIL-level rotation if needed 

95 if task.pil_rotation_90 > 0: 

96 img = apply_pil_rotation(img, task.pil_rotation_90) 

97 

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 

102 

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 ) 

112 

113 # Crop the image 

114 cropped_img = crop_image_to_coords(img, crop_coords) 

115 

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 ) 

123 

124 # Apply rounded corners if needed 

125 if task.corner_radius > 0: 

126 cropped_img = apply_rounded_corners(cropped_img, task.corner_radius) 

127 

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) 

138 

139 except Exception as e: 

140 import traceback 

141 

142 return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}") 

143 

144 

145@dataclass 

146class RenderContext: 

147 """Parameters for rendering an image element""" 

148 

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 

160 

161 

162@dataclass 

163class SplitRenderParams: 

164 """Parameters for rendering a split element""" 

165 

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 

174 

175 

176class PDFExporter: 

177 """Handles PDF export of photo album projects""" 

178 

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 

182 

183 def __init__(self, project, export_dpi: int = 300, max_workers: Optional[int] = None): 

184 """ 

185 Initialize PDF exporter with a project. 

186 

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] = {} 

200 

201 def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]: 

202 """ 

203 Export the project to PDF. 

204 

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. 

208 

209 Args: 

210 output_path: Path where PDF should be saved 

211 progress_callback: Optional callback(current, total, message) for progress updates 

212 

213 Returns: 

214 Tuple of (success: bool, warnings: List[str]) 

215 """ 

216 self.warnings = [] 

217 self._processed_images = {} 

218 

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 ) 

224 

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 

231 

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) 

240 

241 # Phase 2: determine ordered page sequence (inserts blank pages for spread alignment) 

242 page_sequence = self._compute_page_sequence() 

243 

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 

249 

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}...") 

266 

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) 

271 

272 self._processed_images = {} 

273 if progress_callback: 

274 progress_callback(total_pages, total_pages, "Export complete!") 

275 return True, self.warnings 

276 

277 except Exception as e: 

278 self.warnings.append(f"Export failed: {str(e)}") 

279 return False, self.warnings 

280 

281 def _compute_page_sequence(self) -> List[Tuple[str, Any]]: 

282 """ 

283 Build an ordered list of (page_type, page) items to render. 

284 

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 

304 

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). 

314 

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() 

322 

323 if page_type == "blank": 

324 c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt)) 

325 c.showPage() 

326 c.save() 

327 

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() 

337 

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() 

344 

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() 

349 

350 return buf.getvalue() 

351 

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() 

359 

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 

363 

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) 

373 

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}" 

384 

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. 

388 

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 

394 

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) 

408 

409 return tasks 

410 

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 

421 

422 for element in page.layout.elements: 

423 if not isinstance(element, ImageData): 

424 continue 

425 

426 image_path = element.resolve_image_path() 

427 if not image_path: 

428 continue 

429 

430 # Calculate dimensions 

431 element_x_px, element_y_px = element.position 

432 element_width_px, element_height_px = element.size 

433 

434 element_width_mm = element_width_px * 25.4 / dpi 

435 element_height_mm = element_height_px * 25.4 / dpi 

436 

437 width_pt = element_width_mm * self.MM_TO_POINTS 

438 height_pt = element_height_mm * self.MM_TO_POINTS 

439 

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) 

442 

443 task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt) 

444 

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) 

459 

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 

472 

473 for element in page.layout.elements: 

474 if not isinstance(element, ImageData): 

475 continue 

476 

477 image_path = element.resolve_image_path() 

478 if not image_path: 

479 continue 

480 

481 element_x_px, element_y_px = element.position 

482 element_width_px, element_height_px = element.size 

483 

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 

487 

488 width_pt = element_width_mm * self.MM_TO_POINTS 

489 height_pt = element_height_mm * self.MM_TO_POINTS 

490 

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) 

505 

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) 

511 

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 

526 

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) 

529 

530 task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt) 

531 

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) 

546 

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. 

555 

556 Results are stored in self._processed_images for use during PDF assembly. 

557 """ 

558 completed = 0 

559 total_tasks = len(tasks) 

560 

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} 

563 

564 for future in as_completed(future_to_task): 

565 task = future_to_task[future] 

566 completed += 1 

567 

568 try: 

569 task_id, image_bytes, error = future.result() 

570 

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 

585 

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) 

590 

591 if progress_callback and completed % 5 == 0: 

592 progress_callback( 

593 0, 

594 total_pages, 

595 f"Processing images: {completed}/{total_tasks}...", 

596 ) 

597 

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 

605 

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 

609 

610 # Create a new page with cover dimensions 

611 c.setPageSize((cover_width_pt, cover_height_pt)) 

612 

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") 

616 

617 c.showPage() # Finish cover page 

618 

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)) 

626 

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) 

630 

631 c.showPage() # Finish this page 

632 

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) 

640 

641 # Convert center line to pixels for comparison 

642 dpi = self.project.working_dpi 

643 center_px = center_mm * dpi / 25.4 

644 

645 # Calculate threshold for tiny elements (1:500) in pixels 

646 threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4 

647 

648 expanded_width_pt = page_width_pt + 2 * bleed_pt 

649 expanded_height_pt = page_height_pt + 2 * bleed_pt 

650 

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 

656 

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) 

677 

678 c.showPage() # Finish left page 

679 

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 

685 

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) 

705 

706 c.showPage() # Finish right page 

707 

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. 

720 

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 

733 

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 

737 

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 

744 

745 # Adjust x position for offset (now in mm) 

746 adjusted_x_mm = element_x_mm - x_offset_mm 

747 

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 

754 

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) 

768 

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). 

772 

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 

780 

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 

784 

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 

791 

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 

804 

805 # Adjust render position for offset 

806 adjusted_x_mm = render_x_mm - params.x_offset_mm 

807 

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 

818 

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 

822 

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) 

838 

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 ) 

855 

856 def _render_image(self, ctx: RenderContext): 

857 """ 

858 Render an image element on the PDF canvas. 

859 

860 Uses pre-processed images from the cache when available (parallel processing), 

861 otherwise falls back to processing the image on-demand. 

862 

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) 

869 

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 

875 

876 try: 

877 style = ctx.image_element.style 

878 

879 # Save state for transformations 

880 ctx.canvas.saveState() 

881 

882 # Draw drop shadow first (behind image) 

883 if style.shadow_enabled: 

884 self._draw_shadow_pdf(ctx) 

885 

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 ) 

909 

910 # Draw border on top of image 

911 if style.border_width > 0: 

912 self._draw_border_pdf(ctx) 

913 

914 # Draw decorative frame if specified 

915 if style.frame_style: 

916 self._draw_frame_pdf(ctx) 

917 

918 ctx.canvas.restoreState() 

919 

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) 

924 

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. 

928 

929 This is a fallback for backwards compatibility or cache misses. 

930 

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() 

936 

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 

943 

944 try: 

945 # Load image using resolved path 

946 img: Image.Image = Image.open(image_full_path) 

947 img = convert_to_rgba(img) 

948 

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) 

952 

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 

957 

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 

965 

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 ) 

975 

976 # Crop the image 

977 cropped_img = crop_image_to_coords(img, crop_coords) 

978 

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) 

982 

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) 

986 

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 

991 

992 cropped_img = apply_rounded_corners(cropped_img, style.corner_radius) 

993 

994 return cropped_img 

995 

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 

1001 

1002 def _draw_shadow_pdf(self, ctx: RenderContext): 

1003 """Draw drop shadow in PDF output.""" 

1004 style = ctx.image_element.style 

1005 

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 

1009 

1010 # Shadow color (normalize to 0-1) 

1011 r, g, b, a = style.shadow_color 

1012 shadow_alpha = a / 255.0 

1013 

1014 # Draw shadow rectangle 

1015 ctx.canvas.saveState() 

1016 ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha) 

1017 

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() 

1041 

1042 def _draw_border_pdf(self, ctx: RenderContext): 

1043 """Draw styled border in PDF output.""" 

1044 style = ctx.image_element.style 

1045 

1046 # Border width in points 

1047 border_width_pt = style.border_width * self.MM_TO_POINTS 

1048 

1049 # Border color (normalize to 0-1) 

1050 r, g, b = style.border_color 

1051 

1052 ctx.canvas.saveState() 

1053 ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0) 

1054 ctx.canvas.setLineWidth(border_width_pt) 

1055 

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) 

1063 

1064 ctx.canvas.restoreState() 

1065 

1066 def _draw_frame_pdf(self, ctx: RenderContext): 

1067 """Draw decorative frame in PDF output.""" 

1068 from pyPhotoAlbum.frame_manager import get_frame_manager 

1069 

1070 style = ctx.image_element.style 

1071 frame_manager = get_frame_manager() 

1072 

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 ) 

1083 

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. 

1090 

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 

1098 

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)) 

1103 

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 

1109 

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) 

1117 

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 ) 

1126 

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) 

1135 

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 ) 

1145 

1146 # Escape special XML characters and convert newlines to <br/> tags 

1147 text_content = text_element.text_content 

1148 text_content = text_content.replace("&", "&amp;") 

1149 text_content = text_content.replace("<", "&lt;") 

1150 text_content = text_content.replace(">", "&gt;") 

1151 text_content = text_content.replace("\n", "<br/>") 

1152 

1153 # Create paragraph with the text 

1154 para = Paragraph(text_content, style) 

1155 

1156 # Save state for transformations 

1157 c.saveState() 

1158 

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) 

1167 

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) 

1171 

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 

1175 

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) 

1181 

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 

1186 

1187 para.drawOn(c, draw_x, draw_y) 

1188 

1189 except Exception as e: 

1190 warning = f"Error rendering text box: {str(e)}" 

1191 print(f"WARNING: {warning}") 

1192 self.warnings.append(warning) 

1193 

1194 finally: 

1195 c.restoreState()