- Implemented the main function to launch the editor. - Added error handling for missing PySide6 dependency. - Included instructions for installing required packages.
574 lines
22 KiB
Python
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)
|