Tutorial: Generating an Archival PDF/A-2b from an Essay

wkhtmltopdf + ghostscript, uniform figures, footnotes that share a line, and the PDF/A trade-offs

tutorial · 20% AI

Post 2026-A-0151

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

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.pdf

PDFA_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

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 pdfaid

Accept 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?

Support my work