Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-10-07 16:12:39 -04:00
parent 4d5d3a4c75
commit 52a497e972
4 changed files with 2332 additions and 72 deletions
+604 -2
View File
@@ -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)