1176 lines
47 KiB
Python
1176 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.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 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 = "Arial"
|
|
front_title_font_size_pt: float = 36.0
|
|
front_subtitle_font_size_pt: float = 20.0
|
|
front_title_x: float = 0.5
|
|
front_title_y: float = 0.17
|
|
front_subtitle_x: float = 0.5
|
|
front_subtitle_y: float = 0.28
|
|
title_font_family: str = "Arial"
|
|
title_font_size_pt: float = 10.0
|
|
title_color: str = "#fff03f"
|
|
subtitle_font_family: str = "Arial"
|
|
subtitle_font_size_pt: float = 8.0
|
|
subtitle_color: str = "#fff03f"
|
|
spine_title_y_in: float | None = None
|
|
spine_subtitle_y_in: float | None = None
|
|
face_width_in: float = 10.074
|
|
face_height_in: float = 9.0
|
|
spine_width_in: float = 0.46
|
|
margin_left_in: float | None = 0.125
|
|
margin_right_in: float | None = 0.125
|
|
margin_top_in: float | None = 0.125
|
|
margin_bottom_in: float | None = 0.125
|
|
|
|
@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 "Arial"),
|
|
front_title_font_size_pt=_to_float(data.get("front_title_font_size_pt"), 36.0),
|
|
front_subtitle_font_size_pt=_to_float(data.get("front_subtitle_font_size_pt"), 20.0),
|
|
front_title_x=_to_float(data.get("front_title_x"), 0.5),
|
|
front_title_y=_to_float(data.get("front_title_y"), 0.17),
|
|
front_subtitle_x=_to_float(data.get("front_subtitle_x"), 0.5),
|
|
front_subtitle_y=_to_float(data.get("front_subtitle_y"), 0.28),
|
|
title_font_family=str(data.get("title_font_family") or "Arial"),
|
|
title_font_size_pt=_to_float(data.get("title_font_size_pt"), 10.0),
|
|
title_color=str(data.get("title_color") or "#fff03f"),
|
|
subtitle_font_family=str(data.get("subtitle_font_family") or "Arial"),
|
|
subtitle_font_size_pt=_to_float(data.get("subtitle_font_size_pt"), 8.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")),
|
|
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.46),
|
|
margin_left_in=_to_float(data.get("margin_left_in"), 0.125),
|
|
margin_right_in=_to_float(data.get("margin_right_in"), 0.125),
|
|
margin_top_in=_to_float(data.get("margin_top_in"), 0.125),
|
|
margin_bottom_in=_to_float(data.get("margin_bottom_in"), 0.125),
|
|
)
|
|
|
|
|
|
@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)
|