491 lines
22 KiB
Python
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()
|