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 filesPhotos-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:
magick <input.jpg> -auto-orient -resize 1900x1600^ -gravity center -extent 1900x1600 -strip -interlace Plane -quality 85 page_NNNN.jpg
-auto-orientapplies 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-extenttrims the overflow. This is what produces full-bleed. For fit-inside instead, drop the^.-stripdrops 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:
PXL_(20\d{6})_(\d{6})-> date + timeIMG(20\d{6})(\d{6})-> date + time- 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:
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:
$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.
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:
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:
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.xmlcontainscoderrules 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 policyoutput does not echo the coder rules, sopolicy.xmlitself 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
- 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.
- The 4 wide/portrait outliers. Cropped hard (33% / 37%). Review, or special-case them to fit-inside?
- 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.
- Missing zip archives. Cause of their disappearance is still unknown.
- 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).
Pilloworimg2pdfwould 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.