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?
- Signal (announcements): https://signal.group/#CjQKIGLn7xDB0uOXMMlbKlsKEG0CmkmL9gk3U0SeIX0KlKRZEhDoqIluCXo84TrBz-2tMJD7
- Signal (discussion): https://signal.group/#CjQKIDA0v6tUciWe-3jRArkbYttju8xfuoczTOfMrGuvhmEZEhCrOnPk-IWFmFmipdI1EHxv
- Signal: archerships.43 (https://signal.me/#eu/9JUc8x9c-QA0_-QR9qQd0HUmjsnAG1BeOJM2nDo5DopjIPq5bThAJYr99lsh0cPP)
- Mailing list: https://archerships.substack.com/subscribe
- Email: [email protected]
- Website: https://archerships.com
- Substack: https://substack.com/@archerships
- Twitter: https://x.com/archerships
- Facebook: https://www.facebook.com/archerships
- Yahihonne: https://yakihonne.com/profile/nprofile1qqsgr0xn6vvr8su9ptzj4n50j8vzmczzayed0wcl5rdnvh0tc6xhqncy6jrjw
- Nostr-npub:
npub1sx7d85ccx0pc2zk99t8glywc9hsy96fj67a3lgxmxew7h35dwp8shak49e - Odysee: https://odysee.com/@archerships:6
- TikTok: https://www.tiktok.com/@archertships
Support my work
- Donations (crypto): https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=85e4n5bgLTWiAWZbkjbbF5MLrwyiU8kjxHWHL9t6vDE5MyNUCPzBuZUNDcvbCisC5iW5PPBP9ETRQUWQQjMuvAhHRFaYCeM&donation=True&simple_mode=True&name=Archerships&[email protected]&ticker_from=xmr&network_from=Mainnet&bgcolor=000000ff
- Donations (fiat): https://ko-fi.com/archerships
- Consulting: privacy / crypto / censorship consulting – email or Signal