Abstract
A naive wkhtmltopdf essay.html -o book.pdf gives a PDF, but a flawed one: wkhtmltopdf silently drops WebP images (so figures vanish), the footnote numbers wrap onto their own line, the figures render at inconsistent widths, and the output is not an archival PDF/A. This tutorial is the complete, repeatable pipeline that produces a clean PDF/A-2b: rasting the WebP figures to PNG at a uniform width, laying footnotes out so the number and text share the first line, adding a section-heading bookmark side-panel, and running the result through ghostscript for PDF/A-2b conformance. It also documents the two hard trade-offs of PDF/A – no clickable in-body links, and a bookmark tree that is the practical navigation. There is also a one-command shortcut (pbf) that does it all.
Prerequisites
wkhtmltopdfinstalled (HTML to PDF).ghostscriptinstalled (PDF to PDF/A-2b via-dPDFA=2).- Python 3.12+ with Pillow (for WebP rasterization and uniform sizing). This project uses
~/av/venv/hydra/bin/python3. - The post’s rendered HTML (
~/av/doc/posts/<SLUG>/<SLUG>.html) – the PDF build consumes HTML, not the markdown (unlike the EPUB path). - The pipeline module
bin/hydra/publish/signal_arweave.py.
Quickest start: ~/av/venv/hydra/bin/python3 ~/av/bin/pbf SLUG produces the PDF/A-2b. The rest of this tutorial explains what it does and the pitfalls.
Step 1: Rasterize WebP Figures (or They Vanish)
wkhtmltopdf does not decode WebP in <img> tags and silently skips those figures – the PDF simply has no image where the figure was. Before rendering, walk the HTML, convert every local WebP (and any other non-raster) image to PNG with Pillow, and re-point the <img> at the PNG copy.
While you are in there, pin every <figure><img> to a uniform width (say 600px) and resize the source with LANCZOS so all the floor-plans, diagrams, and photos align at the same width regardless of the source proportions.
Step 2: Fix the Footnote Layout
wkhtmltopdf renders a hanging-indent <p> (padding-left + text-indent) with the number wrapping onto its own line above the note text. Replace each footnote with a two-cell table: a narrow nowrap number cell and a text cell, so number and note share the first line and wrapped lines indent cleanly.
Step 3: Run wkhtmltopdf
Run from the HTML’s own directory so the (now absolute) image paths resolve:
wkhtmltopdf --enable-local-file-access --page-size Letter \
--outline-depth 6 --title "TITLE" in.html raw.pdf--outline-depth 6 writes a section-heading bookmark tree into the PDF. This is what survives the PDF/A pass as your navigation – see Step 5.
Step 4: Convert to PDF/A-2b with ghostscript
gs -dPDFA=2 -dBATCH -dNOPAUSE -dNOOUTERSAVE \
-sColorConversionStrategy=RGB -dProcessColorModel=/DeviceRGB \
-sDEVICE=pdfwrite -dPDFACompatibilityPolicy=1 \
-sOutputFile=out.pdf -f PDFA_def.ps -f raw.pdfPDFA_def.ps must reference an absolute srgb.icc path, or ghostscript cannot resolve the ICC profile and aborts. Locate pdfwrite’s PDFA_def.ps under the Homebrew ghostscript cell, and emit a fixed copy whose ICCProfile (srgb.icc) points to the absolute .../share/ghostscript/iccprofiles/srgb.icc.
Step 5: Know the PDF/A Trade-offs
- PDF/A strips ALL link annotations. ghostscript
-dPDFA=2emits “annotation not permitted in PDF/A” and drops every link – both in-body TOC links and external URLs. This is a hard behavior; you cannot have clickable in-body links in a gs-produced PDF/A-2b. - The bookmark tree survives. The
/Outlinesfrom--outline-depthcarries through the ghostscript pass, so the PDF still has a section-heading navigation side-panel. That is the practical navigation. - Other producers don’t help. qpdf’s
--pdfa-2bflag is absent on the installed build, andocrmypdf --output-type pdfaalso routes through gs and strips links the same way. gs-dPDFA=2is the canonical producer.
Step 6: Verify
pdfinfo out.pdf # PDF/A marker: pdfaid:part='2' conformance='B'
pdfimages -list out.pdf # every figure present (no WebP drops)
pdfinfo out.pdf | grep -i pages
qpdf --json out.pdf # 'outlines' -> bookmark side-panel entries
strings out.pdf | grep pdfaidAccept when: it is a genuine PDF/A-2b; every figure is embedded and uniformly width; footnote numbers and text share a line with nothing clipped on the left; and the bookmark outline lists the document (and its sections).
Step 7: The pbf Shortcut
All of the above is wrapped in ~/av/bin/pbf, which takes a post slug (or a path to its .md), reads title from frontmatter, and builds the PDF from the post’s rendered HTML:
~/av/venv/hydra/bin/python3 ~/av/bin/pbf SLUG --title "..." [--open]It is registered in the Shorthands catalog as pbf. The underlying functions live in bin/hydra/publish/signal_arweave.py (render_pdfa2b, _html_with_raster_images, _find_pdfa_def, pdfa2b_available) and are reused by the Signal PDF delivery path (signal-assemble-full.py --pdf).
Reference
| Concept | Where |
|---|---|
| PDF pipeline | bin/hydra/publish/signal_arweave.py::render_pdfa2b |
| WebP -> PNG + uniform width | _html_with_raster_images (uniform_width) |
| Footnote table layout | _html_with_raster_images::_manual_list |
| PDFA_def.ps abs-ICC | _find_pdfa_def |
| Availability check | pdfa2b_available() |
| One-command build | ~/av/bin/pbf |
Want to stay in touch? You can reach me in a variety of ways from my Contact page.