1 """

2 PDF export functionality for pyPhotoAlbum

3

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

5 """

6

7 import os

8 import threading

9 from typing import Any, List, Tuple, Optional, Union, Dict

10 from dataclasses import dataclass, field

11 from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed

12 import multiprocessing

13 import io

14 from reportlab.lib.pagesizes import A4

15 from reportlab.pdfgen import canvas

16 from reportlab.lib.utils import ImageReader

17 from reportlab.platypus import Paragraph

18 from reportlab.lib.styles import ParagraphStyle

19 from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY

20 from PIL import Image

21 import math

22 from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData

23 from 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

32 class 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

48 def _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

146 class 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

163 class 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

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