"""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 = 2 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 SPINE_SUBTITLE_ZONE_HEIGHT_IN = 3.504 FRONT_COVER_OUTLINE_RADIUS_PX = 7.0 _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.625 trim_height_in: float = 8.25 pdf_width_in: float | None = 9.625 pdf_height_in: float | None = 8.25 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.625), trim_height_in=_to_float(data.get("trim_height_in"), 8.25), pdf_width_in=( _optional_float(data.get("pdf_width_in")) if "pdf_width_in" in data else 9.625 ), pdf_height_in=( _optional_float(data.get("pdf_height_in")) if "pdf_height_in" in data else 8.25 ), 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.625) - _to_float(data.get("trim_width_in"), 9.625) - _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 CoverLayout: """ImageWrap cover artwork and the full-spread print geometry.""" back_page: PhotoPage | None = None front_page: PhotoPage | None = None title: str = "" subtitle: str = "" front_title: str = "" front_subtitle: str = "" front_font_family: str = "MV Boli" front_title_font_size_pt: float = 48.0 front_subtitle_font_size_pt: float = 24.0 front_title_x: float = 0.6511959815448585 front_title_y: float = 0.22590834010644723 front_subtitle_x: float = 0.7505046203361379 front_subtitle_y: float = 0.3083317165255237 title_font_family: str = "MV Boli" title_font_size_pt: float = 20.0 title_color: str = "#fff03f" subtitle_font_family: str = "MV Boli" subtitle_font_size_pt: float = 14.0 subtitle_color: str = "#fff03f" spine_title_y_in: float | None = None spine_subtitle_y_in: float | None = 1.1093844419391201 face_width_in: float = 10.074 face_height_in: float = 9.0 spine_width_in: float = 0.57 margin_left_in: float | None = 0.0 margin_right_in: float | None = 0.0 margin_top_in: float | None = 0.0 margin_bottom_in: float | None = 0.0 @property def has_content(self) -> bool: return bool( self.back_page or self.front_page or self.title.strip() or self.subtitle.strip() or self.front_title.strip() or self.front_subtitle.strip() ) def validate(self) -> list[str]: errors: list[str] = [] for label, value in ( ("Cover face width", self.face_width_in), ("Cover face height", self.face_height_in), ("Spine width", self.spine_width_in), ): if not math.isfinite(value) or value <= 0: errors.append(f"{label} must be greater than zero.") elif value > MAX_PAGE_INCHES: errors.append(f"{label} must be no more than {MAX_PAGE_INCHES:g} inches.") if (self.title.strip() or self.subtitle.strip()) and self.spine_width_in * 72 * 0.8 < 3: errors.append("Spine width is too narrow for readable title or subtitle text.") for label, family, size, color in ( ("Title", self.title_font_family, self.title_font_size_pt, self.title_color), ("Subtitle", self.subtitle_font_family, self.subtitle_font_size_pt, self.subtitle_color), ): if not family.strip(): errors.append(f"{label} font family cannot be empty.") if not math.isfinite(size) or not 4 <= size <= 72: errors.append(f"{label} font size must be between 4 and 72 points.") if not re.fullmatch(r"#[0-9a-fA-F]{6}", color): errors.append(f"{label} color must be a six-digit hex color.") if not self.front_font_family.strip(): errors.append("Front cover font family cannot be empty.") for label, size in ( ("Front title", self.front_title_font_size_pt), ("Front subtitle", self.front_subtitle_font_size_pt), ): if not math.isfinite(size) or not 4 <= size <= 144: errors.append(f"{label} font size must be between 4 and 144 points.") for label, position in ( ("Front title horizontal position", self.front_title_x), ("Front title vertical position", self.front_title_y), ("Front subtitle horizontal position", self.front_subtitle_x), ("Front subtitle vertical position", self.front_subtitle_y), ): if not math.isfinite(position) or not 0 <= position <= 1: errors.append(f"{label} must be between 0 and 1.") spine_title_position = self.spine_title_y_in if spine_title_position is not None and ( not math.isfinite(spine_title_position) or not 0 <= spine_title_position <= self.face_height_in ): errors.append("Spine title vertical position must be within the cover face.") subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.face_height_in) spine_subtitle_position = self.spine_subtitle_y_in if spine_subtitle_position is not None and ( not math.isfinite(spine_subtitle_position) or not 0 <= spine_subtitle_position <= subtitle_zone_height ): errors.append("Spine subtitle vertical position must be within the subtitle area.") for label, value in ( ("Left wrap/bleed margin", self.margin_left_in), ("Right wrap/bleed margin", self.margin_right_in), ("Top wrap/bleed margin", self.margin_top_in), ("Bottom wrap/bleed margin", self.margin_bottom_in), ): if value is None: errors.append(f"Enter the {label.lower()} from the Blurb specifications.") elif 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 not errors: spread_width, spread_height = self.spread_size_in if spread_width > MAX_PAGE_INCHES or spread_height > MAX_PAGE_INCHES: errors.append( f"The full cover spread cannot exceed {MAX_PAGE_INCHES:g} inches in either dimension." ) return errors @property def spread_size_in(self) -> tuple[float, float]: return ( float(self.margin_left_in or 0.0) + self.face_width_in * 2 + self.spine_width_in + float(self.margin_right_in or 0.0), float(self.margin_top_in or 0.0) + self.face_height_in + float(self.margin_bottom_in or 0.0), ) @property def spine_text_layout(self) -> tuple[float, float, float, float]: """Return title/subtitle centers (inches from bottom) and max lengths (points).""" title_y = ( self.spine_title_y_in if self.spine_title_y_in is not None else self.face_height_in / 2 ) subtitle_zone_height = min(SPINE_SUBTITLE_ZONE_HEIGHT_IN, self.face_height_in) subtitle_y = ( self.spine_subtitle_y_in if self.spine_subtitle_y_in is not None else subtitle_zone_height / 2 ) title_extent = max( 1.0, min(title_y, self.face_height_in - title_y) * 2 * 72, ) subtitle_extent = max( 1.0, min(subtitle_y, subtitle_zone_height - subtitle_y) * 2 * 72, ) if self.title.strip() and self.subtitle.strip(): separation = max(1.0, abs(title_y - subtitle_y) * 72 - 12) title_extent = min(title_extent, separation) subtitle_extent = min(subtitle_extent, separation) return title_y, subtitle_y, title_extent, subtitle_extent def to_dict(self) -> dict[str, object]: return { "back_page": self.back_page.to_dict() if self.back_page else None, "front_page": self.front_page.to_dict() if self.front_page else None, "title": self.title, "subtitle": self.subtitle, "front_title": self.front_title, "front_subtitle": self.front_subtitle, "front_font_family": self.front_font_family, "front_title_font_size_pt": self.front_title_font_size_pt, "front_subtitle_font_size_pt": self.front_subtitle_font_size_pt, "front_title_x": self.front_title_x, "front_title_y": self.front_title_y, "front_subtitle_x": self.front_subtitle_x, "front_subtitle_y": self.front_subtitle_y, "title_font_family": self.title_font_family, "title_font_size_pt": self.title_font_size_pt, "title_color": self.title_color, "subtitle_font_family": self.subtitle_font_family, "subtitle_font_size_pt": self.subtitle_font_size_pt, "subtitle_color": self.subtitle_color, "spine_title_y_in": self.spine_title_y_in, "spine_subtitle_y_in": self.spine_subtitle_y_in, "face_width_in": self.face_width_in, "face_height_in": self.face_height_in, "spine_width_in": self.spine_width_in, "margin_left_in": self.margin_left_in, "margin_right_in": self.margin_right_in, "margin_top_in": self.margin_top_in, "margin_bottom_in": self.margin_bottom_in, } @classmethod def from_dict(cls, data: dict[str, object] | None) -> "CoverLayout": if not data: return cls() back_data = data.get("back_page") front_data = data.get("front_page") return cls( back_page=PhotoPage.from_dict(back_data) if isinstance(back_data, dict) else None, front_page=PhotoPage.from_dict(front_data) if isinstance(front_data, dict) else None, title=str(data.get("title", "")), subtitle=str(data.get("subtitle", "")), front_title=str(data.get("front_title", "")), front_subtitle=str(data.get("front_subtitle", "")), front_font_family=str(data.get("front_font_family") or "MV Boli"), front_title_font_size_pt=_to_float(data.get("front_title_font_size_pt"), 48.0), front_subtitle_font_size_pt=_to_float(data.get("front_subtitle_font_size_pt"), 24.0), front_title_x=_to_float(data.get("front_title_x"), 0.6511959815448585), front_title_y=_to_float(data.get("front_title_y"), 0.22590834010644723), front_subtitle_x=_to_float(data.get("front_subtitle_x"), 0.7505046203361379), front_subtitle_y=_to_float(data.get("front_subtitle_y"), 0.3083317165255237), title_font_family=str(data.get("title_font_family") or "MV Boli"), title_font_size_pt=_to_float(data.get("title_font_size_pt"), 20.0), title_color=str(data.get("title_color") or "#fff03f"), subtitle_font_family=str(data.get("subtitle_font_family") or "MV Boli"), subtitle_font_size_pt=_to_float(data.get("subtitle_font_size_pt"), 14.0), subtitle_color=str(data.get("subtitle_color") or "#fff03f"), spine_title_y_in=_optional_float(data.get("spine_title_y_in")), spine_subtitle_y_in=( _optional_float(data.get("spine_subtitle_y_in")) if "spine_subtitle_y_in" in data else 1.1093844419391201 ), face_width_in=_to_float(data.get("face_width_in"), 10.074), face_height_in=_to_float(data.get("face_height_in"), 9.0), spine_width_in=_to_float(data.get("spine_width_in"), 0.57), margin_left_in=_to_float(data.get("margin_left_in"), 0.0), margin_right_in=_to_float(data.get("margin_right_in"), 0.0), margin_top_in=_to_float(data.get("margin_top_in"), 0.0), margin_bottom_in=_to_float(data.get("margin_bottom_in"), 0.0), ) @dataclass class BookProject: photo_paths: list[str] = field(default_factory=list) pages: list[PhotoPage] = field(default_factory=list) profile: PrintProfile = field(default_factory=PrintProfile) cover: CoverLayout = field(default_factory=CoverLayout) 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(), "cover": self.cover.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 not in (1, 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") cover_data = data.get("cover") 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.") if cover_data is not None and not isinstance(cover_data, dict): raise ValueError("The project file has invalid cover 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), cover=CoverLayout.from_dict(cover_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) def _cover_page_size_pixels(cover: CoverLayout, ppi: int = EXPORT_PPI) -> tuple[int, int]: errors = cover.validate() if errors: raise ValueError("\n".join(errors)) width_in, height_in = cover.spread_size_in width = max(1, round(width_in * ppi)) height = max(1, round(height_in * ppi)) pixels = width * height if pixels > MAX_RENDER_PIXELS: raise ValueError( f"The cover spread would render to {pixels:,} pixels at {ppi} PPI. " "Reduce the cover size or margins." ) return width, height def _paint_cover_spine_text( spread: Image.Image, cover: CoverLayout, spine_x_px: int, spine_width_px: int, face_height_px: int, top_px: int, ) -> None: """Render editable system fonts into a transparent 300 PPI spine layer.""" if not cover.title.strip() and not cover.subtitle.strip(): return from PySide6.QtCore import QRectF, Qt from PySide6.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter layer = QImage( max(1, spine_width_px), max(1, face_height_px), QImage.Format.Format_RGBA8888, ) layer.fill(Qt.GlobalColor.transparent) painter = QPainter(layer) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) title_y, subtitle_y, title_extent, subtitle_extent = cover.spine_text_layout for text, family, size_pt, color, y_from_bottom, max_length_pt in ( ( cover.title, cover.title_font_family, cover.title_font_size_pt, cover.title_color, title_y, title_extent, ), ( cover.subtitle, cover.subtitle_font_family, cover.subtitle_font_size_pt, cover.subtitle_color, subtitle_y, subtitle_extent, ), ): if not text.strip(): continue font_px = max(1, round(size_pt * EXPORT_PPI / 72)) font = QFont(family) font.setPixelSize(font_px) measured_px = QFontMetricsF(font).horizontalAdvance(text) max_length_px = max(1.0, max_length_pt * EXPORT_PPI / 72) if measured_px > max_length_px: font_px = round(font_px * max_length_px / measured_px) if font_px < round(3.0 * EXPORT_PPI / 72): raise ValueError(f'Spine text "{text}" is too long to fit at its configured position.') font.setPixelSize(max(1, font_px)) painter.save() painter.translate( spine_width_px / 2, face_height_px - y_from_bottom * EXPORT_PPI, ) painter.rotate(90) painter.setFont(font) painter.setPen(QColor(color)) text_rect = QRectF( -max_length_px / 2, -spine_width_px / 2, max_length_px, spine_width_px, ) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) painter.restore() painter.end() image_bytes = bytes(layer.constBits()) text_layer = Image.frombytes( "RGBA", (layer.width(), layer.height()), image_bytes, ) spread.paste(text_layer, (spine_x_px, top_px), text_layer) text_layer.close() layer = None def _paint_cover_front_text( spread: Image.Image, cover: CoverLayout, front_x_px: int, face_width_px: int, face_height_px: int, top_px: int, ) -> None: """Render outlined front-cover titles into the spread image.""" if not cover.front_title.strip() and not cover.front_subtitle.strip(): return from PySide6.QtCore import QPointF, Qt from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPainterPath, QPen layer = QImage( max(1, face_width_px), max(1, face_height_px), QImage.Format.Format_RGBA8888, ) layer.fill(Qt.GlobalColor.transparent) painter = QPainter(layer) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) for text, size_pt, x_fraction, y_fraction, color in ( ( cover.front_title, cover.front_title_font_size_pt, cover.front_title_x, cover.front_title_y, cover.title_color, ), ( cover.front_subtitle, cover.front_subtitle_font_size_pt, cover.front_subtitle_x, cover.front_subtitle_y, cover.subtitle_color, ), ): if not text.strip(): continue font_px = max(1, round(size_pt * EXPORT_PPI / 72)) font = QFont(cover.front_font_family) font.setPixelSize(font_px) path = QPainterPath() path.addText(QPointF(0, 0), font, text) bounds = path.boundingRect() max_width_px = max(1.0, face_width_px * 0.9) if bounds.width() > max_width_px: font_px = max(1, round(font_px * max_width_px / bounds.width())) font.setPixelSize(font_px) path = QPainterPath() path.addText(QPointF(0, 0), font, text) bounds = path.boundingRect() path.translate( x_fraction * face_width_px - bounds.center().x(), y_fraction * face_height_px - bounds.center().y(), ) painter.setPen( QPen( QColor("#000000"), FRONT_COVER_OUTLINE_RADIUS_PX * 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin, ) ) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(path) painter.setPen(QPen(Qt.PenStyle.NoPen)) painter.setBrush(QColor(color)) painter.drawPath(path) painter.end() image_bytes = bytes(layer.constBits()) text_layer = Image.frombytes("RGBA", (layer.width(), layer.height()), image_bytes) spread.paste(text_layer, (front_x_px, top_px), text_layer) text_layer.close() layer = None def _verify_cover_pdf(path: Path, width_in: float, height_in: float) -> None: import pymupdf document = pymupdf.open(path) try: if len(document) != 1: raise ValueError(f"Cover PDF verification found {len(document)} pages, expected one.") rect = document[0].rect if abs(rect.width - width_in * 72) > 0.15 or abs(rect.height - height_in * 72) > 0.15: raise ValueError("Cover PDF does not match the configured full-spread dimensions.") if not document[0].get_images(full=True): raise ValueError("Cover PDF is missing its spread artwork.") finally: document.close() def export_cover_pdf( project: BookProject, output_path: str | Path, progress: Callable[[int, int], None] | None = None, ) -> Path: """Create and verify a single-page ImageWrap full-spread cover PDF.""" cover = project.cover if not cover.has_content: raise ValueError("Add a cover photo or spine text before exporting the cover.") page_width_px, page_height_px = _cover_page_size_pixels(cover) destination = Path(output_path).expanduser().resolve() destination.parent.mkdir(parents=True, exist_ok=True) spread_width_in, spread_height_in = cover.spread_size_in temp_pdf: str | None = None try: with tempfile.TemporaryDirectory(prefix="blurb-cover-render-") as render_directory: render_root = Path(render_directory) spread = Image.new("RGB", (page_width_px, page_height_px), "white") left_px = round(float(cover.margin_left_in) * EXPORT_PPI) back_width_px = round(cover.face_width_in * EXPORT_PPI) spine_width_px = round(cover.spine_width_in * EXPORT_PPI) face_height_px = round(cover.face_height_in * EXPORT_PPI) top_px = round(float(cover.margin_top_in) * EXPORT_PPI) bottom_px = round(float(cover.margin_bottom_in) * EXPORT_PPI) right_px = round(float(cover.margin_right_in) * EXPORT_PPI) back_x_px = left_px spine_x_px = back_x_px + back_width_px front_x_px = spine_x_px + spine_width_px spine_art = Image.new( "RGB", (max(1, spine_width_px), page_height_px), (38, 59, 80), ) spread.paste(spine_art, (spine_x_px, 0)) spine_art.close() rendered_panels: list[tuple[str, Image.Image, int]] = [] for label, page, x_px in ( ("back", cover.back_page, back_x_px), ("front", cover.front_page, front_x_px), ): if page is None or not page.image_path: continue image_path = Path(page.image_path) if not image_path.is_file(): raise FileNotFoundError(f"Cover photo is missing: {image_path}") panel = render_photo( image_path, (back_width_px, face_height_px), page.zoom, page.pan_x, page.pan_y, ) spread.paste(panel, (x_px, top_px)) rendered_panels.append((label, panel, x_px)) # Extend the outer and vertical face edges into the configured wrap areas. # This gives edge-to-edge artwork rather than leaving the added print area white. for label, panel, x_px in rendered_panels: panel_w, panel_h = panel.size if top_px: top_edge = panel.crop((0, 0, panel_w, 1)).resize( (panel_w, top_px), Image.Resampling.BILINEAR ) spread.paste(top_edge, (x_px, 0)) top_edge.close() if bottom_px: bottom_edge = panel.crop((0, panel_h - 1, panel_w, panel_h)).resize( (panel_w, bottom_px), Image.Resampling.BILINEAR ) spread.paste(bottom_edge, (x_px, top_px + face_height_px)) bottom_edge.close() if label == "back" and left_px: left_edge = panel.crop((0, 0, 1, panel_h)).resize( (left_px, panel_h), Image.Resampling.BILINEAR ) spread.paste(left_edge, (0, top_px)) left_edge.close() if top_px: spread.paste(panel.getpixel((0, 0)), (0, 0, left_px, top_px)) if bottom_px: spread.paste( panel.getpixel((0, panel_h - 1)), (0, top_px + face_height_px, left_px, page_height_px), ) if label == "front" and right_px: right_edge = panel.crop((panel_w - 1, 0, panel_w, panel_h)).resize( (right_px, panel_h), Image.Resampling.BILINEAR ) spread.paste(right_edge, (front_x_px + panel_w, top_px)) right_edge.close() if top_px: spread.paste( panel.getpixel((panel_w - 1, 0)), (front_x_px + panel_w, 0, page_width_px, top_px), ) if bottom_px: spread.paste( panel.getpixel((panel_w - 1, panel_h - 1)), ( front_x_px + panel_w, top_px + face_height_px, page_width_px, page_height_px, ), ) panel.close() _paint_cover_spine_text( spread, cover, spine_x_px, spine_width_px, face_height_px, top_px, ) _paint_cover_front_text( spread, cover, front_x_px, back_width_px, face_height_px, top_px, ) rendered_path = render_root / "cover-spread.jpg" spread.save(rendered_path, format="JPEG", quality=JPEG_QUALITY, optimize=True) spread.close() 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=(spread_width_in * 72, spread_height_in * 72), pageCompression=1, ) pdf.setTitle(destination.stem) pdf.setCreator("Blurb Photo Book Editor") pdf.drawImage( str(rendered_path), 0, 0, width=spread_width_in * 72, height=spread_height_in * 72, ) pdf.showPage() pdf.save() _verify_cover_pdf(Path(temp_pdf), spread_width_in, spread_height_in) os.replace(temp_pdf, destination) temp_pdf = None if progress: progress(1, 1) return destination finally: if temp_pdf and os.path.exists(temp_pdf): os.unlink(temp_pdf)