pyPhotoAlbum/pyPhotoAlbum/pdf_exporter.py
Duncan Tourolle 46585228fd
Some checks failed
Lint / lint (push) Failing after 2m46s
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
first commit
2025-10-21 22:02:49 +02:00

491 lines
22 KiB
Python

"""
PDF export functionality for pyPhotoAlbum
"""
import os
from typing import List, Tuple, Optional
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from PIL import Image
import math
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):
"""
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
"""
self.project = project
self.export_dpi = export_dpi
self.warnings = []
self.current_pdf_page = 1
def export(self, output_path: str, progress_callback=None) -> Tuple[bool, List[str]]:
"""
Export the project to PDF.
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.current_pdf_page = 1
try:
# Calculate total pages for progress
total_pages = sum(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
# Convert to PDF points
page_width_pt = page_width_mm * self.MM_TO_POINTS
page_height_pt = page_height_mm * self.MM_TO_POINTS
# Create PDF canvas
c = canvas.Canvas(output_path, pagesize=(page_width_pt, page_height_pt))
# Process each page
pages_processed = 0
for page in self.project.pages:
if progress_callback:
progress_callback(pages_processed, total_pages,
f"Exporting page {page.page_number}...")
if page.is_double_spread:
# Ensure spread starts on even page (left page of facing pair)
if self.current_pdf_page % 2 == 1:
# Insert blank page
c.showPage() # Finish current page
self.current_pdf_page += 1
if progress_callback:
progress_callback(pages_processed, total_pages,
f"Inserting blank page for alignment...")
# Export spread as two pages
self._export_spread(c, page, page_width_pt, page_height_pt)
pages_processed += 2
else:
# Export single page
self._export_single_page(c, page, page_width_pt, page_height_pt)
pages_processed += 1
# Save PDF
c.save()
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 _export_single_page(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
"""Export a single page to PDF"""
# Render all elements
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)
c.showPage() # Finish this page
self.current_pdf_page += 1
def _export_spread(self, c: canvas.Canvas, page, page_width_pt: float,
page_height_pt: float):
"""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
# Process elements for left page
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)
elif element_x_px >= center_px - threshold_px:
# Skip for now, will render on right page
pass
else:
# Spanning element - render left portion
self._render_split_element(c, element, 0, center_mm, page_width_pt,
page_height_pt, page.page_number, 'left')
c.showPage() # Finish left page
self.current_pdf_page += 1
# Process elements for right page
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)
elif element_x_px < center_px and element_x_px + element_width_px > center_px + threshold_px:
# Spanning element - render right portion
self._render_split_element(c, element, center_mm, center_mm, page_width_pt,
page_height_pt, page.page_number + 1, 'right')
c.showPage() # Finish right page
self.current_pdf_page += 1
def _render_element(self, c: canvas.Canvas, element, x_offset_mm: float,
page_width_pt: float, page_height_pt: float, page_number: int):
"""
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
page_height_pt: Page height in points
page_number: Current page number (for error messages)
"""
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
# 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)
x_pt = adjusted_x_mm * self.MM_TO_POINTS
y_pt = page_height_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):
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, page_number)
elif isinstance(element, TextBoxData):
self._render_textbox(c, element, x_pt, y_pt, width_pt, height_pt)
def _render_split_element(self, c: canvas.Canvas, element, x_offset_mm: float,
split_line_mm: float, page_width_pt: float, page_height_pt: float,
page_number: int, side: str):
"""
Render a split element (only the portion on one side of the split line).
Args:
c: ReportLab canvas
element: The layout element to render
x_offset_mm: X offset in mm (0 for left, page_width for right)
split_line_mm: Position of split line in mm
page_width_pt: Page width in points
page_height_pt: Page height in points
page_number: Current page number
side: 'left' or 'right'
"""
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
# Skip placeholders
if isinstance(element, PlaceholderData):
return
# Get element position and size in pixels
element_x_px, element_y_px = element.position
element_width_px, element_height_px = 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(element, ImageData):
# Calculate which portion of the image to render
if side == 'left':
# Render from element start to split line
crop_width_mm = 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) - split_line_mm
crop_x_start = split_line_mm - element_x_mm
render_x_mm = split_line_mm # Start at split line in spread coordinates
# Adjust render position for offset
adjusted_x_mm = render_x_mm - x_offset_mm
# Convert to points
x_pt = adjusted_x_mm * self.MM_TO_POINTS
y_pt = page_height_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
self._render_image(c, element, x_pt, y_pt, width_pt, height_pt, 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)
elif isinstance(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 (side == 'left' and element_center_mm < split_line_mm) or \
(side == 'right' and element_center_mm >= split_line_mm):
self._render_element(c, element, x_offset_mm, page_width_pt, page_height_pt, page_number)
def _render_image(self, c: 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):
"""
Render an image element on the PDF canvas.
Args:
c: ReportLab canvas
image_element: ImageData instance
x_pt, y_pt, width_pt, height_pt: Position and size in points (after cropping for split images)
page_number: Current page number (for warnings)
crop_left: Left crop position (0.0 to 1.0)
crop_right: Right crop position (0.0 to 1.0)
original_width_pt: Original element width in points (before splitting, for aspect ratio)
original_height_pt: Original element height in points (before splitting, for aspect ratio)
"""
# Check if image exists
if not image_element.image_path or not os.path.exists(image_element.image_path):
warning = f"Page {page_number}: Image not found: {image_element.image_path}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
return
try:
# Load image
img = Image.open(image_element.image_path)
img = img.convert('RGBA')
# Apply element's crop_info (from the element's own cropping)
crop_x_min, crop_y_min, crop_x_max, crop_y_max = image_element.crop_info
# Combine with split cropping if applicable
final_crop_x_min = crop_x_min + (crop_x_max - crop_x_min) * crop_left
final_crop_x_max = crop_x_min + (crop_x_max - crop_x_min) * crop_right
# Calculate pixel crop coordinates
img_width, img_height = img.size
# Apply center crop first (matching the render logic in models.py)
img_aspect = img_width / img_height
# Use original dimensions for aspect ratio if provided (for split images)
# This prevents stretching when splitting an image across pages
if original_width_pt is not None and original_height_pt is not None:
target_aspect = original_width_pt / original_height_pt
else:
target_aspect = width_pt / height_pt
if img_aspect > target_aspect:
# Image is wider - crop horizontally
scale = target_aspect / img_aspect
tx_offset = (1.0 - scale) / 2.0
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset
ty_min_base = 0.0
ty_max_base = 1.0
else:
# Image is taller - crop vertically
scale = img_aspect / target_aspect
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0
tx_max_base = 1.0
ty_min_base = ty_offset
ty_max_base = 1.0 - ty_offset
# Apply element crop_info range
tx_range = tx_max_base - tx_min_base
ty_range = ty_max_base - ty_min_base
tx_min = tx_min_base + final_crop_x_min * tx_range
tx_max = tx_min_base + final_crop_x_max * tx_range
ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Convert to pixel coordinates
crop_left_px = int(tx_min * img_width)
crop_right_px = int(tx_max * img_width)
crop_top_px = int(ty_min * img_height)
crop_bottom_px = int(ty_max * img_height)
# Crop the image
cropped_img = img.crop((crop_left_px, crop_top_px, crop_right_px, crop_bottom_px))
# Downsample image to target resolution based on export DPI
# This prevents embedding huge images and reduces PDF file size
# Calculate target dimensions in pixels based on physical size and export DPI
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)
# Only downsample if current image is larger than target
# Don't upscale small images as that would reduce quality
current_width, current_height = cropped_img.size
if current_width > target_width_px or current_height > target_height_px:
# Use LANCZOS resampling for high quality downsampling
cropped_img = cropped_img.resize((target_width_px, target_height_px),
Image.Resampling.LANCZOS)
# Apply rotation if needed
if image_element.rotation != 0:
# Rotate around center
cropped_img = cropped_img.rotate(-image_element.rotation, expand=True,
fillcolor=(255, 255, 255, 0))
# Save state for transformations
c.saveState()
# Apply rotation to canvas if needed
if image_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(image_element.rotation)
c.translate(-width_pt / 2, -height_pt / 2)
# Draw at origin after transformation
c.drawImage(ImageReader(cropped_img), 0, 0, width_pt, height_pt,
mask='auto', preserveAspectRatio=False)
else:
# Draw without rotation
c.drawImage(ImageReader(cropped_img), x_pt, y_pt, width_pt, height_pt,
mask='auto', preserveAspectRatio=False)
c.restoreState()
except Exception as e:
warning = f"Page {page_number}: Error rendering image {image_element.image_path}: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
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.
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 = text_element.font_settings.get('size', 12)
font_color = text_element.font_settings.get('color', (0, 0, 0))
# 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)
# Save state for transformations
c.saveState()
try:
# Set font
c.setFont(font_family, font_size)
# Set color to black (normalize from 0-255 to 0-1 if needed)
if all(isinstance(x, int) and x > 1 for x in font_color):
color = tuple(x / 255.0 for x in font_color)
else:
color = font_color
c.setFillColorRGB(*color)
# No background is drawn - transparent background in PDF
# 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)
# Draw text relative to rotation center
text_y = -height_pt / 2 + font_size # Adjust for text baseline
if text_element.alignment == 'center':
text_x = -c.stringWidth(text_element.text_content, font_family, font_size) / 2
elif text_element.alignment == 'right':
text_x = width_pt / 2 - c.stringWidth(text_element.text_content, font_family, font_size)
else: # left
text_x = -width_pt / 2
c.drawString(text_x, text_y, text_element.text_content)
else:
# No rotation - draw normally with alignment
text_y = y_pt + font_size # Adjust for text baseline
if text_element.alignment == 'center':
text_x = x_pt + (width_pt - c.stringWidth(text_element.text_content,
font_family, font_size)) / 2
elif text_element.alignment == 'right':
text_x = x_pt + width_pt - c.stringWidth(text_element.text_content,
font_family, font_size)
else: # left
text_x = x_pt
c.drawString(text_x, text_y, text_element.text_content)
except Exception as e:
warning = f"Error rendering text box: {str(e)}"
print(f"WARNING: {warning}")
self.warnings.append(warning)
finally:
c.restoreState()