Files
blurb/blurb_book.py
T

1188 lines
47 KiB
Python

"""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)