diff --git a/.gitignore b/.gitignore index 21d0b89..f0ccc32 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .venv/ +__pycache__/ +*.py[cod] diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..5b9a0ff --- /dev/null +++ b/AGENT.md @@ -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. diff --git a/README.md b/README.md index 1e6dd37..644143c 100644 --- a/README.md +++ b/README.md @@ -1,309 +1,46 @@ -# Blurb +# Blurb Photo Book Editor -Build a print-ready PDF from a folder of photos, sized for an Affinity Publisher -spread (9.5 x 8 in). +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. -**Status: starter / provenance document.** The PDF described here was produced by -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. +## Install and launch ---- - -## 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__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: +From PowerShell in this directory: ```powershell -magick -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 .\.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 -system install, which has none of these modules. +## Use the editor -| Package | Version | Role | -|---|---|---| -| Pillow | 12.3.0 | Measure images, read EXIF orientation, cover-crop / contain resize | -| img2pdf | 0.6.3 | Embed JPEG bytes **losslessly**, no re-encode | -| reportlab | 5.0.1 | Page layout: captions, multi-image pages, margins, page numbers | -| 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 | +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. +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. +5. Save the editable `.blurbproj` separately from the exported PDF. -The four direct deps divide the job cleanly: **Pillow** decides *what* each page -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. +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. -### 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 -- 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 +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. -That last check matters because this model cannot view images, so "the render -worked" had to be asserted from pixel statistics instead of by eye. +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. -### 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 -with Image.open(path) as im: - c.drawImage(im, ...) # TypeError: expected str, bytes or os.PathLike, - # not JpegImageFile (reportlab 5.0.1) -``` +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**. -Pass the **path string** instead: +## Code layout -```python -c.drawImage(path, x, y, width=..., height=...) -``` +- `main.py` starts the application. +- `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. - -### 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. \ No newline at end of file +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. diff --git a/blurb_book.py b/blurb_book.py new file mode 100644 index 0000000..7a74fd7 --- /dev/null +++ b/blurb_book.py @@ -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) diff --git a/blurb_editor.py b/blurb_editor.py new file mode 100644 index 0000000..f7de9ad --- /dev/null +++ b/blurb_editor.py @@ -0,0 +1,1359 @@ +"""PySide6 desktop interface for assembling photo pages and exporting a PDF.""" + +from __future__ import annotations + +import copy +import json +from datetime import datetime +from pathlib import Path +import sys + +from PIL import Image, ImageOps +from PySide6.QtCore import QThread, Qt, Signal, QSize, QRectF +from PySide6.QtGui import QAction, QActionGroup, QColor, QFont, QImage, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QMainWindow, + QMessageBox, + QMenu, + QProgressBar, + QProgressDialog, + QPushButton, + QSlider, + QSpinBox, + QSplitter, + QToolBar, + QToolButton, + QVBoxLayout, + QWidget, + QListWidget, + QListWidgetItem, + QLineEdit, + QStatusBar, +) + +from blurb_book import ( + BookProject, + PhotoPage, + PrintProfile, + SUPPORTED_EXTENSIONS, + export_pdf, + pan_for_centered_zoom, + page_zoom_bounds, + photo_sort_date, + render_photo, +) + + +def _pil_to_qimage(image: Image.Image) -> QImage: + rgb = image.convert("RGB") + data = rgb.tobytes("raw", "RGB") + return QImage(data, rgb.width, rgb.height, rgb.width * 3, QImage.Format.Format_RGB888).copy() + + +def _page_dimensions(profile: PrintProfile) -> tuple[float, float]: + return ( + profile.pdf_width_in or profile.trim_width_in, + profile.pdf_height_in or profile.trim_height_in, + ) + + +def _photo_size(path: str) -> tuple[int, int]: + with Image.open(path) as source: + image = ImageOps.exif_transpose(source) + return image.size + + +class PageListWidget(QListWidget): + orderChanged = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.setDragDropOverwriteMode(False) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.setWordWrap(True) + self.setSpacing(5) + self.setIconSize(QSize(150, 126)) + self.setMinimumWidth(330) + self.setMaximumWidth(390) + + def dropEvent(self, event) -> None: # noqa: N802 - Qt event name + super().dropEvent(event) + self.orderChanged.emit() + + +class PageCanvas(QWidget): + changed = Signal() + editFinished = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setMinimumSize(520, 420) + self.setMouseTracking(True) + self.setCursor(Qt.CursorShape.ArrowCursor) + self.page: PhotoPage | None = None + self.profile = PrintProfile() + self.page_index = 0 + self.page_count = 0 + self._preview: QImage | None = None + self._error: str | None = None + self._page_rect = QRectF() + self._drag_pos = None + self._drag_start_pan = (0.0, 0.0) + self._source_size: tuple[int, int] | None = None + + def set_page(self, page: PhotoPage | None, profile: PrintProfile, index: int, count: int) -> None: + self.page = page + self.profile = profile + self.page_index = index + self.page_count = count + self._source_size = None + self._render_preview() + + def set_profile(self, profile: PrintProfile) -> None: + self.profile = profile + self._render_preview() + + def _render_preview(self) -> None: + self._preview = None + self._error = None + if self.page and self.page.image_path: + try: + page_w, page_h = _page_dimensions(self.profile) + aspect = page_w / page_h + if self.width() > 0 and self.height() > 0: + available_w = max(100, self.width() - 50) + available_h = max(100, self.height() - 74) + if available_w / available_h > aspect: + preview_h = available_h + preview_w = round(preview_h * aspect) + else: + preview_w = available_w + preview_h = round(preview_w / aspect) + preview_w, preview_h = min(preview_w, 1800), min(preview_h, 1800) + rendered = render_photo( + self.page.image_path, + (max(1, preview_w), max(1, preview_h)), + self.page.zoom, + self.page.pan_x, + self.page.pan_y, + preview=True, + ) + self._preview = _pil_to_qimage(rendered) + rendered.close() + if self._source_size is None: + self._source_size = _photo_size(self.page.image_path) + except (OSError, ValueError) as exc: + self._error = f"Photo could not be opened:\n{exc}" + self.update() + + def resizeEvent(self, event) -> None: # noqa: N802 - Qt event name + super().resizeEvent(event) + self._render_preview() + + def _fit_page_rect(self) -> QRectF: + page_w, page_h = _page_dimensions(self.profile) + aspect = page_w / page_h + usable_w = max(100.0, self.width() - 48.0) + usable_h = max(100.0, self.height() - 72.0) + if usable_w / usable_h > aspect: + height = usable_h + width = height * aspect + else: + width = usable_w + height = width / aspect + return QRectF((self.width() - width) / 2, (self.height() - height) / 2 + 9, width, height) + + def paintEvent(self, event) -> None: # noqa: N802 - Qt event name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.fillRect(self.rect(), QColor("#252932")) + self._page_rect = self._fit_page_rect() + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor("#111319")) + shadow = self._page_rect.adjusted(5, 5, 5, 5) + painter.drawRoundedRect(shadow, 2, 2) + painter.fillRect(self._page_rect, QColor("white")) + if self._preview is not None: + painter.drawImage(self._page_rect, self._preview) + elif self.page and self.page.image_path: + painter.setPen(QColor("#a5aab3")) + painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, self._error or "Loading photo…") + else: + painter.setPen(QColor("#7a808b")) + painter.drawText(self._page_rect, Qt.AlignmentFlag.AlignCenter, "Blank page") + + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor("#e4e6eb"), 1)) + painter.drawRect(self._page_rect) + profile = self.profile + pdf_w, pdf_h = _page_dimensions(profile) + # PDF page 1 is right-hand. Its binding edge is the left edge; on + # alternating left-hand pages, the binding is on the right. + trim_left_in = ( + profile.trim_binding_in if self.page_index % 2 == 0 else profile.trim_outside_in + ) + trim_x = self._page_rect.left() + self._page_rect.width() * trim_left_in / pdf_w + trim_y = self._page_rect.top() + self._page_rect.height() * ( + 1.0 - (profile.trim_bottom_in + profile.trim_height_in) / pdf_h + ) + trim_w = self._page_rect.width() * profile.trim_width_in / pdf_w + trim_h = self._page_rect.height() * profile.trim_height_in / pdf_h + trim_rect = QRectF(trim_x, trim_y, trim_w, trim_h) + # If trim size equals the displayed page, the guide lands on top of the + # page outline. Move it just inside the edge so the dashed guide stays + # visible even before bleed dimensions are configured. + trim_rect = trim_rect.adjusted( + 2 if abs(trim_rect.left() - self._page_rect.left()) < 2 else 0, + 2 if abs(trim_rect.top() - self._page_rect.top()) < 2 else 0, + -2 if abs(trim_rect.right() - self._page_rect.right()) < 2 else 0, + -2 if abs(trim_rect.bottom() - self._page_rect.bottom()) < 2 else 0, + ) + painter.setPen(QPen(QColor("#ffc94f"), 2, Qt.PenStyle.DashLine)) + painter.drawRect(trim_rect) + painter.setPen(QColor("#d1d5dc")) + painter.drawText( + QRectF(0, 10, self.width(), 26), + Qt.AlignmentFlag.AlignCenter, + f"Page {self.page_index + 1} of {self.page_count} · dashed line: trim area", + ) + painter.end() + + def mousePressEvent(self, event) -> None: # noqa: N802 - Qt event name + if ( + event.button() == Qt.MouseButton.LeftButton + and self.page + and self.page.image_path + and self._preview is not None + and self._page_rect.contains(event.position()) + ): + self._drag_pos = event.position() + self._drag_start_pan = (self.page.pan_x, self.page.pan_y) + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt event name + if self._drag_pos is not None and self.page and self._source_size: + point = event.position() + dx = point.x() - self._drag_pos.x() + dy = point.y() - self._drag_pos.y() + relative_x = self._page_rect.width() / max(1, self._preview.width()) + relative_y = self._page_rect.height() / max(1, self._preview.height()) + image_x, image_y, image_w, image_h, _ = _cover_geometry_for_ui( + self._source_size, + (self._preview.width(), self._preview.height()), + self.page.zoom, + self._drag_start_pan[0], + self._drag_start_pan[1], + ) + denom_x = (image_w - self._preview.width()) / 2 + denom_y = (image_h - self._preview.height()) / 2 + pan_x = self._drag_start_pan[0] + (dx / relative_x / denom_x if abs(denom_x) > 1e-6 else 0) + pan_y = self._drag_start_pan[1] + (dy / relative_y / denom_y if abs(denom_y) > 1e-6 else 0) + self.page.pan_x = max(-1.0, min(pan_x, 1.0)) + self.page.pan_y = max(-1.0, min(pan_y, 1.0)) + self._render_preview() + self.changed.emit() + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt event name + if event.button() == Qt.MouseButton.LeftButton and self._drag_pos is not None: + self._drag_pos = None + self.setCursor(Qt.CursorShape.ArrowCursor) + self.editFinished.emit() + event.accept() + return + super().mouseReleaseEvent(event) + + +def _cover_geometry_for_ui(source_size, page_size, zoom, pan_x, pan_y): + source_w, source_h = source_size + page_w, page_h = page_size + cover = max(page_w / source_w, page_h / source_h) + contain = min(page_w / source_w, page_h / source_h) + scale = cover * max(contain / cover, min(zoom, 4.0)) + image_w, image_h = source_w * scale, source_h * scale + room_x = (page_w - image_w) / 2 + room_y = (page_h - image_h) / 2 + image_x = room_x * (1 - max(-1, min(pan_x, 1))) + image_y = room_y * (1 - max(-1, min(pan_y, 1))) + return image_x, image_y, image_w, image_h, contain / cover + + +class PrintSetupDialog(QDialog): + def __init__(self, profile: PrintProfile, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Blurb print setup") + self.setMinimumWidth(480) + self._profile = copy.deepcopy(profile) + + description = QLabel( + "Trim size is the finished page area. Enter the final exported PDF page dimensions " + "and trim offsets from the Blurb specification for your selected format, paper, " + "and cover. PDF pages include the bleed area. Page limits also vary by format and paper." + ) + description.setWordWrap(True) + + self.trim_width = self._spin(profile.trim_width_in, 0.1, 30, " in") + self.trim_height = self._spin(profile.trim_height_in, 0.1, 30, " in") + self.pdf_width = self._line(profile.pdf_width_in) + self.pdf_height = self._line(profile.pdf_height_in) + self.trim_binding = self._spin(profile.trim_binding_in, 0, 30, " in") + self.trim_outside = self._spin(profile.trim_outside_in, 0, 30, " in") + self.trim_top = self._spin(profile.trim_top_in, 0, 30, " in") + self.trim_bottom = self._spin(profile.trim_bottom_in, 0, 30, " in") + self.min_pages = QSpinBox() + self.min_pages.setRange(1, 2000) + self.min_pages.setValue(profile.min_pages) + self.max_pages = QSpinBox() + self.max_pages.setRange(1, 2000) + self.max_pages.setValue(profile.max_pages) + + form = QFormLayout() + form.addRow("Trim width", self.trim_width) + form.addRow("Trim height", self.trim_height) + form.addRow("Final PDF page width", self.pdf_width) + form.addRow("Final PDF page height", self.pdf_height) + form.addRow("Trim inset from binding edge", self.trim_binding) + form.addRow("Trim inset from outside edge", self.trim_outside) + form.addRow("Trim inset from top edge", self.trim_top) + form.addRow("Trim inset from bottom edge", self.trim_bottom) + form.addRow("Minimum pages", self.min_pages) + form.addRow("Maximum pages for selected format", self.max_pages) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self._try_accept) + buttons.rejected.connect(self.reject) + layout = QVBoxLayout(self) + layout.addWidget(description) + layout.addLayout(form) + layout.addWidget(buttons) + + @staticmethod + def _spin(value: float, minimum: float, maximum: float, suffix: str) -> QDoubleSpinBox: + spin = QDoubleSpinBox() + spin.setRange(minimum, maximum) + spin.setDecimals(3) + spin.setSingleStep(0.125) + spin.setSuffix(suffix) + spin.setValue(value) + return spin + + @staticmethod + def _line(value: float | None) -> QLineEdit: + line = QLineEdit("" if value is None else f"{value:g}") + line.setPlaceholderText("Required, including bleed") + return line + + def _try_accept(self) -> None: + try: + pdf_width = float(self.pdf_width.text()) + pdf_height = float(self.pdf_height.text()) + except ValueError: + QMessageBox.warning(self, "Print setup", "Enter numeric final PDF page width and height.") + return + self._profile = PrintProfile( + trim_width_in=self.trim_width.value(), + trim_height_in=self.trim_height.value(), + pdf_width_in=pdf_width, + pdf_height_in=pdf_height, + trim_binding_in=self.trim_binding.value(), + trim_outside_in=self.trim_outside.value(), + trim_top_in=self.trim_top.value(), + trim_bottom_in=self.trim_bottom.value(), + min_pages=self.min_pages.value(), + max_pages=self.max_pages.value(), + ) + errors = self._profile.validate() + if errors: + QMessageBox.warning(self, "Print setup", "\n".join(errors)) + return + self.accept() + + @property + def profile(self) -> PrintProfile: + return self._profile + + +class ExportWorker(QThread): + progressChanged = Signal(int, int) + succeeded = Signal(str) + failed = Signal(str) + + def __init__(self, project: BookProject, output_path: str) -> None: + super().__init__() + self.project = project + self.output_path = output_path + + def run(self) -> None: + try: + result = export_pdf( + self.project, + self.output_path, + progress=lambda done, total: self.progressChanged.emit(done, total), + ) + self.succeeded.emit(str(result)) + except Exception as exc: # The error is shown in the app so export failures are actionable. + self.failed.emit(str(exc)) + + +class FolderImportWorker(QThread): + stageChanged = Signal(str, int) + progressChanged = Signal(int, int) + completed = Signal(object, object, object, object) + failed = Signal(str) + + def __init__(self, folder: str) -> None: + super().__init__() + self.folder = folder + + def run(self) -> None: + try: + project, issues = BookProject.from_folder(self.folder) + total = len(project.pages) + self.stageChanged.emit("Preparing photo previews…", total) + photo_dates: dict[str, str] = {} + thumbnails: dict[int, QImage] = {} + page_width, page_height = _page_dimensions(project.profile) + thumbnail_width = 150 + thumbnail_height = max(1, round(thumbnail_width * page_height / page_width)) + + for index, page in enumerate(project.pages, start=1): + capture_date = project.capture_dates.get(page.image_path or "") + if capture_date is not None and capture_date != datetime.max and page.image_path: + photo_dates[page.image_path] = capture_date.strftime("%m/%d/%Y") + try: + preview = render_photo( + page.image_path or "", + (thumbnail_width, thumbnail_height), + page.zoom, + page.pan_x, + page.pan_y, + preview=True, + ) + try: + thumbnails[id(page)] = _pil_to_qimage(preview) + finally: + preview.close() + except Exception: + placeholder = Image.new( + "RGB", (thumbnail_width, thumbnail_height), "#e8c7c7" + ) + thumbnails[id(page)] = _pil_to_qimage(placeholder) + placeholder.close() + self.progressChanged.emit(index, total) + + if project.pages and project.pages[0].image_path: + first_page = project.pages[0] + try: + first_preview = render_photo( + first_page.image_path, + (thumbnail_width, thumbnail_height), + first_page.zoom, + first_page.pan_x, + first_page.pan_y, + preview=True, + ) + first_preview.close() + except (OSError, ValueError): + pass + + self.completed.emit(project, issues, photo_dates, thumbnails) + except Exception as exc: + self.failed.emit(str(exc)) + + +class PhotoBookWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Blurb Photo Book Editor") + self.resize(1480, 920) + self.project = BookProject() + self.project_path: str | None = None + self._photo_date_cache: dict[str, str] = {} + self._photo_thumbnail_cache: dict[int, QPixmap] = {} + self.view_mode = "all" + self.dirty = False + self.current_page_index = -1 + self.worker: ExportWorker | None = None + self.folder_import_worker: FolderImportWorker | None = None + self.import_progress_dialog: QProgressDialog | None = None + self._pending_import_issues: list[str] = [] + self._pending_import_error: str | None = None + self._pending_empty_import = False + + self.pages_list = PageListWidget() + self.pages_list.currentRowChanged.connect(self._selection_changed) + self.pages_list.orderChanged.connect(self._order_changed) + self.pages_list.customContextMenuRequested.connect(self._show_page_context_menu) + + self.canvas = PageCanvas() + self.canvas.changed.connect(self._mark_dirty) + self.canvas.editFinished.connect(self._selected_thumbnail_changed) + + self.zoom_slider = QSlider(Qt.Orientation.Horizontal) + self.zoom_slider.setRange(0, 100) + self.zoom_slider.valueChanged.connect(self._zoom_changed) + self.zoom_label = QLabel("100% of cover") + self.zoom_out = QToolButton() + self.zoom_out.setText("−") + self.zoom_out.setToolTip("Zoom out") + self.zoom_out.clicked.connect(lambda: self._step_zoom(1 / 1.12)) + self.zoom_in = QToolButton() + self.zoom_in.setText("+") + self.zoom_in.setToolTip("Zoom in") + self.zoom_in.clicked.connect(lambda: self._step_zoom(1.12)) + + self.insert_blank_button = QPushButton("Insert blank") + self.insert_blank_button.clicked.connect(self._insert_blank_page) + self.insert_photo_button = QPushButton("Insert photo") + self.insert_photo_button.clicked.connect(self._insert_photo_page) + self.remove_button = QPushButton("Remove page") + self.remove_button.clicked.connect(self._remove_page) + self.relink_button = QPushButton("Relink photo…") + self.relink_button.clicked.connect(self._relink_photo) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(10, 10, 10, 10) + self.pages_heading = QLabel("PAGES · drag to reorder") + left_layout.addWidget(self.pages_heading) + left_layout.addWidget(self.pages_list, 1) + page_controls = QHBoxLayout() + page_controls.addWidget(self.insert_blank_button) + page_controls.addWidget(self.insert_photo_button) + left_layout.addLayout(page_controls) + page_controls2 = QHBoxLayout() + page_controls2.addWidget(self.remove_button) + page_controls2.addWidget(self.relink_button) + left_layout.addLayout(page_controls2) + + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(12, 10, 12, 10) + right_layout.addWidget(self.canvas, 1) + zoom_controls = QHBoxLayout() + zoom_controls.addStretch(1) + zoom_controls.addWidget(QLabel("Zoom")) + zoom_controls.addWidget(self.zoom_out) + zoom_controls.addWidget(self.zoom_slider, 1) + zoom_controls.addWidget(self.zoom_in) + zoom_controls.addWidget(self.zoom_label) + zoom_controls.addStretch(1) + right_layout.addLayout(zoom_controls) + hint = QLabel("Drag inside the page to reposition the photo. Photos start cropped to fill the page.") + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setStyleSheet("color: #aab1bd; padding: 3px;") + right_layout.addWidget(hint) + + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left) + splitter.addWidget(right) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([360, 1120]) + self.setCentralWidget(splitter) + self._create_actions() + self._create_status() + self._update_actions() + self._set_dirty(False) + self._refresh_canvas() + + def _create_actions(self) -> None: + file_menu = self.menuBar().addMenu("&File") + self.open_folder_action = QAction("Import photo folder…", self) + self.open_folder_action.triggered.connect(self._import_folder) + file_menu.addAction(self.open_folder_action) + self.open_project_action = QAction("Open project…", self) + self.open_project_action.triggered.connect(self._open_project) + file_menu.addAction(self.open_project_action) + self.save_action = QAction("Save project", self) + self.save_action.setShortcut("Ctrl+S") + self.save_action.triggered.connect(self._save_project) + file_menu.addAction(self.save_action) + self.save_as_action = QAction("Save project as…", self) + self.save_as_action.setShortcut("Ctrl+Shift+S") + self.save_as_action.triggered.connect(self._save_project_as) + file_menu.addAction(self.save_as_action) + file_menu.addSeparator() + self.print_setup_action = QAction("Blurb print setup…", self) + self.print_setup_action.triggered.connect(self._edit_print_setup) + file_menu.addAction(self.print_setup_action) + self.export_action = QAction("Export PDF…", self) + self.export_action.setShortcut("Ctrl+E") + self.export_action.triggered.connect(self._export_pdf) + file_menu.addAction(self.export_action) + file_menu.addSeparator() + exit_action = QAction("Exit", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + view_menu = self.menuBar().addMenu("&View") + self.view_action_group = QActionGroup(self) + self.view_action_group.setExclusive(True) + self.view_all_action = QAction("View All", self) + self.view_all_action.setCheckable(True) + self.view_all_action.setChecked(True) + self.view_all_action.triggered.connect(lambda checked=False: self._set_view_mode("all")) + self.view_action_group.addAction(self.view_all_action) + view_menu.addAction(self.view_all_action) + self.view_tagged_action = QAction("View Tagged", self) + self.view_tagged_action.setCheckable(True) + self.view_tagged_action.triggered.connect(lambda checked=False: self._set_view_mode("tagged")) + self.view_action_group.addAction(self.view_tagged_action) + view_menu.addAction(self.view_tagged_action) + + toolbar = QToolBar("Main") + toolbar.setMovable(False) + self.addToolBar(toolbar) + for action in ( + self.open_folder_action, + self.open_project_action, + self.save_action, + self.print_setup_action, + self.export_action, + ): + toolbar.addAction(action) + self.progress = QProgressBar() + self.progress.setMaximumWidth(230) + self.progress.setFixedHeight(18) + self.progress.setVisible(False) + toolbar.addWidget(self.progress) + + def _create_status(self) -> None: + status = QStatusBar() + self.setStatusBar(status) + self.status_label = QLabel("Import a folder of photos to begin.") + status.addWidget(self.status_label, 1) + + def _set_dirty(self, dirty: bool) -> None: + self.dirty = dirty + path_label = Path(self.project_path).name if self.project_path else "Untitled project" + self.setWindowTitle(f"{'* ' if dirty else ''}{path_label} — Blurb Photo Book Editor") + self.save_action.setEnabled(dirty or self.project_path is None) + + def _mark_dirty(self) -> None: + self._set_dirty(True) + self._update_status() + + def _update_status(self) -> None: + count = len(self.project.pages) + photo_count = sum(1 for page in self.project.pages if page.image_path) + odd = " · Blurb export needs an even page count" if count % 2 else "" + if count < self.project.profile.min_pages or count > self.project.profile.max_pages: + odd += ( + f" · outside page range {self.project.profile.min_pages}–" + f"{self.project.profile.max_pages}" + ) + missing = sum( + 1 for page in self.project.pages if page.image_path and not Path(page.image_path).is_file() + ) + missing_text = f" · {missing} missing photo(s)" if missing else "" + setup = "Print setup needed" if not self.project.profile.ready else "Print setup ready" + self.status_label.setText(f"{count} pages · {photo_count} photo pages · {setup}{odd}{missing_text}") + + def _update_actions(self) -> None: + self._sync_view_menu() + selected = self._page_at_row(self.pages_list.currentRow()) + has_selection = selected is not None + self.remove_button.setEnabled(has_selection) + can_insert = self.view_mode == "all" + self.insert_blank_button.setEnabled(can_insert) + self.insert_photo_button.setEnabled(can_insert) + insert_tip = "Switch to View All to insert pages." if not can_insert else "" + self.insert_blank_button.setToolTip(insert_tip) + self.insert_photo_button.setToolTip(insert_tip) + missing = bool(selected and selected.image_path and not Path(selected.image_path).is_file()) + self.relink_button.setEnabled(missing) + self.zoom_slider.setEnabled(bool(selected and selected.image_path and Path(selected.image_path).is_file())) + self.zoom_in.setEnabled(self.zoom_slider.isEnabled()) + self.zoom_out.setEnabled(self.zoom_slider.isEnabled()) + self._update_status() + + def _page_at_row(self, row: int) -> PhotoPage | None: + if not 0 <= row < self.pages_list.count(): + return None + page = self.pages_list.item(row).data(Qt.ItemDataRole.UserRole) + return page if isinstance(page, PhotoPage) else None + + def _project_index_for_page(self, page: PhotoPage | None) -> int: + if page is None: + return -1 + return next( + (index for index, candidate in enumerate(self.project.pages) if candidate is page), + -1, + ) + + def _has_tagged_photos(self) -> bool: + return any(page.image_path and page.tagged for page in self.project.pages) + + def _visible_page_entries(self) -> list[tuple[int, PhotoPage]]: + return [ + (index, page) + for index, page in enumerate(self.project.pages) + if self.view_mode == "all" or (page.image_path and page.tagged) + ] + + def _sync_view_menu(self) -> None: + has_tagged_photos = self._has_tagged_photos() + if self.view_mode == "tagged" and not has_tagged_photos: + self.view_mode = "all" + if hasattr(self, "view_tagged_action"): + self.view_tagged_action.setEnabled(has_tagged_photos) + self.view_all_action.setChecked(self.view_mode == "all") + self.view_tagged_action.setChecked(self.view_mode == "tagged") + if self.view_mode == "all": + self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.pages_heading.setText("PAGES · drag to reorder") + else: + self.pages_list.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) + self.pages_heading.setText("PAGES · tagged only") + + def _set_view_mode(self, mode: str) -> None: + if mode == "tagged" and not self._has_tagged_photos(): + return + selected_index = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow())) + self.view_mode = mode + self._sync_view_menu() + self._refresh_page_list(selected_index if selected_index >= 0 else None) + + def _page_icon(self, page: PhotoPage) -> QPixmap: + if not page.image_path or not Path(page.image_path).is_file(): + pixmap = QPixmap(150, 126) + pixmap.fill(QColor("#e8e9ed")) + else: + pixmap = self._photo_thumbnail_cache.get(id(page)) + if pixmap is None: + aspect = (self.project.profile.pdf_width_in or self.project.profile.trim_width_in) / ( + self.project.profile.pdf_height_in or self.project.profile.trim_height_in + ) + thumb_w = 150 + thumb_h = max(1, round(thumb_w / aspect)) + image: Image.Image | None = None + try: + image = render_photo( + page.image_path, + (thumb_w, thumb_h), + page.zoom, + page.pan_x, + page.pan_y, + preview=True, + ) + pixmap = QPixmap.fromImage(_pil_to_qimage(image)) + except (OSError, ValueError): + pixmap = QPixmap(150, 126) + pixmap.fill(QColor("#e8c7c7")) + finally: + if image is not None: + image.close() + self._photo_thumbnail_cache[id(page)] = QPixmap(pixmap) + else: + pixmap = QPixmap(pixmap) + + if page.tagged: + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + badge_size = 24 + badge = QRectF(pixmap.width() - badge_size - 5, 5, badge_size, badge_size) + painter.setPen(QPen(QColor("white"), 2)) + painter.setBrush(QColor("#e53935")) + painter.drawEllipse(badge) + painter.setPen(QColor("white")) + font = QFont() + font.setBold(True) + font.setPointSize(10) + painter.setFont(font) + painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "T") + painter.end() + return pixmap + + def _photo_date_label(self, path: str) -> str: + if path not in self._photo_date_cache: + date_text = "" + if Path(path).is_file(): + capture_date = photo_sort_date(path) + if capture_date != datetime.max: + date_text = capture_date.strftime("%m/%d/%Y") + self._photo_date_cache[path] = date_text + else: + return "" + return self._photo_date_cache[path] + + def _page_label(self, page: PhotoPage, index: int) -> str: + if not page.image_path: + return f"Page {index + 1:03d} · Blank page" + elif not Path(page.image_path).is_file(): + detail = f"Page {index + 1:03d} · Missing · {Path(page.image_path).name}" + else: + detail = f"Page {index + 1:03d} · {Path(page.image_path).name}" + date_text = self._photo_date_label(page.image_path) + return f"{detail}\n{date_text}" if date_text else detail + return detail + + def _refresh_page_list(self, selected_index: int | None = None) -> None: + if selected_index is None: + selected_index = self._project_index_for_page( + self._page_at_row(self.pages_list.currentRow()) + ) + self._sync_view_menu() + entries = self._visible_page_entries() + selected_row = -1 + self.pages_list.blockSignals(True) + self.pages_list.clear() + for index, page in entries: + item = QListWidgetItem(self._page_label(page, index)) + item.setData(Qt.ItemDataRole.UserRole, page) + item.setIcon(self._page_icon(page)) + item.setToolTip("Tagged page" if page.tagged else "") + item.setSizeHint(QSize(310, 142)) + self.pages_list.addItem(item) + if entries: + selected_row = next( + (row for row, (index, _) in enumerate(entries) if index == selected_index), + next( + (row for row, (index, _) in enumerate(entries) if index >= selected_index), + len(entries) - 1, + ), + ) + self.pages_list.blockSignals(False) + self.pages_list.setCurrentRow(selected_row) + self._selection_changed(self.pages_list.currentRow()) + self._update_actions() + + def _refresh_one_page(self, index: int, *, refresh_thumbnail: bool = False) -> None: + if 0 <= index < self.pages_list.count(): + page = self._page_at_row(index) + if page is None: + return + if refresh_thumbnail: + self._photo_thumbnail_cache.pop(id(page), None) + project_index = self._project_index_for_page(page) + item = self.pages_list.item(index) + item.setText(self._page_label(page, project_index)) + item.setIcon(self._page_icon(page)) + item.setToolTip("Tagged page" if page.tagged else "") + + def _refresh_canvas(self) -> None: + page = self._page_at_row(self.pages_list.currentRow()) + project_index = self._project_index_for_page(page) + self.canvas.set_page( + page, + self.project.profile, + project_index if page else 0, + len(self.project.pages), + ) + + def _selection_changed(self, row: int) -> None: + page = self._page_at_row(row) + project_index = self._project_index_for_page(page) + self.current_page_index = project_index + self.canvas.set_page( + page, + self.project.profile, + project_index if page else 0, + len(self.project.pages), + ) + self._sync_zoom_control() + self._update_actions() + + def _sync_zoom_control(self) -> None: + row = self.pages_list.currentRow() + page = self._page_at_row(row) + if not page or not page.image_path or not Path(page.image_path).is_file(): + self.zoom_slider.blockSignals(True) + self.zoom_slider.setValue(0) + self.zoom_slider.blockSignals(False) + self.zoom_label.setText("—") + return + width, height = _page_dimensions(self.project.profile) + min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) + value = round((page.zoom - min_zoom) / max(0.001, max_zoom - min_zoom) * 100) + self.zoom_slider.blockSignals(True) + self.zoom_slider.setValue(max(0, min(100, value))) + self.zoom_slider.blockSignals(False) + self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") + + def _zoom_changed(self, value: int) -> None: + row = self.pages_list.currentRow() + page = self._page_at_row(row) + if page is None: + return + if not page.image_path or not Path(page.image_path).is_file(): + return + width, height = _page_dimensions(self.project.profile) + min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) + next_zoom = min_zoom + (max_zoom - min_zoom) * value / 100 + page.pan_x, page.pan_y = pan_for_centered_zoom( + _photo_size(page.image_path), + (width, height), + page.zoom, + next_zoom, + page.pan_x, + page.pan_y, + ) + page.zoom = next_zoom + self.zoom_label.setText(f"{page.zoom * 100:.0f}% of cover") + self.canvas.set_page( + page, + self.project.profile, + self._project_index_for_page(page), + len(self.project.pages), + ) + self._mark_dirty() + self._refresh_one_page(row, refresh_thumbnail=True) + + def _step_zoom(self, factor: float) -> None: + row = self.pages_list.currentRow() + page = self._page_at_row(row) + if page is None: + return + if not page.image_path or not Path(page.image_path).is_file(): + return + width, height = _page_dimensions(self.project.profile) + min_zoom, max_zoom = page_zoom_bounds(page.image_path, width / height) + next_zoom = max(min_zoom, min(max_zoom, page.zoom * factor)) + page.pan_x, page.pan_y = pan_for_centered_zoom( + _photo_size(page.image_path), + (width, height), + page.zoom, + next_zoom, + page.pan_x, + page.pan_y, + ) + page.zoom = next_zoom + self._sync_zoom_control() + self.canvas.set_page( + page, + self.project.profile, + self._project_index_for_page(page), + len(self.project.pages), + ) + self._mark_dirty() + self._refresh_one_page(row, refresh_thumbnail=True) + + def _selected_thumbnail_changed(self) -> None: + row = self.pages_list.currentRow() + self._refresh_one_page(row, refresh_thumbnail=True) + self._sync_zoom_control() + self._mark_dirty() + + def _order_changed(self) -> None: + if self.view_mode != "all": + self._refresh_page_list() + return + self.project.pages = [ + self.pages_list.item(index).data(Qt.ItemDataRole.UserRole) + for index in range(self.pages_list.count()) + ] + for index in range(self.pages_list.count()): + self.pages_list.item(index).setText(self._page_label(self.project.pages[index], index)) + self._refresh_canvas() + self._mark_dirty() + + def _show_page_context_menu(self, position) -> None: + item = self.pages_list.itemAt(position) + if item is None: + return + row = self.pages_list.row(item) + self.pages_list.setCurrentRow(row) + page = self._page_at_row(row) + if page is None: + return + menu = QMenu(self) + action = menu.addAction("Untag page" if page.tagged else "Tag page") + action.triggered.connect(lambda checked=False, selected_row=row: self._toggle_page_tag(selected_row)) + menu.exec(self.pages_list.mapToGlobal(position)) + + def _toggle_page_tag(self, row: int) -> None: + page = self._page_at_row(row) + if page is None: + return + project_index = self._project_index_for_page(page) + page.tagged = not page.tagged + if self.view_mode == "tagged": + self._refresh_page_list(project_index) + else: + self._refresh_one_page(row) + self._sync_view_menu() + self._mark_dirty() + + def _insert_at(self) -> int: + selected = self._project_index_for_page(self._page_at_row(self.pages_list.currentRow())) + return selected + 1 if selected >= 0 else len(self.project.pages) + + def _insert_blank_page(self) -> None: + index = self._insert_at() + self.project.pages.insert(index, PhotoPage()) + self._set_dirty(True) + self._refresh_page_list(index) + + def _insert_photo_page(self) -> None: + folder = self.project.source_directory or str(Path.home()) + filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")" + path, _ = QFileDialog.getOpenFileName(self, "Choose a photo for the new page", folder, filters) + if not path: + return + path = str(Path(path).resolve()) + if path not in self.project.photo_paths: + self.project.photo_paths.append(path) + index = self._insert_at() + self.project.pages.insert(index, PhotoPage(image_path=path)) + self._set_dirty(True) + self._refresh_page_list(index) + + def _remove_page(self) -> None: + row = self.pages_list.currentRow() + page = self._page_at_row(row) + if page is None: + return + project_index = self._project_index_for_page(page) + detail = Path(page.image_path).name if page.image_path else "blank page" + box = QMessageBox(self) + box.setWindowTitle("Remove page") + box.setText(f"Remove this page ({detail}) from the project? The source photo will not be deleted.") + remove = box.addButton("Remove page", QMessageBox.ButtonRole.DestructiveRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + if box.clickedButton() is not remove: + return + del self.project.pages[project_index] + self._photo_thumbnail_cache.pop(id(page), None) + self._set_dirty(True) + self._refresh_page_list(project_index) + + def _relink_photo(self) -> None: + row = self.pages_list.currentRow() + page = self._page_at_row(row) + if page is None: + return + project_index = self._project_index_for_page(page) + old = page.image_path + start = self.project.source_directory or str(Path.home()) + if old: + parent = Path(old).parent + if parent.is_dir(): + start = str(parent) + filters = "Photo files (" + " ".join(f"*{ext}" for ext in sorted(SUPPORTED_EXTENSIONS)) + ")" + path, _ = QFileDialog.getOpenFileName(self, "Relink missing photo", start, filters) + if not path: + return + path = str(Path(path).resolve()) + page.image_path = path + self._photo_thumbnail_cache.pop(id(page), None) + if path not in self.project.photo_paths: + self.project.photo_paths.append(path) + self._set_dirty(True) + self._refresh_page_list(project_index) + + def _maybe_save(self) -> bool: + if not self.dirty: + return True + box = QMessageBox(self) + box.setWindowTitle("Unsaved project") + box.setText("Save changes to the current photo book project?") + box.setStandardButtons( + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QMessageBox.StandardButton.Save) + choice = box.exec() + if choice == QMessageBox.StandardButton.Cancel: + return False + if choice == QMessageBox.StandardButton.Save: + return self._save_project() + return True + + def _import_folder(self) -> None: + if self.folder_import_worker and self.folder_import_worker.isRunning(): + return + if not self._maybe_save(): + return + folder = QFileDialog.getExistingDirectory(self, "Choose photo folder", self.project.source_directory or str(Path.home())) + if not folder: + return + self._pending_import_issues = [] + self._pending_import_error = None + self._pending_empty_import = False + dialog = QProgressDialog("Scanning photo folder…", "", 0, 0, self) + dialog.setWindowTitle("Importing photos") + dialog.setWindowModality(Qt.WindowModality.WindowModal) + dialog.setCancelButton(None) + dialog.setMinimumDuration(0) + dialog.setAutoClose(False) + dialog.setAutoReset(False) + dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) + self.import_progress_dialog = dialog + self.folder_import_worker = FolderImportWorker(folder) + self.folder_import_worker.stageChanged.connect(self._folder_import_stage_changed) + self.folder_import_worker.progressChanged.connect(self._folder_import_progress_changed) + self.folder_import_worker.completed.connect(self._folder_import_completed) + self.folder_import_worker.failed.connect(self._folder_import_failed) + self.folder_import_worker.finished.connect(self._folder_import_worker_finished) + dialog.show() + self.folder_import_worker.start() + + def _folder_import_stage_changed(self, message: str, total: int) -> None: + if not self.import_progress_dialog: + return + self.import_progress_dialog.setLabelText(message) + self.import_progress_dialog.setRange(0, total) + self.import_progress_dialog.setValue(0) + + def _folder_import_progress_changed(self, completed: int, total: int) -> None: + if self.import_progress_dialog: + self.import_progress_dialog.setLabelText( + f"Preparing photo previews… {completed} of {total}" + ) + self.import_progress_dialog.setValue(completed) + + def _folder_import_completed( + self, + project: BookProject, + issues: list[str], + photo_dates: dict[str, str], + thumbnails: dict[int, QImage], + ) -> None: + self._pending_import_issues = issues + if not project.photo_paths: + self._pending_empty_import = True + return + self.project = project + self.project_path = None + self._photo_date_cache = photo_dates + self._photo_thumbnail_cache = { + page_id: QPixmap.fromImage(image) for page_id, image in thumbnails.items() + } + self._set_dirty(True) + self._refresh_page_list(0) + self.status_label.setText( + f"Imported {len(project.photo_paths)} photo(s), sorted oldest to newest." + ) + + def _folder_import_failed(self, message: str) -> None: + self._pending_import_error = message + + def _folder_import_worker_finished(self) -> None: + worker = self.folder_import_worker + self.folder_import_worker = None + if self.import_progress_dialog: + self.import_progress_dialog.close() + self.import_progress_dialog.deleteLater() + self.import_progress_dialog = None + if worker: + worker.deleteLater() + + if self._pending_import_error: + QMessageBox.critical(self, "Import photos", self._pending_import_error) + elif self._pending_empty_import: + QMessageBox.warning( + self, + "Import photos", + "No readable photos were found in that folder.", + ) + elif self._pending_import_issues: + preview = "\n".join(self._pending_import_issues[:12]) + if len(self._pending_import_issues) > 12: + preview += f"\n…and {len(self._pending_import_issues) - 12} more." + QMessageBox.warning(self, "Some photos could not be read", preview) + self._pending_import_issues = [] + self._pending_import_error = None + self._pending_empty_import = False + + def _open_project(self) -> None: + if not self._maybe_save(): + return + path, _ = QFileDialog.getOpenFileName( + self, "Open photo book project", str(Path.home()), "Blurb projects (*.blurbproj);;JSON files (*.json)" + ) + if not path: + return + try: + project = BookProject.load(path) + except (OSError, ValueError, json.JSONDecodeError) as exc: + QMessageBox.critical(self, "Open project", f"Could not open the project:\n{exc}") + return + self.project = project + self.project_path = path + self._photo_date_cache.clear() + self._photo_thumbnail_cache.clear() + self._set_dirty(False) + self._refresh_page_list(0) + + def _save_project(self) -> bool: + if self.project_path is None: + return self._save_project_as() + try: + self.project.save(self.project_path) + except OSError as exc: + QMessageBox.critical(self, "Save project", str(exc)) + return False + self._set_dirty(False) + self.status_label.setText(f"Saved project: {self.project_path}") + return True + + def _save_project_as(self) -> bool: + path, _ = QFileDialog.getSaveFileName( + self, + "Save photo book project", + self.project_path or str(Path(self.project.source_directory or Path.home()) / "photo-book.blurbproj"), + "Blurb projects (*.blurbproj)", + ) + if not path: + return False + if not path.lower().endswith(".blurbproj"): + path += ".blurbproj" + old_path = self.project_path + self.project_path = path + if not self._save_project(): + self.project_path = old_path + return False + return True + + def _edit_print_setup(self) -> None: + dialog = PrintSetupDialog(self.project.profile, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + self.project.profile = dialog.profile + self._set_dirty(True) + self._refresh_canvas() + self._sync_zoom_control() + self._update_status() + + def _export_pdf(self) -> None: + if self.worker and self.worker.isRunning(): + return + if not self.project.pages: + QMessageBox.information(self, "Export PDF", "Import photos or insert a page first.") + return + if not self.project.profile.ready: + QMessageBox.information( + self, + "Blurb print setup required", + "Enter the final exported page dimensions and trim offsets from Blurb’s specifications first.", + ) + self._edit_print_setup() + if not self.project.profile.ready: + return + errors = self.project.profile.validate() + if errors: + QMessageBox.warning(self, "Blurb print setup", "\n".join(errors)) + return + + count = len(self.project.pages) + if count > self.project.profile.max_pages: + QMessageBox.warning( + self, + "Page limit exceeded", + f"This Blurb format allows at most {self.project.profile.max_pages} pages. " + "Remove pages or update the limit from the selected Blurb specifications.", + ) + return + target_count = max(count, self.project.profile.min_pages) + if target_count % 2: + target_count += 1 + if target_count > self.project.profile.max_pages: + QMessageBox.warning( + self, + "Page limits conflict", + "The minimum page count and even-page rule exceed the maximum for this format. " + "Review the Blurb page limits in print setup.", + ) + return + if target_count > count: + add_count = target_count - count + box = QMessageBox(self) + box.setWindowTitle("Blurb page count") + box.setText( + f"This format requires an even page count between {self.project.profile.min_pages} " + f"and {self.project.profile.max_pages}. Append {add_count} blank page(s) to continue?" + ) + append = box.addButton( + f"Append {add_count} blank page(s)", QMessageBox.ButtonRole.AcceptRole + ) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + if box.clickedButton() is not append: + return + self.project.pages.extend(PhotoPage() for _ in range(add_count)) + self._set_dirty(True) + self._refresh_page_list(len(self.project.pages) - 1) + + suggested = Path(self.project.source_directory or Path.home()) / "blurb-book.pdf" + output, _ = QFileDialog.getSaveFileName(self, "Export Blurb PDF", str(suggested), "PDF files (*.pdf)") + if not output: + return + if not output.lower().endswith(".pdf"): + output += ".pdf" + snapshot = copy.deepcopy(self.project) + self.worker = ExportWorker(snapshot, output) + self.worker.progressChanged.connect(self._export_progress) + self.worker.succeeded.connect(self._export_succeeded) + self.worker.failed.connect(self._export_failed) + self.worker.start() + self.export_action.setEnabled(False) + self.progress.setRange(0, len(snapshot.pages)) + self.progress.setValue(0) + self.progress.setVisible(True) + self.status_label.setText("Rendering pages…") + + def _export_progress(self, done: int, total: int) -> None: + self.progress.setRange(0, total) + self.progress.setValue(done) + self.status_label.setText(f"Rendering page {done} of {total}…") + + def _export_succeeded(self, path: str) -> None: + self.export_action.setEnabled(True) + self.progress.setVisible(False) + self.status_label.setText(f"PDF verified and saved: {path}") + QMessageBox.information(self, "PDF ready", f"PDF created and verified:\n{path}") + + def _export_failed(self, message: str) -> None: + self.export_action.setEnabled(True) + self.progress.setVisible(False) + self.status_label.setText("PDF export failed.") + QMessageBox.critical(self, "PDF export failed", message) + + def closeEvent(self, event) -> None: # noqa: N802 - Qt event name + if self.folder_import_worker and self.folder_import_worker.isRunning(): + QMessageBox.information( + self, + "Photo import running", + "Wait for the photo import to finish before closing.", + ) + event.ignore() + return + if self.worker and self.worker.isRunning(): + QMessageBox.information(self, "PDF export running", "Wait for the PDF export to finish before closing.") + event.ignore() + return + if self._maybe_save(): + event.accept() + else: + event.ignore() + + +def run() -> int: + app = QApplication(sys.argv) + app.setApplicationName("Blurb Photo Book Editor") + app.setStyle("Fusion") + window = PhotoBookWindow() + window.show() + return app.exec() diff --git a/main.py b/main.py new file mode 100644 index 0000000..c60307d --- /dev/null +++ b/main.py @@ -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()) diff --git a/requirements.txt b/requirements.txt index 6c3c6ee..f4782a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,9 @@ # --- 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. pillow==12.3.0 @@ -35,4 +38,4 @@ charset-normalizer==3.5.2 # build tooling packaging==26.3 setuptools==84.0.0 -wheel==0.48.0 \ No newline at end of file +wheel==0.48.0