first commit

This commit is contained in:
2026-10-03 16:13:38 -04:00
commit f62cb2efdb
3 changed files with 348 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.venv/
+309
View File
@@ -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_<uuid>_PXL_20240512_215016784.MP.jpg # 3 files, prefixed by a uuid
```
Suffixes in use: bare (default), `.MP` (macro), `.PORTRAIT` (portrait orientation),
`~2` / `~3` (duplicate-export suffixes).
### Orientation and aspect ratio
| Aspect ratio | Count | Note |
|---|---|---|
| 1.332 / 1.328 / 1.333 (4:3) | 94 | Landscape |
| 1.778 (16:9) | 3 | Landscape, wide |
| 0.751 | 1 | Portrait |
97 landscape, 1 portrait. This matters because the target page ratio is **1.188**,
which is narrower than 4:3 -- see Cropping below.
---
## What was done
### Step 1 - Extract the archives
Two zip archives were unpacked into the photo folder in place:
- `Photos-1-001.zip` -> 14 files
- `Photos-1-001 (6).zip` -> 13 files
27 unique files; no name collisions between the two. Archives were inspected
before extraction to confirm they held only `.jpg` and that the two sets did not
overlap. Post-extraction the folder held 98 photos: 27 from the archives plus 71
already present.
> Note: both source zips disappeared from the folder sometime after extraction.
> No photo data was lost (all 27 extracted files remain, 0 zero-byte files) and
> the archives were not in the recycle bin, but the cause was never identified.
> Worth keeping in mind before wiring an "extract then delete" step into any
> automated pipeline.
### Step 2 - Choose the page geometry
The 9.5 x 8 in spread size came from Affinity Publisher. That is a ratio of
**1.1875**, which is what drives the crop behavior.
One ambiguity worth resolving before this becomes code: a Publisher *spread* is
normally two facing pages. 9.5 x 8 in could mean either:
- one 9.5 x 8 in PDF page per photo (what was actually produced), or
- a 9.5 x 8 in spread meaning **two** 4.75 x 8 in pages, i.e. 2 photos per PDF page.
This build assumed the first. The second is very likely the real intent for a
book layout and is the single biggest thing to confirm.
### Step 3 - Measure before placing
Rather than guessing at fill behavior, every image was measured with
`magick identify` first. This surfaced that 94 photos are 4:3 while the page is
1.188, so full-bleed fill has to crop -- and that 4 outliers would be cropped far
harder than the rest:
| Photo AR | Count | Full-bleed crop required |
|---|---|---|
| ~1.33 (4:3) | 94 | ~11% off the sides |
| 1.778 (16:9) | 3 | **~33% off the sides** |
| 0.751 (portrait) | 1 | **~37% off the height** |
Cropping was accepted for uniformity, and the 4 outliers were flagged to the user
rather than silently trimmed. **Worth reviewing before print.**
### Step 4 - Render each page
For each photo, in chronological order, rendered one 1900 x 1600 page:
```powershell
magick <input.jpg> -auto-orient -resize 1900x1600^ -gravity center -extent 1900x1600 -strip -interlace Plane -quality 85 page_NNNN.jpg
```
- `-auto-orient` applies the EXIF rotation flag *before* cropping, so PORTRAIT
shots are not cropped as though they were landscape.
- `-resize WxH^` (the `^`) scales to *cover* the box, then `-extent` trims the
overflow. This is what produces full-bleed. For fit-inside instead, drop the
`^`.
- `-strip` drops EXIF including GPS. Dates survive in the page order and
filenames, so nothing meaningful was lost.
- Output was written to a numbered sequence (`page_0001.jpg` ... `page_0098.jpg`)
in a temp dir, not into the photo folder, keeping sources and build artifacts
cleanly separated.
Sort key, tried in order:
1. `PXL_(20\d{6})_(\d{6})` -> date + time
2. `IMG(20\d{6})(\d{6})` -> date + time
3. creation time as fallback
Sorting on the filename timestamp matters: filesystem mtimes were scattered across
several days in Sep/Oct 2026 (copy/export artifacts) and would have produced a
meaningless order.
### Step 5 - Assemble the PDF
The `%04d` glob sequence was tried first and **failed** -- ImageMagick did not
expand `page_%04d.jpg` and errored with `unable to open image`. Passing the 98
absolute paths as explicit arguments worked:
```powershell
magick @pages -units PixelsPerInch -density 200 -quality 85 C:\tmp\bbook.pdf
```
Total argument length stayed around 4.5 KB, far below the Windows command-line
limit, so this is safe until roughly 700+ images per invocation -- at which point
it needs chunking or a response file.
### Step 6 - Verify
**A bug worth documenting:** the first PDF came out at **26.39 x 22.22 in** --
not 9.5 x 8. Cause: `-strip` had removed the density metadata, so ImageMagick
assumed 72 DPI and treated the 1900 x 1600 px page as 1900 x 1600 *points*. Pixel
dimensions alone do not determine PDF page size.
Fix: set density explicitly at PDF-write time with `-units PixelsPerInch -density 200`.
Ghostscript is not installed, so ImageMagick cannot read or rasterize a PDF to
check it visually. Verification was therefore done by parsing the PDF bytes
directly:
```powershell
$txt = [System.Text.Encoding]::Latin1.GetString([IO.File]::ReadAllBytes($out))
([regex]::Matches($txt, '/Type\s*/Page[^s]')).Count # -> 98 page objects
[regex]::Match($txt, '/MediaBox\s*\[([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\]')
```
That confirmed 98 pages at 684 x 576 pt on every page. This byte-level check is
worth keeping -- it caught the page-size bug that a file-exists test would have
missed.
---
## Development environment
A venv is set up in this directory with everything needed to generate PDFs.
Pinned versions live in `requirements.txt`.
```powershell
cd "D:\Python Utilities\Blurb"
python -m venv .venv
.\.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.
+38
View File
@@ -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