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.
This commit is contained in:
+573
@@ -0,0 +1,573 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user