From f62cb2efdb4272bc107e746d137bcc2d29a76472 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sat, 3 Oct 2026 16:13:38 -0400 Subject: [PATCH] first commit --- .gitignore | 1 + README.md | 309 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 38 ++++++ 3 files changed, 348 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e6dd37 --- /dev/null +++ b/README.md @@ -0,0 +1,309 @@ +# Blurb + +Build a print-ready PDF from a folder of photos, sized for an Affinity Publisher +spread (9.5 x 8 in). + +**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. + +--- + +## 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: + +```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 +``` + +Always invoke as `.\.venv\Scripts\python.exe` -- plain `python` will hit the +system install, which has none of these modules. + +| 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 | + +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. + +### Verified end to end + +A smoke test confirmed the whole chain works, not just that the imports resolve: + +- 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 + +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. + +### reportlab 5.x API break + +`canvas.drawImage()` no longer accepts a PIL `Image` object: + +```python +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: + +```python +c.drawImage(path, x, y, width=..., height=...) +``` + +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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c3c6ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,38 @@ +# Blurb - Python dependencies +# Direct dependencies only; transitive deps are pinned below for reproducibility. +# +# python -m venv .venv +# .venv\Scripts\python.exe -m pip install -r requirements.txt +# +# Verified on Windows 10 IoT Enterprise LTSC (build 19044), Python 3.12.0. + +# --- Direct dependencies --- + +# Measure images, read EXIF orientation, do the cover-crop/contain resize. +pillow==12.3.0 + +# Embed JPEG bytes into a PDF losslessly - no re-encode, so no second +# generation of compression. Best fit for a photo book. +img2pdf==0.6.3 + +# Programmatic page layout: captions, multi-image pages, margins, page numbers. +reportlab==5.0.1 + +# Inspect and render finished PDFs (page count, MediaBox, page -> PNG preview). +# Replaces the Ghostscript dependency, which is NOT installed on this machine - +# without this, ImageMagick cannot read a PDF at all. +pymupdf==1.28.2 + +# --- Transitive, pinned for reproducibility --- + +# pulled in by pikepdf +lxml==6.1.3 +pikepdf==10.16.0 + +# pulled in by reportlab +charset-normalizer==3.5.2 + +# build tooling +packaging==26.3 +setuptools==84.0.0 +wheel==0.48.0 \ No newline at end of file