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
+10 -6
View File
@@ -2,13 +2,13 @@
## Project purpose ## Project purpose
Maintain a Windows desktop photo-book editor that imports a photo folder, arranges one photo per page, edits photo crops, and exports an interior PDF for Blurb. Maintain a Windows desktop photo-book editor that imports a photo folder, arranges one photo per interior page, edits photo crops, and exports Blurb interior and optional ImageWrap cover PDFs.
## Project map ## Project map
- `main.py` launches the app. - `main.py` launches the app.
- `blurb_editor.py` implements the PySide6 UI, page list and canvas, and the background import/export workers. - `blurb_editor.py` implements the PySide6 UI, page and cover lists/canvases, and the background import/export workers.
- `blurb_book.py` defines `PhotoPage`, `BookProject`, and `PrintProfile`; it also handles photo sorting, crop rendering, and PDF export. - `blurb_book.py` defines `PhotoPage`, `CoverLayout`, `BookProject`, and `PrintProfile`; it also handles photo sorting, crop rendering, and PDF export.
- `requirements.txt` pins runtime dependencies. - `requirements.txt` pins runtime dependencies.
- `README.md` is the user-facing setup and feature guide. - `README.md` is the user-facing setup and feature guide.
@@ -16,13 +16,17 @@ Maintain a Windows desktop photo-book editor that imports a photo folder, arrang
- Folder import is top-level only. Supported formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW formats are reported as unsupported. - Folder import is top-level only. Supported formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW formats are reported as unsupported.
- Photos sort by recognized filename timestamp first, then EXIF capture time, then filesystem creation time. - Photos sort by recognized filename timestamp first, then EXIF capture time, then filesystem creation time.
- Rescanning a selected folder adds only paths not already in the project, preserves existing page edits and relative order, inserts new pages chronologically, and reports the number added.
- New photo pages start with centered cover placement (`zoom=1`, `pan_x=0`, `pan_y=0`). Keep preview and export rendering aligned through `render_photo` and the shared crop geometry helpers. - New photo pages start with centered cover placement (`zoom=1`, `pan_x=0`, `pan_y=0`). Keep preview and export rendering aligned through `render_photo` and the shared crop geometry helpers.
- Photo sources are read-only. Projects store absolute paths, page order, crop transforms, tags, and print settings. - Photo sources are read-only. Projects store absolute paths, page order, crop transforms, tags, print settings, cover-spine text styles and vertical positions, and front-cover text settings and positions.
- `PhotoPage.tagged` is optional in older project files; missing values must continue to load as untagged. - `PhotoPage.tagged` is optional in older project files; missing values must continue to load as untagged.
- The page list is canonical in `BookProject.pages`. In **View Tagged**, list rows are only a filtered subset. Never treat a visible row number as the project page index; use the page object and the `_page_at_row` / `_project_index_for_page` helpers. Preserve full-book page numbering while filtering. - The page list is canonical in `BookProject.pages`. In **View Tagged**, list rows are only a filtered subset. Never treat a visible row number as the project page index; use the page object and the `_page_at_row` / `_project_index_for_page` helpers. Preserve full-book page numbering while filtering.
- Cover photos live in `BookProject.cover`, outside `BookProject.pages`. The **Cover Photos** list is informational; cover photos are not interior pages, page-count entries, or reorderable page rows. Removing a cover returns its `PhotoPage` to `BookProject.pages` by capture date.
- Front-cover title and subtitle text use one saved font family, separate sizes and movable normalized positions. Each uses the corresponding spine text color and a 7 px black outline radius; cover export rasterizes the lettering into the spread image.
- Spine title and subtitle vertical positions are saved in inches from the bottom of the face. The title moves within the full spine; the subtitle stays in the bottom subtitle area. Resetting spine text clears custom positions and restores the centered defaults.
- **View Tagged** includes tagged photo pages and is disabled when there are no tagged photos. Page insertion and drag reordering are available in **View All** only. - **View Tagged** includes tagged photo pages and is disabled when there are no tagged photos. Page insertion and drag reordering are available in **View All** only.
- Folder import scanning and thumbnail preparation run in `FolderImportWorker`. Keep image decoding and preview work off the UI thread. Create or modify `QPixmap` objects only on the GUI thread; worker results should use thread-safe image data such as `QImage`. Keep the progress dialog and worker lifetime/error handling in sync. - Folder import scanning, project loading, and thumbnail preparation run in background workers. Keep image decoding and preview work off the UI thread. Create or modify `QPixmap` objects only on the GUI thread; worker results should use thread-safe image data such as `QImage`. Keep progress dialogs and worker lifetime/error handling in sync.
- The default trim area is 9.5 × 8 inches per individual page. Final PDF dimensions and trim insets are set separately for the chosen Blurb configuration. Export contains interior pages only, renders at 300 PPI, and verifies PDF page dimensions and page count. - The default trim area is 9.5 × 8 inches per individual interior page. Final PDF dimensions and trim insets are set separately for the chosen Blurb configuration. Interior export renders at 300 PPI and verifies PDF page dimensions and page count. Configured covers export as a separate one-page full spread with editable face/spine dimensions and 0.125-inch starting values for the wrap/bleed margins; users must verify exact values for their selected Blurb configuration.
- Do not assume trim dimensions equal final PDF dimensions. Blurb requirements vary by format and paper; consult the current official specifications when changing export guidance. - Do not assume trim dimensions equal final PDF dimensions. Blurb requirements vary by format and paper; consult the current official specifications when changing export guidance.
## Development ## Development
+7 -5
View File
@@ -1,6 +1,6 @@
# Blurb Photo Book Editor # Blurb Photo Book Editor
A Windows desktop app for arranging a folder of photos into a Blurb-ready interior PDF. Each photo is placed on its own page. The editor supports reordering, crop adjustment, page tags, and print-size setup. A Windows desktop app for arranging a folder of photos into a Blurb-ready interior PDF and ImageWrap cover spread. Each interior photo is placed on its own page. The editor supports reordering, crop adjustment, page tags, and print-size setup.
## Install and launch ## Install and launch
@@ -15,10 +15,12 @@ python -m venv .venv
## Use the editor ## Use the editor
1. Choose **File → Import photo folder**. Import scans the selected folder's top level, sorts photos oldest to newest, and prepares previews in the background. A progress dialog shows scanning and preview progress. 1. Choose **File → Import photo folder**. Import scans the selected folder's top level, sorts photos oldest to newest, and prepares previews in the background. A progress dialog shows scanning and preview progress.
To add photos later, choose **File → Rescan Folder for New Photos**. It adds images not already in the project at their chronological positions, preserves existing page edits, and reports how many were added.
2. Select a page card to edit it. Drag a photo inside the page to reposition it; use the zoom slider or +/- buttons to zoom around the current frame center. Photos start centered and cropped to fill the page. 2. Select a page card to edit it. Drag a photo inside the page to reposition it; use the zoom slider or +/- buttons to zoom around the current frame center. Photos start centered and cropped to fill the page.
3. Drag cards to reorder pages. Use **Insert blank**, **Insert photo**, **Remove page**, or **Relink photo** as needed. The source photos are not modified. 3. Drag cards to reorder pages. Use **Insert blank**, **Insert photo**, **Remove page**, or **Relink photo** as needed. The source photos are not modified.
4. Right-click a page card to **Tag page** or **Untag page**. A red badge marks tagged pages, and tags are saved in the project. **View → View Tagged** filters the list to tagged photo pages; it is disabled when no photos are tagged. In this view, insertion and reordering are disabled; switch to **View All** to use them. 4. Right-click a photo page to **Make Front Cover** or **Make Back Cover**. The page leaves the interior and appears in **Cover Photos** with its cover-side badge. In **View → Cover Editor**, adjust its crop, enter spine text, and add optional front-cover title and subtitle text. Drag spine text up or down and front-cover text in any direction. **View → Reset spine text positions** restores the default spine layout. The front text uses the shared font and separate size controls, a 7 px radius black outline, and the matching spine text color. Set cover dimensions and wrap/bleed margins here as well. Remove a cover to return its photo to the interior in date order. Dashed spine guides show the full title area and the bottom 3.504-inch subtitle area.
5. Save the editable `.blurbproj` separately from the exported PDF. 5. Right-click a page card to **Tag page** or **Untag page**. A red badge marks tagged pages, and tags are saved in the project. **View → View Tagged** filters the list to tagged photo pages; it is disabled when no photos are tagged. In this view, insertion and reordering are disabled; switch to **View All** to use them.
6. Save the editable `.blurbproj` separately from the exported PDFs. **File → Open Recent** keeps up to ten projects you opened or saved so you can return to them later. Opening a project shows progress while it loads and prepares photo previews.
Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps in the filename first, then EXIF capture date, then filesystem creation time as a fallback. Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps in the filename first, then EXIF capture date, then filesystem creation time as a fallback.
@@ -26,7 +28,7 @@ Photo cards show the date as `mm/dd/yyyy`. Dates come from supported timestamps
The default trim area is **9.5 × 8 inches per individual page**. In **File → Blurb print setup**, enter the final PDF page width and height and the trim offsets required for the selected Blurb book format, paper, and cover. The exported PDF has one page per book page and includes the configured bleed area. The dashed guide shows the trim area, including alternating binding and outside edges. The default trim area is **9.5 × 8 inches per individual page**. In **File → Blurb print setup**, enter the final PDF page width and height and the trim offsets required for the selected Blurb book format, paper, and cover. The exported PDF has one page per book page and includes the configured bleed area. The dashed guide shows the trim area, including alternating binding and outside edges.
Export renders photo pages at 300 PPI, checks the resulting PDF page count and dimensions, and requires an even page count. It can append blank pages to meet the configured minimum. The default page limits are 20–440; set them to the limits for the selected format and paper. The PDF contains interior pages only; cover design is not included. Export renders photo pages at 300 PPI, checks the resulting PDF page count and dimensions, and requires an even page count. It can append blank pages to meet the configured minimum. The default page limits are 20–440; set them to the limits for the selected format and paper. When cover content is assigned, export also creates a one-page full-spread companion named `<book>-cover.pdf` beside the interior PDF. Cover face defaults are 10.074 × 9 inches, with a 0.46-inch spine. Wrap/bleed margins start at 0.125 inches on each edge; these are editable starting values, so verify the exact dimensions and margins for the chosen Blurb format, paper, and page count before exporting.
Blurb's requirements vary by book configuration. Check the current [PDF-to-Book specifications](https://support.blurb.com/hc/en-us/articles/207792946-PDF-to-Book-specifications-and-checklist) and [page-count limits](https://support.blurb.com/hc/en-us/articles/7607055286541-How-many-pages-can-my-book-have) before exporting. Blurb's requirements vary by book configuration. Check the current [PDF-to-Book specifications](https://support.blurb.com/hc/en-us/articles/207792946-PDF-to-Book-specifications-and-checklist) and [page-count limits](https://support.blurb.com/hc/en-us/articles/7607055286541-How-many-pages-can-my-book-have) before exporting.
@@ -34,7 +36,7 @@ Supported photo formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW
## Project files ## Project files
A `.blurbproj` file stores photo paths, page order, each page's zoom and pan, page tags, and print settings. Photo files remain at their original locations; if a path is no longer available, use **Relink photo**. A `.blurbproj` file stores photo paths, page order, each page's zoom and pan, page tags, print settings, cover photos, spine and front-cover text styling and positions, and cover dimensions. Photo files remain at their original locations; if a path is no longer available, use **Relink photo** for interior pages or remove the cover photo and relink it from the book editor.
## Code layout ## Code layout
+604 -2
View File
@@ -17,12 +17,14 @@ from PIL import Image, ImageOps
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
PROJECT_VERSION = 1 PROJECT_VERSION = 2
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"} SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"}
EXPORT_PPI = 300 EXPORT_PPI = 300
JPEG_QUALITY = 94 JPEG_QUALITY = 94
MAX_PAGE_INCHES = 30.0 MAX_PAGE_INCHES = 30.0
MAX_RENDER_PIXELS = 50_000_000 MAX_RENDER_PIXELS = 50_000_000
SPINE_SUBTITLE_ZONE_HEIGHT_IN = 3.504
FRONT_COVER_OUTLINE_RADIUS_PX = 7.0
_FILENAME_DATE_PATTERNS = ( _FILENAME_DATE_PATTERNS = (
re.compile(r"PXL_(20\d{6})_(\d{6})", re.IGNORECASE), 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 @dataclass
class BookProject: class BookProject:
photo_paths: list[str] = field(default_factory=list) photo_paths: list[str] = field(default_factory=list)
pages: list[PhotoPage] = field(default_factory=list) pages: list[PhotoPage] = field(default_factory=list)
profile: PrintProfile = field(default_factory=PrintProfile) profile: PrintProfile = field(default_factory=PrintProfile)
cover: CoverLayout = field(default_factory=CoverLayout)
source_directory: str | None = None source_directory: str | None = None
capture_dates: dict[str, datetime] = field(default_factory=dict, repr=False) capture_dates: dict[str, datetime] = field(default_factory=dict, repr=False)
@@ -184,6 +419,7 @@ class BookProject:
"photo_paths": self.photo_paths, "photo_paths": self.photo_paths,
"pages": [page.to_dict() for page in self.pages], "pages": [page.to_dict() for page in self.pages],
"print_profile": self.profile.to_dict(), "print_profile": self.profile.to_dict(),
"cover": self.cover.to_dict(),
} }
def save(self, path: str | Path) -> None: def save(self, path: str | Path) -> None:
@@ -209,19 +445,23 @@ class BookProject:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("The project file does not contain a project object.") raise ValueError("The project file does not contain a project object.")
version = data.get("version", 1) 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.") raise ValueError(f"Project version {version} is not supported.")
photo_paths = data.get("photo_paths", []) photo_paths = data.get("photo_paths", [])
pages = data.get("pages", []) pages = data.get("pages", [])
profile_data = data.get("print_profile") profile_data = data.get("print_profile")
cover_data = data.get("cover")
if not isinstance(photo_paths, list) or not isinstance(pages, list): if not isinstance(photo_paths, list) or not isinstance(pages, list):
raise ValueError("The project file has an invalid photo or page 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): if profile_data is not None and not isinstance(profile_data, dict):
raise ValueError("The project file has invalid print settings.") 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( return cls(
photo_paths=[str(path_value) for path_value in photo_paths if path_value], 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)], pages=[PhotoPage.from_dict(page) for page in pages if isinstance(page, dict)],
profile=PrintProfile.from_dict(profile_data), profile=PrintProfile.from_dict(profile_data),
cover=CoverLayout.from_dict(cover_data),
source_directory=( source_directory=(
str(data["source_directory"]) if data.get("source_directory") else None str(data["source_directory"]) if data.get("source_directory") else None
), ),
@@ -571,3 +811,365 @@ def export_pdf(
finally: finally:
if temp_pdf and os.path.exists(temp_pdf): if temp_pdf and os.path.exists(temp_pdf):
os.unlink(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)
+1711 -59
View File
File diff suppressed because it is too large Load Diff