first commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.venv/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user