PDF generation¶
prodockit.pdf builds a standalone PDF from your Zensical site - the kind of
downloadable, submittable document professional and academic reports
commonly need alongside the website itself. It reads the same
zensical.toml your site already has, so there's nothing new to learn or
configure beyond a couple of optional settings.
Build your first PDF¶
From the project root—the directory containing zensical.toml—run the
prodockit pdf command:
The command reads every page in nav, keeps that order, and writes
docs/site_documentation.pdf by default. Open the result and check its cover,
contents, headings, page breaks, diagrams, and final page before changing any
layout setting.
If the command reports a missing program or native library, install the
requirements in the next section and repeat the same command. A project that
already completed prodockit bootstrap --apply should have them.
Requirements¶
The PDF is built via Pandoc and
WeasyPrint, so both need to be installed and on
your PATH:
then follow Pandoc's own install instructions
for your platform (e.g. brew install pandoc on macOS).
WeasyPrint is not a pure-Python package
pip install weasyprint installs the Python half. WeasyPrint draws
text through Pango and a few related native libraries -
libgobject-2.0, libpango-1.0, libpangoft2-1.0, libharfbuzz,
libharfbuzz-subset and libfontconfig - which pip cannot install,
because they belong to the operating system.
| Platform | |
|---|---|
| macOS | brew install pango |
| Debian/Ubuntu | sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 |
| Windows | pacman -S mingw-w64-x86_64-pango under MSYS2, with C:\msys64\mingw64\bin on PATH |
One package covers it on macOS and Windows because glib, HarfBuzz and
fontconfig arrive as dependencies of Pango. On Debian,
libharfbuzz-subset0 is a separate package from libharfbuzz0b and
is the one usually missed.
On Apple Silicon macOS, Python's dynamic loader may still not search the
Homebrew library directory after Pango is installed. Export the path in
the terminal where you run prodockit pdf:
Use /usr/local/lib on an Intel Mac. If
cannot load library 'libgobject-2.0-0' appears after brew install pango,
check this variable before reinstalling anything.
Missing them looks like a Pandoc problem rather than an install one:
Error: pandoc exited with status 43 building 'docs/site_documentation.pdf' (only pass)
Output from the failing command:
...
OSError: cannot load library 'libgobject-2.0-0'
Status 43 is Pandoc's own PandocPDFError - Pandoc ran, and the PDF
engine it handed off to did not start. The detail beneath the error is
WeasyPrint's, and names the library it could not load. python -c
"import weasyprint" is the quickest way to confirm the stack before
building.
Back-of-book indexes
additionally need pymupdf - pip
install prodockit[index] (or plain pip install pymupdf) - but only if
you set include = true for prodockit.index.
Mermaid diagrams and TeX maths need a little Node tooling on top - see below. Every other feature on this page needs nothing beyond Pandoc/WeasyPrint.
Mermaid diagrams and TeX maths¶
WeasyPrint has no JS engine, so neither can be rendered the way your live website renders them. Both are pre-rendered to static images before Pandoc sees them - Mermaid via mermaid-cli, maths via a small MathJax script.
Set both up with the prodockit init-tools command:
That writes tools/mermaid/package.json, tools/mathjax/package.json and
tools/mathjax/tex2svg.js - the exact layout prodockit pdf looks for -
then prints the npm commands to install them, the .gitignore lines you
want, and the environment variables a CI run needs. It won't overwrite
files you already have unless you pass --force, and --no-mermaid /
--no-mathjax skip either half.
Once that's in place, maths is ordinary markdown - write $...$ for an
inline formula and $$...$$ for a display one, and both are rendered
wherever the page is read. The right-angle case, inline: \(c = \sqrt{a^2 +
b^2}\), and as a display formula:
Those are live rather than illustrative, and are the only maths in these
docs. They earn their place: without a real formula somewhere, this
project's own rendering checks cannot tell a working
MathJax toolchain from a missing one - assert_no_unrendered_tex passes
trivially on a document with no maths to leave unrendered. Read them in the
PDF and they are static SVG; read them on the website and MathJax typeset
them in your browser.
Deliberately shown rendered rather than as a fenced markdown sample: a
code block's LaTeX and an unrendered formula are the same characters once
both are plain text in a PDF, so quoting the source here would fail this
project's own check - the same trap that made the "contains TeX maths"
warning fire on
prose describing it.
assert_no_unrendered_tex matches the handful of TeX command sequences
listed in prodockit.testing.checks, so keep literal LaTeX out of any page
a -m built run inspects - naming even one of them in this sentence was
enough to fail the check.
The failure here is quiet by default
If a renderer isn't found, the content is left exactly as it is rather
than failing the build - the right default for a project using neither
feature. A project that does use them would otherwise get a PDF full
of raw flowchart LR ... source or literal LaTeX with nothing having
gone wrong as far as the build is concerned, so since 0.12.0
prodockit pdf prints a warning naming the missing renderer whenever
that combination occurs.
In CI, use PUPPETEER_SKIP_DOWNLOAD
mermaid-cli drives Chrome through Puppeteer. The older
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD is the one most people reach for,
and puppeteer 25.x - what mermaid-cli 11.x resolves to - ignores it,
so a full Chrome build is downloaded on every run before being
discarded in favour of PUPPETEER_EXECUTABLE_PATH. init-tools
prints the correct pair.
Configure the PDF¶
Everything is read from your project's own zensical.toml - nothing is
passed on the command line beyond, optionally, which config file to use:
Building a single file¶
To build a PDF from just one markdown file - a single chapter, say, rather
than the whole site - pass --markdown-file (-m for short), a path
relative to docs_dir:
This ignores nav entirely and renders only that page. Everything else -
fonts, page size, margins, heading_numbering, and so on - still comes
from zensical.toml exactly as it would for a full build. The output
defaults to that file's own name with a .pdf extension inside
docs_dir (e.g. docs/chapter1.pdf) instead of site_documentation.pdf,
unless pdf_output is set, in which case that always wins.
Most of what the PDF needs, it already gets from settings your site likely
has for other reasons: site_name, copyright, repo_url, docs_dir,
theme.font.text/.code, theme.icon.admonition, and extra_css - your
site's own stylesheet(s) are passed straight through, so a @media print
rule (e.g. hiding a website-only "Download PDF" link/button, since
WeasyPrint always renders in print mode) applies in the PDF too. The rest
lives under [project.extra], all optional:
| Setting | Default | What it does |
|---|---|---|
pdf_output |
"<docs_dir>/site_documentation.pdf" |
Where the PDF is written. |
pdf_copyright |
falls back to copyright |
Overrides copyright for the PDF's own footer only - see Copyright text. |
pdf_page_size |
"A4" |
Any WeasyPrint-supported CSS page size ("Letter", ...). |
pdf_margin_top / _right / _bottom / _left |
"2cm", except _bottom at "2.5cm" |
Page margins, as CSS lengths. The bottom is deeper because the running footer sits in it - see Copyright text. |
pdf_double_sided |
false |
Duplex-printing layout - see Double-sided (duplex) printing. |
pdf_margin_inner / _outer |
"2cm" each |
Spine-side/fore-edge margins, used instead of pdf_margin_left/_right when pdf_double_sided is on. |
pdf_header_footer_font_size / _color / _divider_color |
"10pt" / "#555555" / "#e2e8f0" |
Running header/footer styling. |
heading_numbering |
true |
Chapter/appendix numbering on headings and captions. |
reference_style |
"european" |
"european" (tight, single-line citation entries) or "global" (double-spaced, hanging indent - the common APA/MLA/Chicago style). |
pdf_include_table_of_contents |
true |
Whether to generate and insert a table of contents. |
pdf_table_of_contents_title |
"Table of Contents" |
That page's own heading text. |
pdf_mmdc_bin |
auto-detected | Path to a mermaid-cli mmdc binary, for pre-rendering Mermaid diagrams. Diagrams are left unrendered if none is found - see Mermaid diagrams and TeX maths. |
pdf_tex2svg_script / pdf_math_dir |
auto-detected | A local MathJax tex2svg-style Node script, for pre-rendering TeX math (WeasyPrint has no JS engine to run MathJax client-side). Formulas are left as literal text if none is found - see Mermaid diagrams and TeX maths. |
pdf_extra_css |
none | A list of docs_dir-relative stylesheet paths, same shape as extra_css above but meant only for the PDF - e.g. a rule that would look wrong on the live website, or one overriding something extra_css itself sets (concatenated after it, so it wins the cascade). |
A page's own front matter is_appendix: true gives it letter-based
numbering ("A", "A.1", ...) instead of numeric, matching
prodockit.headings' own appendix_attr convention.
A page's own front matter recto_title: "Short Title" overrides its
running header text from the next page onward - see
Double-sided (duplex) printing. Your
nav's index page can also use {WORDCOUNT}/{REPOURL}/{RELEASE}/
prodockit markers - see Cover page markers.
Web-only / PDF-only content¶
Mark any block or inline element {.web-only} (via
attr_list) for
content meant only for the live website - a "Download PDF" link/button is
the common case, since linking to the very PDF you're already reading
doesn't make sense once it's embedded in that PDF. {.pdf-only} is the
opposite: content meant only for the PDF, e.g. an automated word count or
release tag on a cover page that only makes sense in a standalone
document.
.web-only needs no configuration - prodockit.pdf's own generated CSS
always hides it, in every build, whether you're using prodockit pdf or
calling build_pdf() directly. .pdf-only is the one half prodockit can't
provide automatically (its own CSS has no reach into your live website),
so add this one line to your project's own website stylesheet:
(see this project's own docs/stylesheets/extra.css for a working
example). If your project doesn't yet use .pdf-only for anything, there's
nothing to add until it does.
Copyright text¶
copyright (a native Zensical setting, not one of prodockit's own -
see Building a single file above) feeds both
the live website's own footer and the PDF's running footer, by
default - whatever you set once shows, unchanged, in both places.
pdf_copyright (under [project.extra]) overrides it for the PDF's own
footer only, leaving the website's copyright text completely untouched
either way. Unset by default, so an existing project's PDF and website
keep matching unless you deliberately add it - useful when you want the
PDF to show something the website version wouldn't make sense showing
(or vice versa), without having to keep two near-identical strings in
sync by hand.
Both copyright and pdf_copyright accept a real HTML fragment, the
same as Zensical's own website-side copyright setting already does -
a real <a href="../..."> link renders as a real, clickable link in the
PDF too, not flattened to plain text. Use a real <br> for a forced
line break. The obvious use: crediting the tools your report was built
with, on their own second line, without touching the copyright/licence
text itself:
[project.extra]
pdf_copyright = 'Author: Jane Doe. Licensed under the MIT License.<br>Made with <a href="https://zensical.org/">Zensical</a> and <a href="https://buckwem.github.io/prodockit-extensions/">prodockit</a>.'
Links and line breaks remain real PDF content rather than being flattened to plain text. Contributors changing the footer implementation should read PDF pipeline and API.
The equivalent website-side credit (if your project wants a "Made with
Zensical and X" line on the live site too) isn't a prodockit setting
at all - prodockit has no reach into the website's own Jinja partials.
It's a Zensical theme override instead: with custom_dir set (see
Zensical's own docs), drop your own overrides/partials/copyright.html
based on the bundled version, adding a second credit line after the
existing "Made with Zensical" one.
Why the bottom margin is deeper than the others
The footer is top-aligned in the bottom margin and grows downward as it gains lines, so whatever the margin does not use is the space left before the paper edge. A two-line footer at a 2cm bottom margin ends about 6.1mm from the edge - inside the 5-6.4mm many consumer and office printers cannot print at all, so the second line risks being cropped even though the PDF itself is correct.
pdf_margin_bottom therefore defaults to 2.5cm, which leaves about
11.1mm. A footer of three or more lines needs more again: set
pdf_margin_bottom explicitly and check the result on paper, not just
on screen.
Cover page markers¶
Drop any of these literal strings into your nav's index page - typically
a cover page, e.g. wrapped in {.pdf-only} as in the example above - and
prodockit pdf substitutes a real value once that page's HTML exists, no
configuration needed:
| Marker | Becomes |
|---|---|
{WORDCOUNT} |
The site-wide word count (the same value a 54,092 website macro variable would show), so a submission's PDF and its live website page never disagree. |
{REPOURL} |
The git-detected repo URL (the same value https://github.com/buckwem/prodockit-extensions gives a website macro). |
{RELEASE} |
The latest published GitHub/GitLab release tag (e.g. v1.2.0). The whole line containing this marker is dropped instead if there isn't one - most projects never publish a release at all, so nothing shows a bare "Release: " label by default. |
prodockit |
Your project's own site_name, substituted literally - prodockit pdf never evaluates Jinja, so the exact same prodockit text a website macro variable uses works here too, one line of markdown for both outputs. |
Skipped entirely for a --markdown-file-scoped build, or if your nav
has only one page - there's no separate "cover" vs "content" to compute a
word count from either way.
Landscape pages¶
Anything too wide for a portrait page - a reference table, a diagram, a
chart - can be given landscape page(s) of its own instead: wrap it (and
its own caption) in <div class="landscape-page" markdown="1">, using
md_in_html
(the markdown="1" is required - without it, the content inside is left
as literal, unconverted text):
<div class="landscape-page" markdown="1">
**A wide reference table**
| ID {: width="15%" } | Description {: width="70%" } | Due {: width="15%" } |
|---|---|---|
| 1 | ... | Q1 |
</div>
It is not limited to tables. A Mermaid diagram, an image, a wide code block - whatever is in the block gets the page:
<div class="landscape-page" markdown="1">

</div>
The content prints on its own landscape-sized page(s) - the same configured page size, width and height swapped. A page break is always forced immediately before and after the block, so it never shares a page with anything else.
Content longer than one page simply carries on. A table spanning
several landscape pages repeats its header row on every one of them,
exactly as it would on a portrait page - measured directly: a 90-row
table produced five landscape pages, each carrying the header (see
prodockit.tables for the width syntax above,
which works the same way here).
A document mixing portrait and landscape pages prints without any special handling - a PDF reader rotates each page to fit the paper on its own.
This is PDF-only - the same wrapped content renders completely normally
on the live website, the same way .web-only content
elsewhere in this project only ever affects one of the two outputs.
Double-sided (duplex) printing¶
Set pdf_double_sided = true under [project.extra] for a document meant
to be printed and bound on both sides - a book or handbook, rather than a
web-printed report. Left-hand (verso) and right-hand (recto) pages mirror
their header/footer content and page margins, and every numbered heading
starts its own recto page:
[project.extra]
pdf_double_sided = true
pdf_margin_inner = "3cm" # spine side - wider, to leave room for binding
pdf_margin_outer = "1.5cm" # fore-edge (outer) side
pdf_margin_inner/pdf_margin_outer replace pdf_margin_left/_right
once pdf_double_sided is on - the "inner" (spine) side is the left
margin on a recto page but the right margin on a verso page, and vice
versa for "outer" (fore-edge), so a single pair of settings covers both
without you having to think about which physical side is which for any
given page. pdf_margin_top/_bottom are unaffected either way.
Every corner of the running header/footer mirrors between recto and verso, keeping the chapter title and page number on the outer, fore-edge corner and the site name/copyright on the inner, spine-side corner, whichever physical side that happens to be for a given page - confirmed directly, by rendering a real double-sided document and inspecting facing pages, that this is how it actually looks.
Every numbered heading (chapter start) also always starts on its own
recto page - a blank page is inserted automatically if the previous
chapter ended on an odd page, exactly like the blank pages you'd expect at
the start of each chapter in a real printed book. This needs no
configuration; it's part of what pdf_double_sided turns on.
A page's own front matter recto_title: "Short Title" overrides that
page's own running header text with a shorter title, from the next page
onward (the heading's own page still shows its full title) - handy when a
chapter's real title is too long to comfortably fit the running header:
---
recto_title: "Ch. 1"
---
# Chapter One: A Rather Long Title That Wouldn't Fit In A Running Header
This setting is meaningful whether or not pdf_double_sided is on - the
running chapter title appears in the header either way, just in a
different corner.
Bundling source into a PDF¶
The prodockit source-bundle command builds a second PDF - your Markdown content and
zensical.toml, one file per page - for a submission that needs the
underlying source alongside the rendered document:
This is separate from prodockit pdf because the two files serve different
purposes: one is the rendered document and the other is a record of its
source. Run each command only when you need that output.
Writes docs_dir/source_bundle.pdf by default, so Zensical serves it
with no separate copy step. Override with pdf_source_bundle_output
under [project.extra]:
The running header's report name is your site_name; the page size is
pdf_page_size - the same setting prodockit pdf reads, so both PDFs a
project publishes share one physical page size rather than needing it
set twice.
Every file is rendered in 8pt Courier with wrapped lines (a genuinely long line wraps rather than running off the page or getting cut off), starting on its own page, with a running header (that page's own file path on the right) and a "Page N of M" footer.
Which files are included: every .md file under docs_dir (recursively)
plus zensical.toml itself - your documentation's own source, not the
project's tooling around it. A file that isn't valid UTF-8 text is
silently skipped rather than failing the build, though in practice that
never applies here (Markdown and TOML are always text).
Need to bundle more than the document source?
The command deliberately includes only Markdown pages and
zensical.toml. Contributors building a custom bundle can use the
Python API described in PDF pipeline and API.
Table of contents and bookmark outline¶
A PDF built by prodockit pdf has two separate tables of contents, built by
two different tools:
- The Table of Contents page itself (
pdf_include_table_of_contents/pdf_table_of_contents_titleabove) - generated by Pandoc from every heading it sees, viapandoc.structure.table_of_contents(). - The bookmark outline - the navigation pane a PDF reader shows down
the side, e.g. Adobe Reader's or a browser's own PDF viewer's sidebar.
This is built separately by WeasyPrint, which bookmarks every
h1-h6straight from its own UA stylesheet.
prodockit.headings's
unlisted class (Pandoc's own, not a prodockit invention) keeps a heading
off the Table of Contents page. It has no effect on the bookmark outline -
an .unlisted heading still becomes an outline node, and because outline
nesting follows heading level, every later heading of lower level nests
underneath it instead of under its real chapter. Add unbookmarked too
(prodockit's own generated CSS gives h1.unbookmarked-h6.unbookmarked
bookmark-level: none) to remove a heading from the outline as well:
Nothing prodockit pdf generates itself carries unbookmarked - the
back-of-book index's own A/B/C letter headings, the Table of Contents
title, and every cover-page heading are all unnumbered unlisted (so a
reader can still navigate to them) but deliberately not unbookmarked,
since they belong in the outline. Reach for unbookmarked yourself only
for a heading that shouldn't be there at all - e.g. an illustrative
heading in documentation that demonstrates real rendered output rather
than a code sample (see this project's own
docs/extensions/refs.md).
Back-of-book index¶
A traditional, two-column back-of-book index, generated from every
\index{Term} marker when include = true in the prodockit.index
extension's settings. It is PDF-only; there is no equivalent on the live
website. Marking terms, turning the setting on, and what the
generated page itself looks like are all covered together in
Index (PDF only),
since (unlike every other feature on this page) marking and generation
are two different extensions - see that page for the full syntax and
worked examples. Contributors scripting a custom build can read about the
two-pass implementation in
PDF pipeline and API.
Contributors calling the Python API or changing the HTML, Lua, CSS, Mermaid, source-bundle, or index stages should use PDF pipeline and API.
Limitations and workarounds¶
prodockit.pdf pipes your site's own rendered HTML through Pandoc and
WeasyPrint to produce the PDF - two tools with their own reader/writer
quirks and no JS engine, quite different from a browser rendering your
live website. See Known limitations
for the confirmed limitations this shapes in prodockit.pdf.html/.lua/
.css, and the workaround each one gets.
For supported tool versions, platforms, and the pre-1.0 stability boundary, see Support and compatibility.