Add main entry point for Blurb Photo Book Editor

- Implemented the main function to launch the editor.
- Added error handling for missing PySide6 dependency.
- Included instructions for installing required packages.
This commit is contained in:
2026-10-05 00:11:30 -04:00
parent f62cb2efdb
commit 4d5d3a4c75
7 changed files with 2027 additions and 289 deletions
+2
View File
@@ -1 +1,3 @@
.venv/ .venv/
__pycache__/
*.py[cod]
+44
View File
@@ -0,0 +1,44 @@
# Agent Guide
## 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.
## Project map
- `main.py` launches the app.
- `blurb_editor.py` implements the PySide6 UI, page list and canvas, and the background import/export workers.
- `blurb_book.py` defines `PhotoPage`, `BookProject`, and `PrintProfile`; it also handles photo sorting, crop rendering, and PDF export.
- `requirements.txt` pins runtime dependencies.
- `README.md` is the user-facing setup and feature guide.
## Product behavior and constraints
- 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.
- 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.
- `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.
- **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.
- 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.
- 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
Launch from PowerShell with the project environment:
```powershell
.\.venv\Scripts\python.exe main.py
```
Install pinned dependencies with:
```powershell
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
```
Keep dependency pins current with intentional changes to `requirements.txt`. The PDF path uses Pillow for image processing, ReportLab for assembly, and PyMuPDF for output checks.
There is no documented automated test suite. Do not add or run tests unless explicitly requested; when verification is requested, report exactly what was run.
+25 -288
View File
@@ -1,309 +1,46 @@
# Blurb # Blurb Photo Book Editor
Build a print-ready PDF from a folder of photos, sized for an Affinity Publisher 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.
spread (9.5 x 8 in).
**Status: starter / provenance document.** The PDF described here was produced by ## Install and launch
a one-off PowerShell + ImageMagick procedure, not by any committed code in this
repo. Nothing in `Blurb/` is implemented yet. This README records exactly what was
done so it can be turned into a real, repeatable tool.
--- From PowerShell in this directory:
## The artifact that was produced
| Property | Value |
|---|---|
| Output | `C:\tmp\bbook.pdf` |
| Pages | 98 (one photo per page) |
| File size | 59.3 MB |
| Page size | 9.5 x 8 in (684 x 576 pt) |
| Image resolution | 200 DPI (1900 x 1600 px) |
| JPEG quality | 85 |
| Page order | Chronological by date embedded in filename, 2024-01-02 -> 2024-11-01 |
| Page fill | Full-bleed, center-cropped, no white bars |
| Metadata | Stripped (GPS/EXIF removed) |
Source folder `C:\tmp\bbook` was left untouched: 98 `.jpg` files, no stray output.
---
## Source images
98 JPEGs in `C:\tmp\bbook`, from Pixel phone cameras. Three filename shapes, all
carrying a sortable timestamp:
```
PXL_20241014_213548581.PORTRAIT.jpg # main pattern
IMG20240724161556.jpg # 4 files
original_<uuid>_PXL_20240512_215016784.MP.jpg # 3 files, prefixed by a uuid
```
Suffixes in use: bare (default), `.MP` (macro), `.PORTRAIT` (portrait orientation),
`~2` / `~3` (duplicate-export suffixes).
### Orientation and aspect ratio
| Aspect ratio | Count | Note |
|---|---|---|
| 1.332 / 1.328 / 1.333 (4:3) | 94 | Landscape |
| 1.778 (16:9) | 3 | Landscape, wide |
| 0.751 | 1 | Portrait |
97 landscape, 1 portrait. This matters because the target page ratio is **1.188**,
which is narrower than 4:3 -- see Cropping below.
---
## What was done
### Step 1 - Extract the archives
Two zip archives were unpacked into the photo folder in place:
- `Photos-1-001.zip` -> 14 files
- `Photos-1-001 (6).zip` -> 13 files
27 unique files; no name collisions between the two. Archives were inspected
before extraction to confirm they held only `.jpg` and that the two sets did not
overlap. Post-extraction the folder held 98 photos: 27 from the archives plus 71
already present.
> Note: both source zips disappeared from the folder sometime after extraction.
> No photo data was lost (all 27 extracted files remain, 0 zero-byte files) and
> the archives were not in the recycle bin, but the cause was never identified.
> Worth keeping in mind before wiring an "extract then delete" step into any
> automated pipeline.
### Step 2 - Choose the page geometry
The 9.5 x 8 in spread size came from Affinity Publisher. That is a ratio of
**1.1875**, which is what drives the crop behavior.
One ambiguity worth resolving before this becomes code: a Publisher *spread* is
normally two facing pages. 9.5 x 8 in could mean either:
- one 9.5 x 8 in PDF page per photo (what was actually produced), or
- a 9.5 x 8 in spread meaning **two** 4.75 x 8 in pages, i.e. 2 photos per PDF page.
This build assumed the first. The second is very likely the real intent for a
book layout and is the single biggest thing to confirm.
### Step 3 - Measure before placing
Rather than guessing at fill behavior, every image was measured with
`magick identify` first. This surfaced that 94 photos are 4:3 while the page is
1.188, so full-bleed fill has to crop -- and that 4 outliers would be cropped far
harder than the rest:
| Photo AR | Count | Full-bleed crop required |
|---|---|---|
| ~1.33 (4:3) | 94 | ~11% off the sides |
| 1.778 (16:9) | 3 | **~33% off the sides** |
| 0.751 (portrait) | 1 | **~37% off the height** |
Cropping was accepted for uniformity, and the 4 outliers were flagged to the user
rather than silently trimmed. **Worth reviewing before print.**
### Step 4 - Render each page
For each photo, in chronological order, rendered one 1900 x 1600 page:
```powershell ```powershell
magick <input.jpg> -auto-orient -resize 1900x1600^ -gravity center -extent 1900x1600 -strip -interlace Plane -quality 85 page_NNNN.jpg
```
- `-auto-orient` applies the EXIF rotation flag *before* cropping, so PORTRAIT
shots are not cropped as though they were landscape.
- `-resize WxH^` (the `^`) scales to *cover* the box, then `-extent` trims the
overflow. This is what produces full-bleed. For fit-inside instead, drop the
`^`.
- `-strip` drops EXIF including GPS. Dates survive in the page order and
filenames, so nothing meaningful was lost.
- Output was written to a numbered sequence (`page_0001.jpg` ... `page_0098.jpg`)
in a temp dir, not into the photo folder, keeping sources and build artifacts
cleanly separated.
Sort key, tried in order:
1. `PXL_(20\d{6})_(\d{6})` -> date + time
2. `IMG(20\d{6})(\d{6})` -> date + time
3. creation time as fallback
Sorting on the filename timestamp matters: filesystem mtimes were scattered across
several days in Sep/Oct 2026 (copy/export artifacts) and would have produced a
meaningless order.
### Step 5 - Assemble the PDF
The `%04d` glob sequence was tried first and **failed** -- ImageMagick did not
expand `page_%04d.jpg` and errored with `unable to open image`. Passing the 98
absolute paths as explicit arguments worked:
```powershell
magick @pages -units PixelsPerInch -density 200 -quality 85 C:\tmp\bbook.pdf
```
Total argument length stayed around 4.5 KB, far below the Windows command-line
limit, so this is safe until roughly 700+ images per invocation -- at which point
it needs chunking or a response file.
### Step 6 - Verify
**A bug worth documenting:** the first PDF came out at **26.39 x 22.22 in** --
not 9.5 x 8. Cause: `-strip` had removed the density metadata, so ImageMagick
assumed 72 DPI and treated the 1900 x 1600 px page as 1900 x 1600 *points*. Pixel
dimensions alone do not determine PDF page size.
Fix: set density explicitly at PDF-write time with `-units PixelsPerInch -density 200`.
Ghostscript is not installed, so ImageMagick cannot read or rasterize a PDF to
check it visually. Verification was therefore done by parsing the PDF bytes
directly:
```powershell
$txt = [System.Text.Encoding]::Latin1.GetString([IO.File]::ReadAllBytes($out))
([regex]::Matches($txt, '/Type\s*/Page[^s]')).Count # -> 98 page objects
[regex]::Match($txt, '/MediaBox\s*\[([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\]')
```
That confirmed 98 pages at 684 x 576 pt on every page. This byte-level check is
worth keeping -- it caught the page-size bug that a file-exists test would have
missed.
---
## Development environment
A venv is set up in this directory with everything needed to generate PDFs.
Pinned versions live in `requirements.txt`.
```powershell
cd "D:\Python Utilities\Blurb"
python -m venv .venv python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt .\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe main.py
``` ```
Always invoke as `.\.venv\Scripts\python.exe` -- plain `python` will hit the ## Use the editor
system install, which has none of these modules.
| Package | Version | Role | 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.
|---|---|---| 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.
| Pillow | 12.3.0 | Measure images, read EXIF orientation, cover-crop / contain resize | 3. Drag cards to reorder pages. Use **Insert blank**, **Insert photo**, **Remove page**, or **Relink photo** as needed. The source photos are not modified.
| img2pdf | 0.6.3 | Embed JPEG bytes **losslessly**, no re-encode | 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.
| reportlab | 5.0.1 | Page layout: captions, multi-image pages, margins, page numbers | 5. Save the editable `.blurbproj` separately from the exported PDF.
| pymupdf | 1.28.2 | Inspect/render finished PDFs -- page count, MediaBox, page-to-PNG preview |
| lxml | 6.1.3 | transitive (pikepdf) |
| pikepdf | 10.16.0 | transitive (img2pdf) |
| charset-normalizer | 3.5.2 | transitive (reportlab) |
| packaging / setuptools / wheel | pinned | build tooling |
The four direct deps divide the job cleanly: **Pillow** decides *what* each page 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.
looks like, **img2pdf** or **reportlab** decide *how* pages are assembled, and
**pymupdf** verifies the result. pymupdf is not optional-in-practice: it is what
lets the finished PDF be inspected at all on this machine.
### Verified end to end ## Blurb print setup and export
A smoke test confirmed the whole chain works, not just that the imports resolve: 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.
- Pillow read two 4000x3000 source photos and reported EXIF orientation 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.
- Pillow cover-cropped both to 1900x1600
- img2pdf produced a 2-page PDF, verified by pymupdf as **9.50 x 8.00 in**
(page size correct: True) with 1 embedded image on page 1
- reportlab produced a captioned page, verified as 9.50x8.00 in with text
extracting correctly as `'IMG20240724161556.jpg\n1'`
- pymupdf rendered page 1 to PNG; pixel stats confirmed real photo content
(79,800 distinct colors, per-channel stddev ~50) rather than a blank page
That last check matters because this model cannot view images, so "the render 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.
worked" had to be asserted from pixel statistics instead of by eye.
### reportlab 5.x API break Supported photo formats are JPEG, PNG, TIFF, WebP, and BMP. HEIC and camera RAW files are reported as unsupported. Folder import is not recursive.
`canvas.drawImage()` no longer accepts a PIL `Image` object: ## Project files
```python 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**.
with Image.open(path) as im:
c.drawImage(im, ...) # TypeError: expected str, bytes or os.PathLike,
# not JpegImageFile (reportlab 5.0.1)
```
Pass the **path string** instead: ## Code layout
```python - `main.py` starts the application.
c.drawImage(path, x, y, width=..., height=...) - `blurb_editor.py` contains the PySide6 interface, background import and export workers, page list, and photo canvas.
``` - `blurb_book.py` contains project and print-profile data, photo sorting and rendering, and PDF export.
- `requirements.txt` pins the Python dependencies.
Older reportlab tutorials pass image objects and will fail here. Use the project virtual environment when launching or installing dependencies. The PDF exporter uses Pillow for rendering, ReportLab for assembly, and PyMuPDF for output checks.
### Recommended architecture
Two viable routes:
- **img2pdf for one-photo-per-page.** Embed losslessly, so the already-cropped
JPEG is stored verbatim and there is no second generation of compression.
Pillow does the crop; img2pdf just wraps it. Cheapest and highest fidelity.
- **reportlab when you need captions, page numbers, margins, or several images
composited per page.** More control, but images get re-encoded by ReportLab's
own image pipeline -- pick JPEG quality deliberately.
Likely need both: img2pdf for the bulk photo pages, reportlab for pages that
carry text. Worth benchmarking file size and visual fidelity of each before
settling.
---
## Host environment (for the original one-off build)
The 59.3 MB `bbook.pdf` was **not** produced with the venv above. It was built
with the system Python absent and ImageMagick on PATH:
| Tool | Version / state |
|---|---|
| OS | Windows 10 IoT Enterprise LTSC, build 19044 |
| ImageMagick | 7.1.2-31 Q16-HDRI x64 |
| Python | 3.12.0 (at `C:\Python\3.12.0`) |
| Ghostscript | **not installed** -- no PDF read/render via ImageMagick |
| Pillow, reportlab, img2pdf, PyMuPDF | none installed at build time (see venv above) |
Two notes on the ImageMagick install:
- `policy.xml` contains `coder` rules that *appear* to restrict output to
`{GIF,JPEG,PNG,WEBP}`, which would block PDF writing. Empirically PDF writing
works fine (confirmed by test). Don't trust the apparent policy reading --
test the actual operation.
- Its `magick -list policy` output does **not** echo the coder rules, so
`policy.xml` itself is the only place to check.
ImageMagick is no longer needed for the venv route and can be ignored going
forward. It stays useful as a cross-check on output from a different toolchain.
---
## Open questions
1. **Spread semantics.** One 9.5 x 8 in page per photo, or two 4.75 x 8 in pages?
See Step 2. Biggest open item.
2. **The 4 wide/portrait outliers.** Cropped hard (33% / 37%). Review, or special-case
them to fit-inside?
3. **Page labels.** Not implemented -- ImageMagick cannot write per-page titles here.
Page N maps to the Nth photo in chronological order. Affinity may want captions.
4. **Missing zip archives.** Cause of their disappearance is still unknown.
5. **DPI.** 200 was chosen as a print-proofing middle ground. Book production may
want 300, at roughly 2.25x the pixel count.
## Ideas for the real implementation
- **Dependency direction.** The ImageMagick route shells out per image (98 process
spawns). `Pillow` or `img2pdf` would do it in-process and much faster. Prefer a
pure-Python path; keep ImageMagick as a fallback.
- **Never write into the source folder.** Keep a dedicated build dir, as done here.
- **Make geometry a config object**, not literals: page size, DPI, quality, fill
mode (cover vs. contain), and margin should all be parameters.
- **Treat outliers as data.** Compute per-image fill loss during the measure pass
and report anything past a threshold, instead of hardcoding a warning for 4 files.
- **Dry-run mode.** Render pages and emit a manifest (source -> page -> crop
amount) before committing to a large PDF.
- **Verify after write.** Reuse the byte-level page-count / MediaBox check as an
automated assertion so a page-size regression fails loudly.
+573
View File
@@ -0,0 +1,573 @@
"""Photo-book project data, image rendering, and PDF export."""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import asdict, dataclass, field
from datetime import datetime
import json
import math
import os
from pathlib import Path
import re
import tempfile
from typing import Callable, Iterable
from PIL import Image, ImageOps
from reportlab.pdfgen import canvas
PROJECT_VERSION = 1
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp"}
EXPORT_PPI = 300
JPEG_QUALITY = 94
MAX_PAGE_INCHES = 30.0
MAX_RENDER_PIXELS = 50_000_000
_FILENAME_DATE_PATTERNS = (
re.compile(r"PXL_(20\d{6})_(\d{6})", re.IGNORECASE),
re.compile(r"IMG(20\d{6})(\d{6})", re.IGNORECASE),
)
_THUMBNAIL_CACHE: OrderedDict[tuple[str, int], Image.Image] = OrderedDict()
_THUMBNAIL_CACHE_LIMIT = 8
_UNSUPPORTED_PHOTO_EXTENSIONS = {".heic", ".heif", ".avif", ".dng", ".cr2", ".nef", ".arw", ".raw"}
@dataclass
class PhotoPage:
"""One output page; image_path is None for a blank page."""
image_path: str | None = None
zoom: float = 1.0
pan_x: float = 0.0
pan_y: float = 0.0
tagged: bool = False
def to_dict(self) -> dict[str, object]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, object]) -> "PhotoPage":
image_path = data.get("image_path")
zoom = float(data.get("zoom", 1.0))
pan_x = float(data.get("pan_x", 0.0))
pan_y = float(data.get("pan_y", 0.0))
tagged = data.get("tagged", False) is True
if not math.isfinite(zoom) or zoom <= 0:
zoom = 1.0
if not math.isfinite(pan_x):
pan_x = 0.0
if not math.isfinite(pan_y):
pan_y = 0.0
return cls(
image_path=str(image_path) if image_path else None,
zoom=max(0.01, min(zoom, 4.0)),
pan_x=max(-1.0, min(pan_x, 1.0)),
pan_y=max(-1.0, min(pan_y, 1.0)),
tagged=tagged,
)
@dataclass
class PrintProfile:
"""Trim dimensions plus exact final PDF dimensions supplied by Blurb."""
trim_width_in: float = 9.5
trim_height_in: float = 8.0
pdf_width_in: float | None = None
pdf_height_in: float | None = None
trim_binding_in: float = 0.0
trim_outside_in: float = 0.0
trim_top_in: float = 0.0
trim_bottom_in: float = 0.0
min_pages: int = 20
max_pages: int = 440
@property
def ready(self) -> bool:
return self.pdf_width_in is not None and self.pdf_height_in is not None
def validate(self) -> list[str]:
errors: list[str] = []
values = {
"trim width": self.trim_width_in,
"trim height": self.trim_height_in,
"trim binding-edge inset": self.trim_binding_in,
"trim outside-edge inset": self.trim_outside_in,
"trim top inset": self.trim_top_in,
"trim bottom inset": self.trim_bottom_in,
}
if self.pdf_width_in is not None:
values["PDF page width"] = self.pdf_width_in
if self.pdf_height_in is not None:
values["PDF page height"] = self.pdf_height_in
for label, value in values.items():
if not math.isfinite(value) or value < 0:
errors.append(f"{label} must be a non-negative number.")
elif value > MAX_PAGE_INCHES:
errors.append(f"{label} must be no more than {MAX_PAGE_INCHES:g} inches.")
if self.trim_width_in <= 0 or self.trim_height_in <= 0:
errors.append("Trim width and height must be greater than zero.")
if self.min_pages < 1:
errors.append("Minimum page count must be at least one.")
if self.max_pages < self.min_pages:
errors.append("Maximum page count must be at least the minimum page count.")
if self.pdf_width_in is None or self.pdf_height_in is None:
errors.append("Enter the final PDF page width and height from the Blurb specifications.")
else:
if self.pdf_width_in < self.trim_width_in or self.pdf_height_in < self.trim_height_in:
errors.append("The final PDF page cannot be smaller than the trim area.")
if abs(
self.trim_width_in + self.trim_binding_in + self.trim_outside_in - self.pdf_width_in
) > 0.003:
errors.append("PDF width must equal trim width plus binding-edge and outside-edge insets.")
if abs(self.trim_height_in + self.trim_top_in + self.trim_bottom_in - self.pdf_height_in) > 0.003:
errors.append("PDF height must equal trim height plus top and bottom insets.")
return errors
def to_dict(self) -> dict[str, object]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, object] | None) -> "PrintProfile":
if not data:
return cls()
return cls(
trim_width_in=_to_float(data.get("trim_width_in"), 9.5),
trim_height_in=_to_float(data.get("trim_height_in"), 8.0),
pdf_width_in=_optional_float(data.get("pdf_width_in")),
pdf_height_in=_optional_float(data.get("pdf_height_in")),
trim_binding_in=_to_float(
data.get("trim_binding_in", data.get("trim_left_in")), 0.0
),
trim_outside_in=_to_float(
data.get(
"trim_outside_in",
max(
0.0,
_to_float(data.get("pdf_width_in"), 9.5)
- _to_float(data.get("trim_width_in"), 9.5)
- _to_float(data.get("trim_left_in"), 0.0),
),
),
0.0,
),
trim_top_in=_to_float(data.get("trim_top_in"), 0.0),
trim_bottom_in=_to_float(data.get("trim_bottom_in"), 0.0),
min_pages=int(_to_float(data.get("min_pages"), 20)),
max_pages=int(_to_float(data.get("max_pages"), 440)),
)
@dataclass
class BookProject:
photo_paths: list[str] = field(default_factory=list)
pages: list[PhotoPage] = field(default_factory=list)
profile: PrintProfile = field(default_factory=PrintProfile)
source_directory: str | None = None
capture_dates: dict[str, datetime] = field(default_factory=dict, repr=False)
@classmethod
def from_folder(cls, folder: str | Path) -> tuple["BookProject", list[str]]:
photos, issues = scan_photo_folder(folder)
return cls(
photo_paths=[str(path) for path, _ in photos],
pages=[PhotoPage(image_path=str(path)) for path, _ in photos],
source_directory=str(Path(folder).resolve()),
capture_dates={str(path): capture_date for path, capture_date in photos},
), issues
def to_dict(self) -> dict[str, object]:
return {
"version": PROJECT_VERSION,
"source_directory": self.source_directory,
"photo_paths": self.photo_paths,
"pages": [page.to_dict() for page in self.pages],
"print_profile": self.profile.to_dict(),
}
def save(self, path: str | Path) -> None:
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=destination.parent, suffix=".tmp", delete=False
) as stream:
temp_path = stream.name
json.dump(self.to_dict(), stream, indent=2, ensure_ascii=False)
stream.write("\n")
os.replace(temp_path, destination)
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
@classmethod
def load(cls, path: str | Path) -> "BookProject":
source = Path(path)
data = json.loads(source.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("The project file does not contain a project object.")
version = data.get("version", 1)
if version != PROJECT_VERSION:
raise ValueError(f"Project version {version} is not supported.")
photo_paths = data.get("photo_paths", [])
pages = data.get("pages", [])
profile_data = data.get("print_profile")
if not isinstance(photo_paths, list) or not isinstance(pages, list):
raise ValueError("The project file has an invalid photo or page list.")
if profile_data is not None and not isinstance(profile_data, dict):
raise ValueError("The project file has invalid print settings.")
return cls(
photo_paths=[str(path_value) for path_value in photo_paths if path_value],
pages=[PhotoPage.from_dict(page) for page in pages if isinstance(page, dict)],
profile=PrintProfile.from_dict(profile_data),
source_directory=(
str(data["source_directory"]) if data.get("source_directory") else None
),
)
def _to_float(value: object, default: float) -> float:
try:
number = float(value)
return number if math.isfinite(number) else default
except (TypeError, ValueError):
return default
def _optional_float(value: object) -> float | None:
if value is None or value == "":
return None
return _to_float(value, 0.0)
def filename_capture_date(path: str | Path) -> datetime | None:
name = Path(path).name
for pattern in _FILENAME_DATE_PATTERNS:
match = pattern.search(name)
if not match:
continue
date_text, time_text = match.groups()
try:
return datetime.strptime(date_text + time_text, "%Y%m%d%H%M%S")
except ValueError:
return None
return None
def exif_capture_date(path: str | Path) -> datetime | None:
try:
with Image.open(path) as image:
exif = image.getexif()
exif_ifd = exif.get_ifd(0x8769) if 0x8769 in exif else {}
candidates: Iterable[object] = (
exif.get(36867),
exif_ifd.get(36867),
exif.get(36868),
exif.get(306),
)
for value in candidates:
if isinstance(value, bytes):
value = value.decode("ascii", errors="ignore")
if not isinstance(value, str):
continue
try:
return datetime.strptime(value.strip(), "%Y:%m:%d %H:%M:%S")
except ValueError:
continue
except (OSError, ValueError, TypeError, KeyError):
return None
return None
def photo_sort_date(path: str | Path) -> datetime:
by_name = filename_capture_date(path)
if by_name:
return by_name
by_exif = exif_capture_date(path)
if by_exif:
return by_exif
try:
return datetime.fromtimestamp(Path(path).stat().st_ctime)
except OSError:
return datetime.max
def scan_photo_folder(folder: str | Path) -> tuple[list[tuple[Path, datetime]], list[str]]:
directory = Path(folder).expanduser().resolve()
if not directory.is_dir():
raise NotADirectoryError(f"Not a folder: {directory}")
photos: list[tuple[Path, datetime]] = []
issues: list[str] = []
unsupported: list[str] = []
for entry in directory.iterdir():
if not entry.is_file():
continue
if entry.suffix.lower() in _UNSUPPORTED_PHOTO_EXTENSIONS:
unsupported.append(entry.name)
continue
if entry.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
try:
with Image.open(entry) as image:
image.verify()
photos.append((entry.resolve(), photo_sort_date(entry)))
except (OSError, ValueError, Image.DecompressionBombError) as exc:
issues.append(f"{entry.name}: could not read image ({exc})")
photos.sort(key=lambda pair: (pair[1], pair[0].name.casefold()))
if unsupported:
names = ", ".join(unsupported[:10])
if len(unsupported) > 10:
names += f", and {len(unsupported) - 10} more"
issues.append(f"Skipped unsupported HEIC/RAW photo format(s): {names}")
return photos, issues
def _open_oriented_rgb(path: str | Path) -> Image.Image:
with Image.open(path) as source:
image = ImageOps.exif_transpose(source)
if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
rgba = image.convert("RGBA")
background = Image.new("RGBA", rgba.size, "white")
background.alpha_composite(rgba)
return background.convert("RGB")
return image.convert("RGB")
def _cover_geometry(
source_size: tuple[int, int],
page_size: tuple[int, int],
zoom: float,
pan_x: float,
pan_y: float,
) -> tuple[float, float, float, float, float]:
source_w, source_h = source_size
page_w, page_h = page_size
cover_scale = max(page_w / source_w, page_h / source_h)
contain_scale = min(page_w / source_w, page_h / source_h)
min_zoom = contain_scale / cover_scale
effective_zoom = max(min_zoom, min(float(zoom), 4.0))
scale = cover_scale * effective_zoom
image_w, image_h = source_w * scale, source_h * scale
pan_x = max(-1.0, min(float(pan_x), 1.0))
pan_y = max(-1.0, min(float(pan_y), 1.0))
# Negative room means the photo is cropped; positive room means white space.
x_room = (page_w - image_w) / 2.0
y_room = (page_h - image_h) / 2.0
image_x = x_room * (1.0 - pan_x)
image_y = y_room * (1.0 - pan_y)
return image_x, image_y, image_w, image_h, min_zoom
def page_zoom_bounds(path: str | Path, page_aspect: float) -> tuple[float, float]:
with Image.open(path) as source:
image = ImageOps.exif_transpose(source)
source_w, source_h = image.size
page_w, page_h = (page_aspect, 1.0) if page_aspect >= 1 else (1.0, 1.0 / page_aspect)
cover_scale = max(page_w / source_w, page_h / source_h)
contain_scale = min(page_w / source_w, page_h / source_h)
return contain_scale / cover_scale, 4.0
def pan_for_centered_zoom(
source_size: tuple[int, int],
page_size: tuple[int, int],
old_zoom: float,
new_zoom: float,
pan_x: float,
pan_y: float,
) -> tuple[float, float]:
"""Keep the source point at the page center fixed while changing zoom."""
old_x, old_y, old_w, old_h, _ = _cover_geometry(
source_size, page_size, old_zoom, pan_x, pan_y
)
_, _, new_w, new_h, _ = _cover_geometry(source_size, page_size, new_zoom, 0.0, 0.0)
old_scale_x = old_w / source_size[0]
old_scale_y = old_h / source_size[1]
new_scale_x = new_w / source_size[0]
new_scale_y = new_h / source_size[1]
source_center_x = (page_size[0] / 2 - old_x) / old_scale_x
source_center_y = (page_size[1] / 2 - old_y) / old_scale_y
new_room_x = (page_size[0] - new_w) / 2
new_room_y = (page_size[1] - new_h) / 2
new_x = page_size[0] / 2 - source_center_x * new_scale_x
new_y = page_size[1] / 2 - source_center_y * new_scale_y
next_pan_x = 0.0 if abs(new_room_x) < 1e-9 else 1.0 - new_x / new_room_x
next_pan_y = 0.0 if abs(new_room_y) < 1e-9 else 1.0 - new_y / new_room_y
return max(-1.0, min(next_pan_x, 1.0)), max(-1.0, min(next_pan_y, 1.0))
def render_photo(
path: str | Path,
page_size_px: tuple[int, int],
zoom: float = 1.0,
pan_x: float = 0.0,
pan_y: float = 0.0,
*,
preview: bool = False,
) -> Image.Image:
"""Render one cropped photo on a white page-sized RGB image."""
page_w, page_h = page_size_px
if page_w <= 0 or page_h <= 0:
raise ValueError("Page preview dimensions must be positive.")
if preview:
key = (str(Path(path).resolve()), 1800)
cached = _THUMBNAIL_CACHE.get(key)
if cached is None:
full = _open_oriented_rgb(path)
full.thumbnail((1800, 1800), Image.Resampling.LANCZOS)
cached = full.copy()
_THUMBNAIL_CACHE[key] = cached
_THUMBNAIL_CACHE.move_to_end(key)
while len(_THUMBNAIL_CACHE) > _THUMBNAIL_CACHE_LIMIT:
_THUMBNAIL_CACHE.popitem(last=False)
else:
_THUMBNAIL_CACHE.move_to_end(key)
image = cached.copy()
else:
image = _open_oriented_rgb(path)
x, y, image_w, image_h, _ = _cover_geometry(image.size, page_size_px, zoom, pan_x, pan_y)
page = Image.new("RGB", page_size_px, "white")
# Crop in source coordinates before scaling so a heavily zoomed preview/export
# never allocates a huge off-page intermediate image.
scale = image_w / image.width
visible_left = max(0.0, -x / scale)
visible_top = max(0.0, -y / scale)
visible_right = min(float(image.width), (page_w - x) / scale)
visible_bottom = min(float(image.height), (page_h - y) / scale)
crop_box = (
max(0, math.floor(visible_left)),
max(0, math.floor(visible_top)),
min(image.width, math.ceil(visible_right)),
min(image.height, math.ceil(visible_bottom)),
)
if crop_box[2] > crop_box[0] and crop_box[3] > crop_box[1]:
visible = image.crop(crop_box)
dest_left = max(0, round(x + crop_box[0] * scale))
dest_top = max(0, round(y + crop_box[1] * scale))
dest_right = min(page_w, round(x + crop_box[2] * scale))
dest_bottom = min(page_h, round(y + crop_box[3] * scale))
dest_size = (max(1, dest_right - dest_left), max(1, dest_bottom - dest_top))
visible = visible.resize(dest_size, Image.Resampling.LANCZOS)
page.paste(visible, (dest_left, dest_top))
visible.close()
image.close()
return page
def print_page_size_pixels(profile: PrintProfile, ppi: int = EXPORT_PPI) -> tuple[int, int]:
if not profile.ready:
raise ValueError("Set the final PDF page dimensions before export.")
width = max(1, round(float(profile.pdf_width_in) * ppi))
height = max(1, round(float(profile.pdf_height_in) * ppi))
pixels = width * height
if pixels > MAX_RENDER_PIXELS:
raise ValueError(
f"This page would render to {pixels:,} pixels at {ppi} PPI. "
"Reduce the page size or use a smaller source folder."
)
return width, height
def _verify_pdf(path: Path, expected_images: list[bool], profile: PrintProfile) -> None:
import pymupdf
document = pymupdf.open(path)
try:
if len(document) != len(expected_images):
raise ValueError(
f"PDF verification found {len(document)} pages, expected {len(expected_images)}."
)
expected_width = float(profile.pdf_width_in) * 72
expected_height = float(profile.pdf_height_in) * 72
for index, page in enumerate(document):
rect = page.rect
if abs(rect.width - expected_width) > 0.15 or abs(rect.height - expected_height) > 0.15:
raise ValueError(f"PDF page {index + 1} does not match the configured page size.")
has_images = bool(page.get_images(full=True))
if has_images != expected_images[index]:
raise ValueError(f"PDF page {index + 1} does not match its expected photo/blank content.")
finally:
document.close()
def export_pdf(
project: BookProject,
output_path: str | Path,
progress: Callable[[int, int], None] | None = None,
) -> Path:
"""Create individual, bleed-sized PDF pages with print-resolution imagery."""
if not project.pages:
raise ValueError("Add at least one page before exporting.")
errors = project.profile.validate()
if errors:
raise ValueError("\n".join(errors))
if len(project.pages) % 2:
raise ValueError("Blurb requires an even page count. Add a blank page and try again.")
if len(project.pages) < project.profile.min_pages:
raise ValueError(
f"This Blurb format requires at least {project.profile.min_pages} pages."
)
if len(project.pages) > project.profile.max_pages:
raise ValueError(
f"This Blurb format allows at most {project.profile.max_pages} pages."
)
page_width_px, page_height_px = print_page_size_pixels(project.profile)
destination = Path(output_path).expanduser().resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
temp_pdf: str | None = None
try:
with tempfile.TemporaryDirectory(prefix="blurb-render-") as render_directory:
render_root = Path(render_directory)
with tempfile.NamedTemporaryFile(
suffix=".pdf", prefix=f".{destination.stem}-", dir=destination.parent, delete=False
) as stream:
temp_pdf = stream.name
pdf = canvas.Canvas(
temp_pdf,
pagesize=(float(project.profile.pdf_width_in) * 72, float(project.profile.pdf_height_in) * 72),
pageCompression=1,
)
pdf.setTitle(destination.stem)
pdf.setCreator("Blurb Photo Book Editor")
total = len(project.pages)
for index, page in enumerate(project.pages):
if page.image_path:
image_path = Path(page.image_path)
if not image_path.is_file():
raise FileNotFoundError(f"Photo is missing: {image_path}")
page_image = render_photo(
image_path,
(page_width_px, page_height_px),
page.zoom,
page.pan_x,
page.pan_y,
)
rendered_path = render_root / f"page_{index + 1:05d}.jpg"
page_image.save(rendered_path, format="JPEG", quality=JPEG_QUALITY, optimize=True)
page_image.close()
pdf.drawImage(
str(rendered_path),
0,
0,
width=float(project.profile.pdf_width_in) * 72,
height=float(project.profile.pdf_height_in) * 72,
)
pdf.showPage()
if progress:
progress(index + 1, total)
pdf.save()
_verify_pdf(
Path(temp_pdf),
[bool(page.image_path) for page in project.pages],
project.profile,
)
os.replace(temp_pdf, destination)
temp_pdf = None
return destination
finally:
if temp_pdf and os.path.exists(temp_pdf):
os.unlink(temp_pdf)
+1359
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
"""Launch the Blurb Photo Book Editor."""
from __future__ import annotations
def main() -> int:
try:
from blurb_editor import run
except ImportError as exc:
if exc.name and exc.name.startswith("PySide6"):
raise SystemExit(
"PySide6 is not installed in this Python environment. "
"Run .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt first."
) from exc
raise
return run()
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -8,6 +8,9 @@
# --- Direct dependencies --- # --- Direct dependencies ---
# Windows desktop editor UI (official Qt for Python bindings).
pyside6==6.11.0
# Measure images, read EXIF orientation, do the cover-crop/contain resize. # Measure images, read EXIF orientation, do the cover-crop/contain resize.
pillow==12.3.0 pillow==12.3.0
@@ -35,4 +38,4 @@ charset-normalizer==3.5.2
# build tooling # build tooling
packaging==26.3 packaging==26.3
setuptools==84.0.0 setuptools==84.0.0
wheel==0.48.0 wheel==0.48.0