1196 lines
47 KiB
Python
1196 lines
47 KiB
Python
"""
|
|
PDF export functionality for pyPhotoAlbum
|
|
|
|
Uses multiprocessing to pre-process images in parallel for faster exports.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
from typing import Any, List, Tuple, Optional, Union, Dict
|
|
from dataclasses import dataclass, field
|
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
|
import multiprocessing
|
|
import io
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.lib.utils import ImageReader
|
|
from reportlab.platypus import Paragraph
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY
|
|
from PIL import Image
|
|
import math
|
|
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
|
|
from pyPhotoAlbum.image_utils import (
|
|
apply_pil_rotation,
|
|
convert_to_rgba,
|
|
calculate_center_crop_coords,
|
|
crop_image_to_coords,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ImageTask:
|
|
"""Parameters needed to process an image in a worker process."""
|
|
|
|
task_id: str
|
|
image_path: str
|
|
pil_rotation_90: int
|
|
crop_info: Tuple[float, float, float, float]
|
|
crop_left: float
|
|
crop_right: float
|
|
target_width: float
|
|
target_height: float
|
|
target_width_px: int
|
|
target_height_px: int
|
|
corner_radius: float
|
|
|
|
|
|
def _process_image_task(task: ImageTask) -> Tuple[str, Optional[bytes], Optional[str]]:
|
|
"""
|
|
Process a single image task in a worker process.
|
|
|
|
This function runs in a separate process and handles all CPU-intensive
|
|
image operations: loading, rotation, cropping, resizing, and styling.
|
|
|
|
Args:
|
|
task: ImageTask with all parameters needed for processing
|
|
|
|
Returns:
|
|
Tuple of (task_id, processed_image_bytes or None, error_message or None)
|
|
"""
|
|
try:
|
|
# Validate image_path is a string before proceeding
|
|
if not isinstance(task.image_path, str):
|
|
return (task.task_id, None, f"Invalid image_path type: {type(task.image_path).__name__}, expected str")
|
|
|
|
# Import PIL first with basic load
|
|
from PIL import Image
|
|
|
|
# Try to open the image - this is the most likely failure point
|
|
try:
|
|
img: Image.Image = Image.open(task.image_path)
|
|
except Exception as open_err:
|
|
import traceback
|
|
|
|
return (task.task_id, None, f"Image.open failed: {open_err}\n{traceback.format_exc()}")
|
|
|
|
# Now import the rest
|
|
try:
|
|
from pyPhotoAlbum.image_utils import (
|
|
apply_pil_rotation,
|
|
convert_to_rgba,
|
|
calculate_center_crop_coords,
|
|
crop_image_to_coords,
|
|
apply_rounded_corners,
|
|
)
|
|
except Exception as import_err:
|
|
import traceback
|
|
|
|
return (task.task_id, None, f"Import image_utils failed: {import_err}\n{traceback.format_exc()}")
|
|
|
|
# Convert to RGBA
|
|
img = convert_to_rgba(img)
|
|
|
|
# Apply PIL-level rotation if needed
|
|
if task.pil_rotation_90 > 0:
|
|
img = apply_pil_rotation(img, task.pil_rotation_90)
|
|
|
|
# Calculate final crop bounds (combining element crop with split crop)
|
|
crop_x_min, crop_y_min, crop_x_max, crop_y_max = task.crop_info
|
|
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * task.crop_left
|
|
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * task.crop_right
|
|
|
|
# Calculate center crop coordinates
|
|
img_width, img_height = img.size
|
|
crop_coords = calculate_center_crop_coords(
|
|
img_width,
|
|
img_height,
|
|
task.target_width,
|
|
task.target_height,
|
|
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
|
|
)
|
|
|
|
# Crop the image
|
|
cropped_img = crop_image_to_coords(img, crop_coords)
|
|
|
|
# Downsample if needed
|
|
current_width, current_height = cropped_img.size
|
|
if current_width > task.target_width_px or current_height > task.target_height_px:
|
|
cropped_img = cropped_img.resize(
|
|
(task.target_width_px, task.target_height_px),
|
|
Image.Resampling.LANCZOS,
|
|
)
|
|
|
|
# Apply rounded corners if needed
|
|
if task.corner_radius > 0:
|
|
cropped_img = apply_rounded_corners(cropped_img, task.corner_radius)
|
|
|
|
# Serialize image: JPEG for photos (much smaller), PNG only when alpha is needed
|
|
buffer = io.BytesIO()
|
|
has_transparency = cropped_img.mode == "RGBA" and task.corner_radius > 0
|
|
if has_transparency:
|
|
cropped_img.save(buffer, format="PNG", optimize=False)
|
|
else:
|
|
# Convert to RGB for JPEG (drop alpha channel if present but unused)
|
|
rgb_img = cropped_img.convert("RGB")
|
|
rgb_img.save(buffer, format="JPEG", quality=92, optimize=True)
|
|
return (task.task_id, buffer.getvalue(), None)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
return (task.task_id, None, f"{str(e)}\n{traceback.format_exc()}")
|
|
|
|
|
|
@dataclass
|
|
class RenderContext:
|
|
"""Parameters for rendering an image element"""
|
|
|
|
canvas: canvas.Canvas
|
|
image_element: ImageData
|
|
x_pt: float
|
|
y_pt: float
|
|
width_pt: float
|
|
height_pt: float
|
|
page_number: int
|
|
crop_left: float = 0.0
|
|
crop_right: float = 1.0
|
|
original_width_pt: Optional[float] = None
|
|
original_height_pt: Optional[float] = None
|
|
|
|
|
|
@dataclass
|
|
class SplitRenderParams:
|
|
"""Parameters for rendering a split element"""
|
|
|
|
canvas: canvas.Canvas
|
|
element: Any
|
|
x_offset_mm: float
|
|
split_line_mm: float
|
|
page_width_pt: float
|
|
page_height_pt: float
|
|
page_number: int
|
|
side: str
|
|
|
|
|
|
class PDFExporter:
|
|
"""Handles PDF export of photo album projects"""
|
|
|
|
# Conversion constants
|
|
MM_TO_POINTS = 2.834645669 # 1mm = 2.834645669 points
|
|
SPLIT_THRESHOLD_RATIO = 0.002 # 1:500 threshold for tiny elements
|
|
|
|
def __init__(self, project, export_dpi: int = 300, max_workers: Optional[int] = None):
|
|
"""
|
|
Initialize PDF exporter with a project.
|
|
|
|
Args:
|
|
project: The Project instance to export
|
|
export_dpi: Target DPI for images in the PDF (default 300 for print quality)
|
|
Use 300 for high-quality print, 150 for screen/draft
|
|
max_workers: Maximum number of worker processes for parallel image processing.
|
|
Defaults to number of CPU cores.
|
|
"""
|
|
self.project = project
|
|
self.export_dpi = export_dpi
|
|
self.warnings: List[str] = []
|
|
self.current_pdf_page = 1
|
|
self.max_workers = max_workers or multiprocessing.cpu_count()
|
|
self._processed_images: Dict[str, Image.Image] = {}
|
|
|
|
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Export the project to PDF.
|
|
|
|
Uses multiprocessing to pre-process all images in parallel, then renders
|
|
each page to its own PDF buffer in parallel (via threads), and finally
|
|
merges the per-page PDFs into the output file.
|
|
|
|
Args:
|
|
output_path: Path where PDF should be saved
|
|
progress_callback: Optional callback(current, total, message) for progress updates
|
|
|
|
Returns:
|
|
Tuple of (success: bool, warnings: List[str])
|
|
"""
|
|
self.warnings = []
|
|
self._processed_images = {}
|
|
|
|
try:
|
|
# Calculate total pages for progress (cover counts as 1)
|
|
total_pages = sum(
|
|
1 if page.is_cover else (2 if page.is_double_spread else 1) for page in self.project.pages
|
|
)
|
|
|
|
# Get page dimensions from project (in mm)
|
|
page_width_mm, page_height_mm = self.project.page_size_mm
|
|
bleed_mm = self.project.page_bleed_mm
|
|
bleed_pt = bleed_mm * self.MM_TO_POINTS
|
|
page_width_pt = page_width_mm * self.MM_TO_POINTS
|
|
page_height_pt = page_height_mm * self.MM_TO_POINTS
|
|
|
|
# Phase 1: parallel image pre-processing (unchanged)
|
|
if progress_callback:
|
|
progress_callback(0, total_pages, "Collecting images for processing...")
|
|
image_tasks = self._collect_image_tasks(page_width_pt, page_height_pt)
|
|
if image_tasks:
|
|
if progress_callback:
|
|
progress_callback(0, total_pages, f"Processing {len(image_tasks)} images in parallel...")
|
|
self._preprocess_images_parallel(image_tasks, progress_callback, total_pages)
|
|
|
|
# Phase 2: determine ordered page sequence (inserts blank pages for spread alignment)
|
|
page_sequence = self._compute_page_sequence()
|
|
|
|
# Phase 3: render each page to its own PDF bytes in parallel
|
|
n = len(page_sequence)
|
|
if progress_callback:
|
|
progress_callback(0, total_pages, f"Rendering {n} pages in parallel...")
|
|
pdf_bytes_list: List[Optional[bytes]] = [None] * n
|
|
|
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
future_to_idx = {
|
|
executor.submit(self._render_item_to_bytes, item, page_width_pt, page_height_pt, bleed_pt): i
|
|
for i, item in enumerate(page_sequence)
|
|
}
|
|
completed = 0
|
|
for future in as_completed(future_to_idx):
|
|
i = future_to_idx[future]
|
|
try:
|
|
pdf_bytes_list[i] = future.result()
|
|
except Exception as e:
|
|
self.warnings.append(f"Error rendering page: {str(e)}")
|
|
pdf_bytes_list[i] = self._make_blank_page_bytes(page_width_pt, page_height_pt, bleed_pt)
|
|
completed += 1
|
|
if progress_callback:
|
|
progress_callback(completed, n, f"Rendering pages: {completed}/{n}...")
|
|
|
|
# Phase 4: merge all per-page PDFs into the output file
|
|
if progress_callback:
|
|
progress_callback(n, total_pages, "Merging pages...")
|
|
self._merge_page_pdfs(pdf_bytes_list, output_path)
|
|
|
|
self._processed_images = {}
|
|
if progress_callback:
|
|
progress_callback(total_pages, total_pages, "Export complete!")
|
|
return True, self.warnings
|
|
|
|
except Exception as e:
|
|
self.warnings.append(f"Export failed: {str(e)}")
|
|
return False, self.warnings
|
|
|
|
def _compute_page_sequence(self) -> List[Tuple[str, Any]]:
|
|
"""
|
|
Build an ordered list of (page_type, page) items to render.
|
|
|
|
Inserts ('blank', None) entries before double-page spreads that would
|
|
otherwise start on an odd-numbered PDF page (spreads must start on even pages).
|
|
"""
|
|
sequence: List[Tuple[str, Any]] = []
|
|
pdf_page_num = 1
|
|
for page in self.project.pages:
|
|
if page.is_cover:
|
|
sequence.append(("cover", page))
|
|
pdf_page_num += 1
|
|
elif page.is_double_spread:
|
|
if pdf_page_num % 2 == 1:
|
|
sequence.append(("blank", None))
|
|
pdf_page_num += 1
|
|
sequence.append(("spread", page))
|
|
pdf_page_num += 2
|
|
else:
|
|
sequence.append(("single", page))
|
|
pdf_page_num += 1
|
|
return sequence
|
|
|
|
def _render_item_to_bytes(
|
|
self,
|
|
item: Tuple[str, Any],
|
|
page_width_pt: float,
|
|
page_height_pt: float,
|
|
bleed_pt: float,
|
|
) -> bytes:
|
|
"""
|
|
Render a single page item to a self-contained PDF (as bytes).
|
|
|
|
Each call creates its own Canvas / BytesIO so pages can be rendered
|
|
concurrently without sharing state.
|
|
"""
|
|
page_type, page = item
|
|
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
|
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
|
buf = io.BytesIO()
|
|
|
|
if page_type == "blank":
|
|
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
|
|
c.showPage()
|
|
c.save()
|
|
|
|
elif page_type == "cover":
|
|
cover_width_mm, cover_height_mm = page.layout.size
|
|
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
|
|
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
|
|
c = canvas.Canvas(buf, pagesize=(cover_width_pt, cover_height_pt))
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
|
|
c.showPage()
|
|
c.save()
|
|
|
|
elif page_type == "single":
|
|
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
|
|
c.showPage()
|
|
c.save()
|
|
|
|
elif page_type == "spread":
|
|
c = canvas.Canvas(buf, pagesize=(expanded_width_pt, expanded_height_pt))
|
|
self._export_spread(c, page, page_width_pt, page_height_pt, bleed_pt)
|
|
c.save()
|
|
|
|
return buf.getvalue()
|
|
|
|
def _make_blank_page_bytes(self, page_width_pt: float, page_height_pt: float, bleed_pt: float) -> bytes:
|
|
"""Return a minimal single-blank-page PDF for use as an error placeholder."""
|
|
buf = io.BytesIO()
|
|
c = canvas.Canvas(buf, pagesize=(page_width_pt + 2 * bleed_pt, page_height_pt + 2 * bleed_pt))
|
|
c.showPage()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
def _merge_page_pdfs(self, pdf_bytes_list: List[Optional[bytes]], output_path: str):
|
|
"""Merge a list of single-page PDF byte strings into one output file."""
|
|
from pypdf import PdfWriter, PdfReader
|
|
|
|
writer = PdfWriter()
|
|
for pdf_bytes in pdf_bytes_list:
|
|
if pdf_bytes is None:
|
|
continue
|
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
|
for page in reader.pages:
|
|
writer.add_page(page)
|
|
with open(output_path, "wb") as f:
|
|
writer.write(f)
|
|
|
|
def _make_task_id(
|
|
self,
|
|
element: ImageData,
|
|
crop_left: float = 0.0,
|
|
crop_right: float = 1.0,
|
|
width_pt: float = 0.0,
|
|
height_pt: float = 0.0,
|
|
) -> str:
|
|
"""Generate a unique task ID for an image element with specific render params."""
|
|
return f"{id(element)}_{crop_left:.4f}_{crop_right:.4f}_{width_pt:.2f}_{height_pt:.2f}"
|
|
|
|
def _collect_image_tasks(self, page_width_pt: float, page_height_pt: float) -> List[ImageTask]:
|
|
"""
|
|
Collect all image processing tasks from the project.
|
|
|
|
Scans all pages and elements to build a list of ImageTask objects
|
|
that can be processed in parallel.
|
|
"""
|
|
tasks: List[ImageTask] = []
|
|
dpi = self.project.working_dpi
|
|
|
|
for page in self.project.pages:
|
|
if page.is_cover:
|
|
cover_width_mm, cover_height_mm = page.layout.size
|
|
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
|
|
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
|
|
self._collect_page_tasks(tasks, page, 0, cover_width_pt, cover_height_pt)
|
|
elif page.is_double_spread:
|
|
# Collect tasks for both left and right pages of the spread
|
|
page_width_mm = self.project.page_size_mm[0]
|
|
center_mm = page_width_mm
|
|
self._collect_spread_tasks(tasks, page, page_width_pt, page_height_pt, center_mm)
|
|
else:
|
|
self._collect_page_tasks(tasks, page, 0, page_width_pt, page_height_pt)
|
|
|
|
return tasks
|
|
|
|
def _collect_page_tasks(
|
|
self,
|
|
tasks: List[ImageTask],
|
|
page,
|
|
x_offset_mm: float,
|
|
page_width_pt: float,
|
|
page_height_pt: float,
|
|
):
|
|
"""Collect image tasks from a single page."""
|
|
dpi = self.project.working_dpi
|
|
|
|
for element in page.layout.elements:
|
|
if not isinstance(element, ImageData):
|
|
continue
|
|
|
|
image_path = element.resolve_image_path()
|
|
if not image_path:
|
|
continue
|
|
|
|
# Calculate dimensions
|
|
element_x_px, element_y_px = element.position
|
|
element_width_px, element_height_px = element.size
|
|
|
|
element_width_mm = element_width_px * 25.4 / dpi
|
|
element_height_mm = element_height_px * 25.4 / dpi
|
|
|
|
width_pt = element_width_mm * self.MM_TO_POINTS
|
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
|
|
|
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
|
|
task_id = self._make_task_id(element, 0.0, 1.0, width_pt, height_pt)
|
|
|
|
task = ImageTask(
|
|
task_id=task_id,
|
|
image_path=image_path,
|
|
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
|
|
crop_info=element.crop_info,
|
|
crop_left=0.0,
|
|
crop_right=1.0,
|
|
target_width=width_pt,
|
|
target_height=height_pt,
|
|
target_width_px=max(1, target_width_px),
|
|
target_height_px=max(1, target_height_px),
|
|
corner_radius=element.style.corner_radius,
|
|
)
|
|
tasks.append(task)
|
|
|
|
def _collect_spread_tasks(
|
|
self,
|
|
tasks: List[ImageTask],
|
|
page,
|
|
page_width_pt: float,
|
|
page_height_pt: float,
|
|
center_mm: float,
|
|
):
|
|
"""Collect image tasks from a double-page spread, handling split elements."""
|
|
dpi = self.project.working_dpi
|
|
center_px = center_mm * dpi / 25.4
|
|
threshold_px = self.project.page_size_mm[0] * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
|
|
|
|
for element in page.layout.elements:
|
|
if not isinstance(element, ImageData):
|
|
continue
|
|
|
|
image_path = element.resolve_image_path()
|
|
if not image_path:
|
|
continue
|
|
|
|
element_x_px, element_y_px = element.position
|
|
element_width_px, element_height_px = element.size
|
|
|
|
element_x_mm = element_x_px * 25.4 / dpi
|
|
element_width_mm = element_width_px * 25.4 / dpi
|
|
element_height_mm = element_height_px * 25.4 / dpi
|
|
|
|
width_pt = element_width_mm * self.MM_TO_POINTS
|
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
|
|
|
# Check if element spans the center
|
|
if element_x_px + element_width_px <= center_px + threshold_px:
|
|
# Entirely on left page
|
|
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
|
|
elif element_x_px >= center_px - threshold_px:
|
|
# Entirely on right page
|
|
self._add_image_task(tasks, element, image_path, 0.0, 1.0, width_pt, height_pt)
|
|
else:
|
|
# Spanning element - create tasks for left and right portions
|
|
# Left portion
|
|
crop_width_mm_left = center_mm - element_x_mm
|
|
crop_right_left = crop_width_mm_left / element_width_mm
|
|
left_width_pt = crop_width_mm_left * self.MM_TO_POINTS
|
|
self._add_image_task(tasks, element, image_path, 0.0, crop_right_left, left_width_pt, height_pt)
|
|
|
|
# Right portion
|
|
crop_x_start_right = center_mm - element_x_mm
|
|
crop_left_right = crop_x_start_right / element_width_mm
|
|
right_width_pt = (element_width_mm - crop_x_start_right) * self.MM_TO_POINTS
|
|
self._add_image_task(tasks, element, image_path, crop_left_right, 1.0, right_width_pt, height_pt)
|
|
|
|
def _add_image_task(
|
|
self,
|
|
tasks: List[ImageTask],
|
|
element: ImageData,
|
|
image_path: str,
|
|
crop_left: float,
|
|
crop_right: float,
|
|
width_pt: float,
|
|
height_pt: float,
|
|
):
|
|
"""Helper to add an image task to the list."""
|
|
# Use original dimensions for aspect ratio calculation
|
|
original_width_pt = width_pt / (crop_right - crop_left) if crop_right != crop_left else width_pt
|
|
original_height_pt = height_pt
|
|
|
|
target_width_px = int((width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
target_height_px = int((height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
|
|
task_id = self._make_task_id(element, crop_left, crop_right, width_pt, height_pt)
|
|
|
|
task = ImageTask(
|
|
task_id=task_id,
|
|
image_path=image_path,
|
|
pil_rotation_90=getattr(element, "pil_rotation_90", 0),
|
|
crop_info=element.crop_info,
|
|
crop_left=crop_left,
|
|
crop_right=crop_right,
|
|
target_width=original_width_pt,
|
|
target_height=original_height_pt,
|
|
target_width_px=max(1, target_width_px),
|
|
target_height_px=max(1, target_height_px),
|
|
corner_radius=element.style.corner_radius,
|
|
)
|
|
tasks.append(task)
|
|
|
|
def _preprocess_images_parallel(
|
|
self,
|
|
tasks: List[ImageTask],
|
|
progress_callback,
|
|
total_pages: int,
|
|
):
|
|
"""
|
|
Process all image tasks in parallel using a process pool.
|
|
|
|
Results are stored in self._processed_images for use during PDF assembly.
|
|
"""
|
|
completed = 0
|
|
total_tasks = len(tasks)
|
|
|
|
with ProcessPoolExecutor(max_workers=self.max_workers) as executor:
|
|
future_to_task = {executor.submit(_process_image_task, task): task for task in tasks}
|
|
|
|
for future in as_completed(future_to_task):
|
|
task = future_to_task[future]
|
|
completed += 1
|
|
|
|
try:
|
|
task_id, image_bytes, error = future.result()
|
|
|
|
if error:
|
|
warning = f"Error processing image {task.image_path}: {error}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
elif image_bytes:
|
|
# Deserialize the image from bytes
|
|
buffer = io.BytesIO(image_bytes)
|
|
img = Image.open(buffer)
|
|
# Force load the image data into memory
|
|
img.load()
|
|
# Store both image and buffer reference to prevent garbage collection
|
|
# Some PIL operations may still reference the source buffer
|
|
img._ppa_buffer = buffer # type: ignore[attr-defined] # Keep buffer alive with image
|
|
self._processed_images[task_id] = img
|
|
|
|
except Exception as e:
|
|
warning = f"Error processing image {task.image_path}: {str(e)}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
|
|
if progress_callback and completed % 5 == 0:
|
|
progress_callback(
|
|
0,
|
|
total_pages,
|
|
f"Processing images: {completed}/{total_tasks}...",
|
|
)
|
|
|
|
def _export_cover(self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float):
|
|
"""
|
|
Export a cover page to PDF.
|
|
Cover has different dimensions (wrap-around: front + spine + back + bleed).
|
|
"""
|
|
# Get cover dimensions (already calculated in page.layout.size)
|
|
cover_width_mm, cover_height_mm = page.layout.size
|
|
|
|
# Convert to PDF points
|
|
cover_width_pt = cover_width_mm * self.MM_TO_POINTS
|
|
cover_height_pt = cover_height_mm * self.MM_TO_POINTS
|
|
|
|
# Create a new page with cover dimensions
|
|
c.setPageSize((cover_width_pt, cover_height_pt))
|
|
|
|
# Render all elements on the cover
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
self._render_element(c, element, 0, cover_width_pt, cover_height_pt, "Cover")
|
|
|
|
c.showPage() # Finish cover page
|
|
|
|
def _export_single_page(
|
|
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
|
|
):
|
|
"""Export a single page to PDF"""
|
|
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
|
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
|
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
|
|
|
# Render all elements, offset by bleed
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
|
|
|
|
c.showPage() # Finish this page
|
|
|
|
def _export_spread(
|
|
self, c: canvas.Canvas, page, page_width_pt: float, page_height_pt: float, bleed_pt: float = 0.0
|
|
):
|
|
"""Export a double-page spread as two PDF pages"""
|
|
# Get center line position in mm
|
|
page_width_mm = self.project.page_size_mm[0]
|
|
center_mm = page_width_mm # Center of the spread (which is 2x width)
|
|
|
|
# Convert center line to pixels for comparison
|
|
dpi = self.project.working_dpi
|
|
center_px = center_mm * dpi / 25.4
|
|
|
|
# Calculate threshold for tiny elements (1:500) in pixels
|
|
threshold_px = page_width_mm * self.SPLIT_THRESHOLD_RATIO * dpi / 25.4
|
|
|
|
expanded_width_pt = page_width_pt + 2 * bleed_pt
|
|
expanded_height_pt = page_height_pt + 2 * bleed_pt
|
|
|
|
# Process elements for left page
|
|
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
element_x_px, element_y_px = element.position
|
|
element_width_px, element_height_px = element.size
|
|
|
|
# Check if element is on left page, right page, or spanning (compare in pixels)
|
|
if element_x_px + element_width_px <= center_px + threshold_px:
|
|
# Entirely on left page
|
|
self._render_element(c, element, 0, page_width_pt, page_height_pt, page.page_number, bleed_pt)
|
|
elif element_x_px >= center_px - threshold_px:
|
|
# Skip for now, will render on right page
|
|
pass
|
|
else:
|
|
# Spanning element - render left portion
|
|
params = SplitRenderParams(
|
|
canvas=c,
|
|
element=element,
|
|
x_offset_mm=0,
|
|
split_line_mm=center_mm,
|
|
page_width_pt=page_width_pt,
|
|
page_height_pt=page_height_pt,
|
|
page_number=page.page_number,
|
|
side="left",
|
|
)
|
|
self._render_split_element(params, bleed_pt)
|
|
|
|
c.showPage() # Finish left page
|
|
|
|
# Process elements for right page
|
|
c.setPageSize((expanded_width_pt, expanded_height_pt))
|
|
for element in sorted(page.layout.elements, key=lambda x: x.z_index):
|
|
element_x_px, element_y_px = element.position
|
|
element_width_px, element_height_px = element.size
|
|
|
|
# Check if element is on right page or spanning (compare in pixels)
|
|
if element_x_px >= center_px - threshold_px and element_x_px + element_width_px > center_px:
|
|
# Entirely on right page or mostly on right
|
|
self._render_element(
|
|
c, element, center_mm, page_width_pt, page_height_pt, page.page_number + 1, bleed_pt
|
|
)
|
|
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
|
|
# Spanning element - render right portion
|
|
params = SplitRenderParams(
|
|
canvas=c,
|
|
element=element,
|
|
x_offset_mm=center_mm,
|
|
split_line_mm=center_mm,
|
|
page_width_pt=page_width_pt,
|
|
page_height_pt=page_height_pt,
|
|
page_number=page.page_number + 1,
|
|
side="right",
|
|
)
|
|
self._render_split_element(params, bleed_pt)
|
|
|
|
c.showPage() # Finish right page
|
|
|
|
def _render_element(
|
|
self,
|
|
c: canvas.Canvas,
|
|
element,
|
|
x_offset_mm: float,
|
|
page_width_pt: float,
|
|
page_height_pt: float,
|
|
page_number: Union[int, str],
|
|
bleed_pt: float = 0.0,
|
|
):
|
|
"""
|
|
Render a single element on the PDF canvas.
|
|
|
|
Args:
|
|
c: ReportLab canvas
|
|
element: The layout element to render
|
|
x_offset_mm: X offset in mm (for right page of spread)
|
|
page_width_pt: Page width in points (cut/trim size, excluding bleed)
|
|
page_height_pt: Page height in points (cut/trim size, excluding bleed)
|
|
page_number: Current page number (for error messages)
|
|
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
|
|
"""
|
|
# Skip placeholders
|
|
if isinstance(element, PlaceholderData):
|
|
return
|
|
|
|
# Get element position and size (in PIXELS from OpenGL coordinates)
|
|
element_x_px, element_y_px = element.position
|
|
element_width_px, element_height_px = element.size
|
|
|
|
# Convert from pixels to mm using the working DPI
|
|
dpi = self.project.working_dpi
|
|
element_x_mm = element_x_px * 25.4 / dpi
|
|
element_y_mm = element_y_px * 25.4 / dpi
|
|
element_width_mm = element_width_px * 25.4 / dpi
|
|
element_height_mm = element_height_px * 25.4 / dpi
|
|
|
|
# Adjust x position for offset (now in mm)
|
|
adjusted_x_mm = element_x_mm - x_offset_mm
|
|
|
|
# Convert to PDF points and flip Y coordinate (PDF origin is bottom-left).
|
|
# bleed_pt shifts content so the cut/trim line is bleed_pt from the PDF page edge.
|
|
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
|
|
y_pt = page_height_pt + bleed_pt - (element_y_mm * self.MM_TO_POINTS) - (element_height_mm * self.MM_TO_POINTS)
|
|
width_pt = element_width_mm * self.MM_TO_POINTS
|
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
|
|
|
if isinstance(element, ImageData):
|
|
ctx = RenderContext(
|
|
canvas=c,
|
|
image_element=element,
|
|
x_pt=x_pt,
|
|
y_pt=y_pt,
|
|
width_pt=width_pt,
|
|
height_pt=height_pt,
|
|
page_number=int(page_number),
|
|
)
|
|
self._render_image(ctx)
|
|
elif isinstance(element, TextBoxData):
|
|
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
|
|
|
|
def _render_split_element(self, params: SplitRenderParams, bleed_pt: float = 0.0):
|
|
"""
|
|
Render a split element (only the portion on one side of the split line).
|
|
|
|
Args:
|
|
params: SplitRenderParams containing all rendering parameters
|
|
bleed_pt: Bleed margin in points; offsets content so cut line sits inside the PDF page
|
|
"""
|
|
# Skip placeholders
|
|
if isinstance(params.element, PlaceholderData):
|
|
return
|
|
|
|
# Get element position and size in pixels
|
|
element_x_px, element_y_px = params.element.position
|
|
element_width_px, element_height_px = params.element.size
|
|
|
|
# Convert to mm
|
|
dpi = self.project.working_dpi
|
|
element_x_mm = element_x_px * 25.4 / dpi
|
|
element_y_mm = element_y_px * 25.4 / dpi
|
|
element_width_mm = element_width_px * 25.4 / dpi
|
|
element_height_mm = element_height_px * 25.4 / dpi
|
|
|
|
if isinstance(params.element, ImageData):
|
|
# Calculate which portion of the image to render
|
|
if params.side == "left":
|
|
# Render from element start to split line
|
|
crop_width_mm = params.split_line_mm - element_x_mm
|
|
crop_x_start = 0
|
|
render_x_mm = element_x_mm
|
|
else: # right
|
|
# Render from split line to element end
|
|
crop_width_mm = (element_x_mm + element_width_mm) - params.split_line_mm
|
|
crop_x_start = params.split_line_mm - element_x_mm
|
|
render_x_mm = params.split_line_mm # Start at split line in spread coordinates
|
|
|
|
# Adjust render position for offset
|
|
adjusted_x_mm = render_x_mm - params.x_offset_mm
|
|
|
|
# Convert to points (bleed_pt shifts content inside the expanded PDF page)
|
|
x_pt = adjusted_x_mm * self.MM_TO_POINTS + bleed_pt
|
|
y_pt = (
|
|
params.page_height_pt
|
|
+ bleed_pt
|
|
- (element_y_mm * self.MM_TO_POINTS)
|
|
- (element_height_mm * self.MM_TO_POINTS)
|
|
)
|
|
width_pt = crop_width_mm * self.MM_TO_POINTS
|
|
height_pt = element_height_mm * self.MM_TO_POINTS
|
|
|
|
# Calculate original element dimensions in points (before splitting)
|
|
original_width_pt = element_width_mm * self.MM_TO_POINTS
|
|
original_height_pt = element_height_mm * self.MM_TO_POINTS
|
|
|
|
# Render cropped image with original dimensions for correct aspect ratio
|
|
ctx = RenderContext(
|
|
canvas=params.canvas,
|
|
image_element=params.element,
|
|
x_pt=x_pt,
|
|
y_pt=y_pt,
|
|
width_pt=width_pt,
|
|
height_pt=height_pt,
|
|
page_number=params.page_number,
|
|
crop_left=crop_x_start / element_width_mm,
|
|
crop_right=(crop_x_start + crop_width_mm) / element_width_mm,
|
|
original_width_pt=original_width_pt,
|
|
original_height_pt=original_height_pt,
|
|
)
|
|
self._render_image(ctx)
|
|
|
|
elif isinstance(params.element, TextBoxData):
|
|
# For text boxes spanning the split, we'll render the whole text on the side
|
|
# where most of it appears (simpler than trying to split text)
|
|
element_center_mm = element_x_mm + element_width_mm / 2
|
|
if (params.side == "left" and element_center_mm < params.split_line_mm) or (
|
|
params.side == "right" and element_center_mm >= params.split_line_mm
|
|
):
|
|
self._render_element(
|
|
params.canvas,
|
|
params.element,
|
|
params.x_offset_mm,
|
|
params.page_width_pt,
|
|
params.page_height_pt,
|
|
params.page_number,
|
|
bleed_pt,
|
|
)
|
|
|
|
def _render_image(self, ctx: RenderContext):
|
|
"""
|
|
Render an image element on the PDF canvas.
|
|
|
|
Uses pre-processed images from the cache when available (parallel processing),
|
|
otherwise falls back to processing the image on-demand.
|
|
|
|
Args:
|
|
ctx: RenderContext containing all rendering parameters
|
|
"""
|
|
# Check for pre-processed image in cache
|
|
task_id = self._make_task_id(ctx.image_element, ctx.crop_left, ctx.crop_right, ctx.width_pt, ctx.height_pt)
|
|
cropped_img = self._processed_images.get(task_id)
|
|
|
|
if cropped_img is None:
|
|
# Fallback: process image on-demand (for backwards compatibility or cache miss)
|
|
cropped_img = self._process_image_fallback(ctx)
|
|
if cropped_img is None:
|
|
return
|
|
|
|
try:
|
|
style = ctx.image_element.style
|
|
|
|
# Save state for transformations
|
|
ctx.canvas.saveState()
|
|
|
|
# Draw drop shadow first (behind image)
|
|
if style.shadow_enabled:
|
|
self._draw_shadow_pdf(ctx)
|
|
|
|
# Apply rotation to canvas if needed
|
|
if ctx.image_element.rotation != 0:
|
|
# Move to element center
|
|
center_x = ctx.x_pt + ctx.width_pt / 2
|
|
center_y = ctx.y_pt + ctx.height_pt / 2
|
|
ctx.canvas.translate(center_x, center_y)
|
|
ctx.canvas.rotate(-ctx.image_element.rotation)
|
|
ctx.canvas.translate(-ctx.width_pt / 2, -ctx.height_pt / 2)
|
|
# Draw at origin after transformation
|
|
ctx.canvas.drawImage(
|
|
ImageReader(cropped_img), 0, 0, ctx.width_pt, ctx.height_pt, mask="auto", preserveAspectRatio=False
|
|
)
|
|
else:
|
|
# Draw without rotation
|
|
ctx.canvas.drawImage(
|
|
ImageReader(cropped_img),
|
|
ctx.x_pt,
|
|
ctx.y_pt,
|
|
ctx.width_pt,
|
|
ctx.height_pt,
|
|
mask="auto",
|
|
preserveAspectRatio=False,
|
|
)
|
|
|
|
# Draw border on top of image
|
|
if style.border_width > 0:
|
|
self._draw_border_pdf(ctx)
|
|
|
|
# Draw decorative frame if specified
|
|
if style.frame_style:
|
|
self._draw_frame_pdf(ctx)
|
|
|
|
ctx.canvas.restoreState()
|
|
|
|
except Exception as e:
|
|
warning = f"Page {ctx.page_number}: Error rendering image {ctx.image_element.image_path}: {str(e)}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
|
|
def _process_image_fallback(self, ctx: RenderContext) -> Optional[Image.Image]:
|
|
"""
|
|
Process an image on-demand when not found in the pre-processed cache.
|
|
|
|
This is a fallback for backwards compatibility or cache misses.
|
|
|
|
Returns:
|
|
Processed PIL Image or None if processing failed.
|
|
"""
|
|
# Resolve image path using ImageData's method
|
|
image_full_path = ctx.image_element.resolve_image_path()
|
|
|
|
# Check if image exists
|
|
if not image_full_path:
|
|
warning = f"Page {ctx.page_number}: Image not found: {ctx.image_element.image_path}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
return None
|
|
|
|
try:
|
|
# Load image using resolved path
|
|
img: Image.Image = Image.open(image_full_path)
|
|
img = convert_to_rgba(img)
|
|
|
|
# Apply PIL-level rotation if needed
|
|
if hasattr(ctx.image_element, "pil_rotation_90") and ctx.image_element.pil_rotation_90 > 0:
|
|
img = apply_pil_rotation(img, ctx.image_element.pil_rotation_90)
|
|
|
|
# Get element's crop_info and combine with split cropping if applicable
|
|
crop_x_min, crop_y_min, crop_x_max, crop_y_max = ctx.image_element.crop_info
|
|
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_left
|
|
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * ctx.crop_right
|
|
|
|
# Determine target dimensions for aspect ratio
|
|
if ctx.original_width_pt is not None and ctx.original_height_pt is not None:
|
|
target_width = ctx.original_width_pt
|
|
target_height = ctx.original_height_pt
|
|
else:
|
|
target_width = ctx.width_pt
|
|
target_height = ctx.height_pt
|
|
|
|
# Calculate center crop coordinates
|
|
img_width, img_height = img.size
|
|
crop_coords = calculate_center_crop_coords(
|
|
img_width,
|
|
img_height,
|
|
target_width,
|
|
target_height,
|
|
(final_crop_x_min, crop_y_min, final_crop_x_max, crop_y_max),
|
|
)
|
|
|
|
# Crop the image
|
|
cropped_img = crop_image_to_coords(img, crop_coords)
|
|
|
|
# Downsample image to target resolution based on export DPI
|
|
target_width_px = int((ctx.width_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
target_height_px = int((ctx.height_pt / self.MM_TO_POINTS) * self.export_dpi / 25.4)
|
|
|
|
current_width, current_height = cropped_img.size
|
|
if current_width > target_width_px or current_height > target_height_px:
|
|
cropped_img = cropped_img.resize((target_width_px, target_height_px), Image.Resampling.LANCZOS)
|
|
|
|
# Apply styling to image (rounded corners)
|
|
style = ctx.image_element.style
|
|
if style.corner_radius > 0:
|
|
from pyPhotoAlbum.image_utils import apply_rounded_corners
|
|
|
|
cropped_img = apply_rounded_corners(cropped_img, style.corner_radius)
|
|
|
|
return cropped_img
|
|
|
|
except Exception as e:
|
|
warning = f"Page {ctx.page_number}: Error processing image {ctx.image_element.image_path}: {str(e)}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
return None
|
|
|
|
def _draw_shadow_pdf(self, ctx: RenderContext):
|
|
"""Draw drop shadow in PDF output."""
|
|
style = ctx.image_element.style
|
|
|
|
# Convert shadow offset from mm to points
|
|
offset_x_pt = style.shadow_offset[0] * self.MM_TO_POINTS
|
|
offset_y_pt = -style.shadow_offset[1] * self.MM_TO_POINTS # Y is inverted in PDF
|
|
|
|
# Shadow color (normalize to 0-1)
|
|
r, g, b, a = style.shadow_color
|
|
shadow_alpha = a / 255.0
|
|
|
|
# Draw shadow rectangle
|
|
ctx.canvas.saveState()
|
|
ctx.canvas.setFillColorRGB(r / 255.0, g / 255.0, b / 255.0, shadow_alpha)
|
|
|
|
# Calculate corner radius in points
|
|
if style.corner_radius > 0:
|
|
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
|
|
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
|
|
ctx.canvas.roundRect(
|
|
ctx.x_pt + offset_x_pt,
|
|
ctx.y_pt + offset_y_pt,
|
|
ctx.width_pt,
|
|
ctx.height_pt,
|
|
radius_pt,
|
|
stroke=0,
|
|
fill=1,
|
|
)
|
|
else:
|
|
ctx.canvas.rect(
|
|
ctx.x_pt + offset_x_pt,
|
|
ctx.y_pt + offset_y_pt,
|
|
ctx.width_pt,
|
|
ctx.height_pt,
|
|
stroke=0,
|
|
fill=1,
|
|
)
|
|
ctx.canvas.restoreState()
|
|
|
|
def _draw_border_pdf(self, ctx: RenderContext):
|
|
"""Draw styled border in PDF output."""
|
|
style = ctx.image_element.style
|
|
|
|
# Border width in points
|
|
border_width_pt = style.border_width * self.MM_TO_POINTS
|
|
|
|
# Border color (normalize to 0-1)
|
|
r, g, b = style.border_color
|
|
|
|
ctx.canvas.saveState()
|
|
ctx.canvas.setStrokeColorRGB(r / 255.0, g / 255.0, b / 255.0)
|
|
ctx.canvas.setLineWidth(border_width_pt)
|
|
|
|
# Calculate corner radius in points
|
|
if style.corner_radius > 0:
|
|
shorter_side_pt = min(ctx.width_pt, ctx.height_pt)
|
|
radius_pt = shorter_side_pt * min(50, style.corner_radius) / 100
|
|
ctx.canvas.roundRect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, radius_pt, stroke=1, fill=0)
|
|
else:
|
|
ctx.canvas.rect(ctx.x_pt, ctx.y_pt, ctx.width_pt, ctx.height_pt, stroke=1, fill=0)
|
|
|
|
ctx.canvas.restoreState()
|
|
|
|
def _draw_frame_pdf(self, ctx: RenderContext):
|
|
"""Draw decorative frame in PDF output."""
|
|
from pyPhotoAlbum.frame_manager import get_frame_manager
|
|
|
|
style = ctx.image_element.style
|
|
frame_manager = get_frame_manager()
|
|
|
|
frame_manager.render_frame_pdf(
|
|
canvas=ctx.canvas,
|
|
frame_name=style.frame_style, # type: ignore[arg-type]
|
|
x_pt=ctx.x_pt,
|
|
y_pt=ctx.y_pt,
|
|
width_pt=ctx.width_pt,
|
|
height_pt=ctx.height_pt,
|
|
color=style.frame_color,
|
|
corners=style.frame_corners,
|
|
)
|
|
|
|
def _render_textbox(
|
|
self, c: canvas.Canvas, text_element: "TextBoxData", x_pt: float, y_pt: float, width_pt: float, height_pt: float
|
|
):
|
|
"""
|
|
Render a text box element on the PDF canvas with transparent background.
|
|
Text is word-wrapped to fit within the box boundaries.
|
|
|
|
Args:
|
|
c: ReportLab canvas
|
|
text_element: TextBoxData instance
|
|
x_pt, y_pt, width_pt, height_pt: Position and size in points
|
|
"""
|
|
if not text_element.text_content:
|
|
return
|
|
|
|
# Get font settings
|
|
font_family = text_element.font_settings.get("family", "Helvetica")
|
|
font_size_px = text_element.font_settings.get("size", 12)
|
|
font_color = text_element.font_settings.get("color", (0, 0, 0))
|
|
|
|
# Convert font size from pixels to PDF points (same conversion as element dimensions)
|
|
# Font size is stored in pixels at working_dpi, same as element position/size
|
|
dpi = self.project.working_dpi
|
|
font_size_mm = font_size_px * 25.4 / dpi
|
|
font_size = font_size_mm * self.MM_TO_POINTS
|
|
|
|
# Map common font names to ReportLab standard fonts
|
|
font_map = {
|
|
"Arial": "Helvetica",
|
|
"Times New Roman": "Times-Roman",
|
|
"Courier New": "Courier",
|
|
}
|
|
font_family = font_map.get(font_family, font_family)
|
|
|
|
# Normalize color to hex for Paragraph style
|
|
if all(isinstance(x, int) and x > 1 for x in font_color):
|
|
color_hex = "#{:02x}{:02x}{:02x}".format(*font_color)
|
|
else:
|
|
# Convert 0-1 range to 0-255 then to hex
|
|
color_hex = "#{:02x}{:02x}{:02x}".format(
|
|
int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255)
|
|
)
|
|
|
|
# Map alignment to ReportLab constants
|
|
alignment_map = {
|
|
"left": TA_LEFT,
|
|
"center": TA_CENTER,
|
|
"right": TA_RIGHT,
|
|
"justify": TA_JUSTIFY,
|
|
}
|
|
text_alignment = alignment_map.get(text_element.alignment, TA_LEFT)
|
|
|
|
# Create paragraph style with word wrapping
|
|
style = ParagraphStyle(
|
|
"textbox",
|
|
fontName=font_family,
|
|
fontSize=font_size,
|
|
leading=font_size * 1.2, # Line spacing (120% of font size)
|
|
textColor=color_hex,
|
|
alignment=text_alignment,
|
|
)
|
|
|
|
# Escape special XML characters and convert newlines to <br/> tags
|
|
text_content = text_element.text_content
|
|
text_content = text_content.replace("&", "&")
|
|
text_content = text_content.replace("<", "<")
|
|
text_content = text_content.replace(">", ">")
|
|
text_content = text_content.replace("\n", "<br/>")
|
|
|
|
# Create paragraph with the text
|
|
para = Paragraph(text_content, style)
|
|
|
|
# Save state for transformations
|
|
c.saveState()
|
|
|
|
try:
|
|
# Apply rotation if needed
|
|
if text_element.rotation != 0:
|
|
# Move to element center
|
|
center_x = x_pt + width_pt / 2
|
|
center_y = y_pt + height_pt / 2
|
|
c.translate(center_x, center_y)
|
|
c.rotate(text_element.rotation)
|
|
|
|
# Wrap and draw paragraph relative to center
|
|
# wrapOn calculates the actual height needed
|
|
para_width, para_height = para.wrapOn(c, width_pt, height_pt)
|
|
|
|
# Position at top-left of box (relative to center after rotation)
|
|
draw_x = -width_pt / 2
|
|
draw_y = height_pt / 2 - para_height
|
|
|
|
para.drawOn(c, draw_x, draw_y)
|
|
else:
|
|
# No rotation - draw normally
|
|
# wrapOn calculates the actual height needed for the wrapped text
|
|
para_width, para_height = para.wrapOn(c, width_pt, height_pt)
|
|
|
|
# drawOn draws from bottom-left of the paragraph
|
|
# We want text at top of box, so: draw_y = box_top - para_height
|
|
draw_x = x_pt
|
|
draw_y = y_pt + height_pt - para_height
|
|
|
|
para.drawOn(c, draw_x, draw_y)
|
|
|
|
except Exception as e:
|
|
warning = f"Error rendering text box: {str(e)}"
|
|
print(f"WARNING: {warning}")
|
|
self.warnings.append(warning)
|
|
|
|
finally:
|
|
c.restoreState()
|