"""Photo-book project data, image rendering, and PDF export.""" from __future__ import annotations from collections import OrderedDict from dataclasses import asdict, dataclass, field from datetime import datetime import json import math import os from pathlib import Path import re import tempfile from typing import Callable, Iterable from PIL import Image, ImageOps from reportlab.pdfgen import canvas PROJECT_VERSION = 1 SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"} EXPORT_PPI = 300 JPEG_QUALITY = 94 MAX_PAGE_INCHES = 30.0 MAX_RENDER_PIXELS = 50_000_000 _FILENAME_DATE_PATTERNS = ( re.compile(r"PXL_(20\d{6})_(\d{6})", re.IGNORECASE), re.compile(r"IMG(20\d{6})(\d{6})", re.IGNORECASE), ) _THUMBNAIL_CACHE: OrderedDict[tuple[str, int], Image.Image] = OrderedDict() _THUMBNAIL_CACHE_LIMIT = 8 _UNSUPPORTED_PHOTO_EXTENSIONS = {".heic", ".heif", ".avif", ".dng", ".cr2", ".nef", ".arw", ".raw"} @dataclass class PhotoPage: """One output page; image_path is None for a blank page.""" image_path: str | None = None zoom: float = 1.0 pan_x: float = 0.0 pan_y: float = 0.0 tagged: bool = False def to_dict(self) -> dict[str, object]: return asdict(self) @classmethod def from_dict(cls, data: dict[str, object]) -> "PhotoPage": image_path = data.get("image_path") zoom = float(data.get("zoom", 1.0)) pan_x = float(data.get("pan_x", 0.0)) pan_y = float(data.get("pan_y", 0.0)) tagged = data.get("tagged", False) is True if not math.isfinite(zoom) or zoom <= 0: zoom = 1.0 if not math.isfinite(pan_x): pan_x = 0.0 if not math.isfinite(pan_y): pan_y = 0.0 return cls( image_path=str(image_path) if image_path else None, zoom=max(0.01, min(zoom, 4.0)), pan_x=max(-1.0, min(pan_x, 1.0)), pan_y=max(-1.0, min(pan_y, 1.0)), tagged=tagged, ) @dataclass class PrintProfile: """Trim dimensions plus exact final PDF dimensions supplied by Blurb.""" trim_width_in: float = 9.5 trim_height_in: float = 8.0 pdf_width_in: float | None = None pdf_height_in: float | None = None trim_binding_in: float = 0.0 trim_outside_in: float = 0.0 trim_top_in: float = 0.0 trim_bottom_in: float = 0.0 min_pages: int = 20 max_pages: int = 440 @property def ready(self) -> bool: return self.pdf_width_in is not None and self.pdf_height_in is not None def validate(self) -> list[str]: errors: list[str] = [] values = { "trim width": self.trim_width_in, "trim height": self.trim_height_in, "trim binding-edge inset": self.trim_binding_in, "trim outside-edge inset": self.trim_outside_in, "trim top inset": self.trim_top_in, "trim bottom inset": self.trim_bottom_in, } if self.pdf_width_in is not None: values["PDF page width"] = self.pdf_width_in if self.pdf_height_in is not None: values["PDF page height"] = self.pdf_height_in for label, value in values.items(): if not math.isfinite(value) or value < 0: errors.append(f"{label} must be a non-negative number.") elif value > MAX_PAGE_INCHES: errors.append(f"{label} must be no more than {MAX_PAGE_INCHES:g} inches.") if self.trim_width_in <= 0 or self.trim_height_in <= 0: errors.append("Trim width and height must be greater than zero.") if self.min_pages < 1: errors.append("Minimum page count must be at least one.") if self.max_pages < self.min_pages: errors.append("Maximum page count must be at least the minimum page count.") if self.pdf_width_in is None or self.pdf_height_in is None: errors.append("Enter the final PDF page width and height from the Blurb specifications.") else: if self.pdf_width_in < self.trim_width_in or self.pdf_height_in < self.trim_height_in: errors.append("The final PDF page cannot be smaller than the trim area.") if abs( self.trim_width_in + self.trim_binding_in + self.trim_outside_in - self.pdf_width_in ) > 0.003: errors.append("PDF width must equal trim width plus binding-edge and outside-edge insets.") if abs(self.trim_height_in + self.trim_top_in + self.trim_bottom_in - self.pdf_height_in) > 0.003: errors.append("PDF height must equal trim height plus top and bottom insets.") return errors def to_dict(self) -> dict[str, object]: return asdict(self) @classmethod def from_dict(cls, data: dict[str, object] | None) -> "PrintProfile": if not data: return cls() return cls( trim_width_in=_to_float(data.get("trim_width_in"), 9.5), trim_height_in=_to_float(data.get("trim_height_in"), 8.0), pdf_width_in=_optional_float(data.get("pdf_width_in")), pdf_height_in=_optional_float(data.get("pdf_height_in")), trim_binding_in=_to_float( data.get("trim_binding_in", data.get("trim_left_in")), 0.0 ), trim_outside_in=_to_float( data.get( "trim_outside_in", max( 0.0, _to_float(data.get("pdf_width_in"), 9.5) - _to_float(data.get("trim_width_in"), 9.5) - _to_float(data.get("trim_left_in"), 0.0), ), ), 0.0, ), trim_top_in=_to_float(data.get("trim_top_in"), 0.0), trim_bottom_in=_to_float(data.get("trim_bottom_in"), 0.0), min_pages=int(_to_float(data.get("min_pages"), 20)), max_pages=int(_to_float(data.get("max_pages"), 440)), ) @dataclass class BookProject: photo_paths: list[str] = field(default_factory=list) pages: list[PhotoPage] = field(default_factory=list) profile: PrintProfile = field(default_factory=PrintProfile) source_directory: str | None = None capture_dates: dict[str, datetime] = field(default_factory=dict, repr=False) @classmethod def from_folder(cls, folder: str | Path) -> tuple["BookProject", list[str]]: photos, issues = scan_photo_folder(folder) return cls( photo_paths=[str(path) for path, _ in photos], pages=[PhotoPage(image_path=str(path)) for path, _ in photos], source_directory=str(Path(folder).resolve()), capture_dates={str(path): capture_date for path, capture_date in photos}, ), issues def to_dict(self) -> dict[str, object]: return { "version": PROJECT_VERSION, "source_directory": self.source_directory, "photo_paths": self.photo_paths, "pages": [page.to_dict() for page in self.pages], "print_profile": self.profile.to_dict(), } def save(self, path: str | Path) -> None: destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) temp_path: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=destination.parent, suffix=".tmp", delete=False ) as stream: temp_path = stream.name json.dump(self.to_dict(), stream, indent=2, ensure_ascii=False) stream.write("\n") os.replace(temp_path, destination) finally: if temp_path and os.path.exists(temp_path): os.unlink(temp_path) @classmethod def load(cls, path: str | Path) -> "BookProject": source = Path(path) data = json.loads(source.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError("The project file does not contain a project object.") version = data.get("version", 1) if version != PROJECT_VERSION: raise ValueError(f"Project version {version} is not supported.") photo_paths = data.get("photo_paths", []) pages = data.get("pages", []) profile_data = data.get("print_profile") if not isinstance(photo_paths, list) or not isinstance(pages, list): raise ValueError("The project file has an invalid photo or page list.") if profile_data is not None and not isinstance(profile_data, dict): raise ValueError("The project file has invalid print settings.") return cls( photo_paths=[str(path_value) for path_value in photo_paths if path_value], pages=[PhotoPage.from_dict(page) for page in pages if isinstance(page, dict)], profile=PrintProfile.from_dict(profile_data), source_directory=( str(data["source_directory"]) if data.get("source_directory") else None ), ) def _to_float(value: object, default: float) -> float: try: number = float(value) return number if math.isfinite(number) else default except (TypeError, ValueError): return default def _optional_float(value: object) -> float | None: if value is None or value == "": return None return _to_float(value, 0.0) def filename_capture_date(path: str | Path) -> datetime | None: name = Path(path).name for pattern in _FILENAME_DATE_PATTERNS: match = pattern.search(name) if not match: continue date_text, time_text = match.groups() try: return datetime.strptime(date_text + time_text, "%Y%m%d%H%M%S") except ValueError: return None return None def exif_capture_date(path: str | Path) -> datetime | None: try: with Image.open(path) as image: exif = image.getexif() exif_ifd = exif.get_ifd(0x8769) if 0x8769 in exif else {} candidates: Iterable[object] = ( exif.get(36867), exif_ifd.get(36867), exif.get(36868), exif.get(306), ) for value in candidates: if isinstance(value, bytes): value = value.decode("ascii", errors="ignore") if not isinstance(value, str): continue try: return datetime.strptime(value.strip(), "%Y:%m:%d %H:%M:%S") except ValueError: continue except (OSError, ValueError, TypeError, KeyError): return None return None def photo_sort_date(path: str | Path) -> datetime: by_name = filename_capture_date(path) if by_name: return by_name by_exif = exif_capture_date(path) if by_exif: return by_exif try: return datetime.fromtimestamp(Path(path).stat().st_ctime) except OSError: return datetime.max def scan_photo_folder(folder: str | Path) -> tuple[list[tuple[Path, datetime]], list[str]]: directory = Path(folder).expanduser().resolve() if not directory.is_dir(): raise NotADirectoryError(f"Not a folder: {directory}") photos: list[tuple[Path, datetime]] = [] issues: list[str] = [] unsupported: list[str] = [] for entry in directory.iterdir(): if not entry.is_file(): continue if entry.suffix.lower() in _UNSUPPORTED_PHOTO_EXTENSIONS: unsupported.append(entry.name) continue if entry.suffix.lower() not in SUPPORTED_EXTENSIONS: continue try: with Image.open(entry) as image: image.verify() photos.append((entry.resolve(), photo_sort_date(entry))) except (OSError, ValueError, Image.DecompressionBombError) as exc: issues.append(f"{entry.name}: could not read image ({exc})") photos.sort(key=lambda pair: (pair[1], pair[0].name.casefold())) if unsupported: names = ", ".join(unsupported[:10]) if len(unsupported) > 10: names += f", and {len(unsupported) - 10} more" issues.append(f"Skipped unsupported HEIC/RAW photo format(s): {names}") return photos, issues def _open_oriented_rgb(path: str | Path) -> Image.Image: with Image.open(path) as source: image = ImageOps.exif_transpose(source) if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info): rgba = image.convert("RGBA") background = Image.new("RGBA", rgba.size, "white") background.alpha_composite(rgba) return background.convert("RGB") return image.convert("RGB") def _cover_geometry( source_size: tuple[int, int], page_size: tuple[int, int], zoom: float, pan_x: float, pan_y: float, ) -> tuple[float, float, float, float, float]: source_w, source_h = source_size page_w, page_h = page_size cover_scale = max(page_w / source_w, page_h / source_h) contain_scale = min(page_w / source_w, page_h / source_h) min_zoom = contain_scale / cover_scale effective_zoom = max(min_zoom, min(float(zoom), 4.0)) scale = cover_scale * effective_zoom image_w, image_h = source_w * scale, source_h * scale pan_x = max(-1.0, min(float(pan_x), 1.0)) pan_y = max(-1.0, min(float(pan_y), 1.0)) # Negative room means the photo is cropped; positive room means white space. x_room = (page_w - image_w) / 2.0 y_room = (page_h - image_h) / 2.0 image_x = x_room * (1.0 - pan_x) image_y = y_room * (1.0 - pan_y) return image_x, image_y, image_w, image_h, min_zoom def page_zoom_bounds(path: str | Path, page_aspect: float) -> tuple[float, float]: with Image.open(path) as source: image = ImageOps.exif_transpose(source) source_w, source_h = image.size page_w, page_h = (page_aspect, 1.0) if page_aspect >= 1 else (1.0, 1.0 / page_aspect) cover_scale = max(page_w / source_w, page_h / source_h) contain_scale = min(page_w / source_w, page_h / source_h) return contain_scale / cover_scale, 4.0 def pan_for_centered_zoom( source_size: tuple[int, int], page_size: tuple[int, int], old_zoom: float, new_zoom: float, pan_x: float, pan_y: float, ) -> tuple[float, float]: """Keep the source point at the page center fixed while changing zoom.""" old_x, old_y, old_w, old_h, _ = _cover_geometry( source_size, page_size, old_zoom, pan_x, pan_y ) _, _, new_w, new_h, _ = _cover_geometry(source_size, page_size, new_zoom, 0.0, 0.0) old_scale_x = old_w / source_size[0] old_scale_y = old_h / source_size[1] new_scale_x = new_w / source_size[0] new_scale_y = new_h / source_size[1] source_center_x = (page_size[0] / 2 - old_x) / old_scale_x source_center_y = (page_size[1] / 2 - old_y) / old_scale_y new_room_x = (page_size[0] - new_w) / 2 new_room_y = (page_size[1] - new_h) / 2 new_x = page_size[0] / 2 - source_center_x * new_scale_x new_y = page_size[1] / 2 - source_center_y * new_scale_y next_pan_x = 0.0 if abs(new_room_x) < 1e-9 else 1.0 - new_x / new_room_x next_pan_y = 0.0 if abs(new_room_y) < 1e-9 else 1.0 - new_y / new_room_y return max(-1.0, min(next_pan_x, 1.0)), max(-1.0, min(next_pan_y, 1.0)) def render_photo( path: str | Path, page_size_px: tuple[int, int], zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = 0.0, *, preview: bool = False, ) -> Image.Image: """Render one cropped photo on a white page-sized RGB image.""" page_w, page_h = page_size_px if page_w <= 0 or page_h <= 0: raise ValueError("Page preview dimensions must be positive.") if preview: key = (str(Path(path).resolve()), 1800) cached = _THUMBNAIL_CACHE.get(key) if cached is None: full = _open_oriented_rgb(path) full.thumbnail((1800, 1800), Image.Resampling.LANCZOS) cached = full.copy() _THUMBNAIL_CACHE[key] = cached _THUMBNAIL_CACHE.move_to_end(key) while len(_THUMBNAIL_CACHE) > _THUMBNAIL_CACHE_LIMIT: _THUMBNAIL_CACHE.popitem(last=False) else: _THUMBNAIL_CACHE.move_to_end(key) image = cached.copy() else: image = _open_oriented_rgb(path) x, y, image_w, image_h, _ = _cover_geometry(image.size, page_size_px, zoom, pan_x, pan_y) page = Image.new("RGB", page_size_px, "white") # Crop in source coordinates before scaling so a heavily zoomed preview/export # never allocates a huge off-page intermediate image. scale = image_w / image.width visible_left = max(0.0, -x / scale) visible_top = max(0.0, -y / scale) visible_right = min(float(image.width), (page_w - x) / scale) visible_bottom = min(float(image.height), (page_h - y) / scale) crop_box = ( max(0, math.floor(visible_left)), max(0, math.floor(visible_top)), min(image.width, math.ceil(visible_right)), min(image.height, math.ceil(visible_bottom)), ) if crop_box[2] > crop_box[0] and crop_box[3] > crop_box[1]: visible = image.crop(crop_box) dest_left = max(0, round(x + crop_box[0] * scale)) dest_top = max(0, round(y + crop_box[1] * scale)) dest_right = min(page_w, round(x + crop_box[2] * scale)) dest_bottom = min(page_h, round(y + crop_box[3] * scale)) dest_size = (max(1, dest_right - dest_left), max(1, dest_bottom - dest_top)) visible = visible.resize(dest_size, Image.Resampling.LANCZOS) page.paste(visible, (dest_left, dest_top)) visible.close() image.close() return page def print_page_size_pixels(profile: PrintProfile, ppi: int = EXPORT_PPI) -> tuple[int, int]: if not profile.ready: raise ValueError("Set the final PDF page dimensions before export.") width = max(1, round(float(profile.pdf_width_in) * ppi)) height = max(1, round(float(profile.pdf_height_in) * ppi)) pixels = width * height if pixels > MAX_RENDER_PIXELS: raise ValueError( f"This page would render to {pixels:,} pixels at {ppi} PPI. " "Reduce the page size or use a smaller source folder." ) return width, height def _verify_pdf(path: Path, expected_images: list[bool], profile: PrintProfile) -> None: import pymupdf document = pymupdf.open(path) try: if len(document) != len(expected_images): raise ValueError( f"PDF verification found {len(document)} pages, expected {len(expected_images)}." ) expected_width = float(profile.pdf_width_in) * 72 expected_height = float(profile.pdf_height_in) * 72 for index, page in enumerate(document): rect = page.rect if abs(rect.width - expected_width) > 0.15 or abs(rect.height - expected_height) > 0.15: raise ValueError(f"PDF page {index + 1} does not match the configured page size.") has_images = bool(page.get_images(full=True)) if has_images != expected_images[index]: raise ValueError(f"PDF page {index + 1} does not match its expected photo/blank content.") finally: document.close() def export_pdf( project: BookProject, output_path: str | Path, progress: Callable[[int, int], None] | None = None, ) -> Path: """Create individual, bleed-sized PDF pages with print-resolution imagery.""" if not project.pages: raise ValueError("Add at least one page before exporting.") errors = project.profile.validate() if errors: raise ValueError("\n".join(errors)) if len(project.pages) % 2: raise ValueError("Blurb requires an even page count. Add a blank page and try again.") if len(project.pages) < project.profile.min_pages: raise ValueError( f"This Blurb format requires at least {project.profile.min_pages} pages." ) if len(project.pages) > project.profile.max_pages: raise ValueError( f"This Blurb format allows at most {project.profile.max_pages} pages." ) page_width_px, page_height_px = print_page_size_pixels(project.profile) destination = Path(output_path).expanduser().resolve() destination.parent.mkdir(parents=True, exist_ok=True) temp_pdf: str | None = None try: with tempfile.TemporaryDirectory(prefix="blurb-render-") as render_directory: render_root = Path(render_directory) with tempfile.NamedTemporaryFile( suffix=".pdf", prefix=f".{destination.stem}-", dir=destination.parent, delete=False ) as stream: temp_pdf = stream.name pdf = canvas.Canvas( temp_pdf, pagesize=(float(project.profile.pdf_width_in) * 72, float(project.profile.pdf_height_in) * 72), pageCompression=1, ) pdf.setTitle(destination.stem) pdf.setCreator("Blurb Photo Book Editor") total = len(project.pages) for index, page in enumerate(project.pages): if page.image_path: image_path = Path(page.image_path) if not image_path.is_file(): raise FileNotFoundError(f"Photo is missing: {image_path}") page_image = render_photo( image_path, (page_width_px, page_height_px), page.zoom, page.pan_x, page.pan_y, ) rendered_path = render_root / f"page_{index + 1:05d}.jpg" page_image.save(rendered_path, format="JPEG", quality=JPEG_QUALITY, optimize=True) page_image.close() pdf.drawImage( str(rendered_path), 0, 0, width=float(project.profile.pdf_width_in) * 72, height=float(project.profile.pdf_height_in) * 72, ) pdf.showPage() if progress: progress(index + 1, total) pdf.save() _verify_pdf( Path(temp_pdf), [bool(page.image_path) for page in project.pages], project.profile, ) os.replace(temp_pdf, destination) temp_pdf = None return destination finally: if temp_pdf and os.path.exists(temp_pdf): os.unlink(temp_pdf)