first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user