Refactor code structure for improved readability and maintainability
This commit is contained in:
+604
-2
@@ -17,12 +17,14 @@ from PIL import Image, ImageOps
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
|
||||
PROJECT_VERSION = 1
|
||||
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),
|
||||
@@ -159,11 +161,244 @@ class PrintProfile:
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -184,6 +419,7 @@ class BookProject:
|
||||
"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:
|
||||
@@ -209,19 +445,23 @@ class BookProject:
|
||||
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:
|
||||
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
|
||||
),
|
||||
@@ -571,3 +811,365 @@ def export_pdf(
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user