Files
blurb/blurb_book.py
T
ryan 4d5d3a4c75 Add main entry point for Blurb Photo Book Editor
- Implemented the main function to launch the editor.
- Added error handling for missing PySide6 dependency.
- Included instructions for installing required packages.
2026-10-05 00:11:30 -04:00

574 lines
22 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 = 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)