---
title: A pandoc pipeline for project documentation
date: 2026-08-29
published: 2026-08-29
tags: ['latex', 'pandoc', 'documentation', 'ci', 'technology']
references:
  - https://pandoc.org/MANUAL.html
  - https://tectonic-typesetting.github.io/
description: "Rendering maintained markdown into branded PDF documents, slide decks and Word files with pandoc and LaTeX, and the silent failure modes that shaped how the pipeline is put together."
---

Some documentation has to live as markdown in a repository, get reviewed like code, and still leave the building as a branded artefact. Three shapes come up repeatedly: an A4 document with a table of contents and numbered sections, a 16:9 slide deck, and occasionally a Word file because somebody downstream needs to track changes in it.

The pipeline that does this is small. Pandoc, a TeX engine, one shell script. What makes it worth writing down is that almost every part of it has a failure mode that produces a plausible-looking document rather than an error. A colour reverts to a default, a bold weight does not take, an arrowhead vanishes on conversion, a subtitle goes missing from a cover page. All of it compiles cleanly and exits zero, so the interesting work turned out to be making wrong output detectable rather than making right output look good.

An earlier version ran on Quarto. Rewriting it as plain pandoc made it shorter, faster, and much easier to explain to someone else.

## The shape of it

The repository holds markdown, brand assets, LaTeX templates, and one script that ties them together.

```
├── tools/
│   ├── render.sh                     the driver: discovery, config, pandoc
│   ├── check-structure.sh            cross-reference and asset validator
│   └── theme/
│       ├── palette.tex               shared: colours, fallbacks, conditionals
│       ├── fonts.tex                 shared: font resolution
│       ├── document-template.tex     pandoc template, article
│       ├── slide-template.tex        pandoc template, beamer
│       └── document-aurora-template.tex   optional per-theme variant
├── brand/
│   ├── colours.json                  per-theme palettes
│   ├── fonts/*.otf                   bundled, referenced by path
│   └── logo/
├── clients/<name>/meta.yaml          per-client name and logo
├── content/                          the markdown
└── dist/                             output, gitignored
```

Versions matter here more than they usually do, because behaviour has changed underneath this pipeline more than once. What follows was checked against pandoc 3.6.4, Tectonic 0.17.0 and librsvg 2.61.4.

The driver is a bash script. It works out what to render, assembles configuration, and calls pandoc once per output. For a document under the template design, that call looks like this in full:

```bash
pandoc "$src" \
  --from markdown+raw_tex+pipe_tables \
  --to pdf \
  --template="tools/theme/document-${theme}-template.tex" \
  --pdf-engine=tectonic \
  --top-level-division=section \
  --toc --toc-depth=2 \
  --listings \
  --wrap=auto \
  -M title="$title" \
  -M author="$author" \
  -M date="$date" \
  -M numbersections=true \
  -M colorlinks=true \
  -V palette="$palette" \
  -V client-name="$client_name" \
  -V client-logo="$client_logo" \
  -o "$out"
```

and for a deck:

```bash
pandoc "$src" \
  --from markdown+raw_tex \
  --to beamer \
  --template="tools/theme/slide-${theme}-template.tex" \
  --pdf-engine=tectonic \
  --slide-level=2 \
  --listings \
  --wrap=auto \
  -V palette="$palette" \
  -o "$out"
```

Everything below is why each of those flags is there.

`--from markdown+raw_tex` deserves an early mention because it is easy to miss. The raw LaTeX blocks used throughout this pipeline for centring, sizing and callouts only work when the reader has `raw_tex` enabled. Without it pandoc treats the block as literal text and prints the backticks into the document. The docx path needs `raw_attribute` for the same reason.

## Two ways to control the output

`--include-in-header=preamble.tex` appends your LaTeX to pandoc's built-in `default.latex` template. Packages can be loaded, `\maketitle` redefined, headings restyled. What is not possible is seeing pandoc's own variables, because the fragment is being pasted into a template you do not control. `--template=mine.tex` replaces that template entirely, and `$title$`, `$if(client-logo)$` and `$body$` become available throughout.

So `--include-in-header` leaves nothing to parameterise with. Brand strings get typed into the `.tex` file, and a hardcoded `pdftitle={Some Platform: Architecture Documentation}` in a shared preamble is wrong for every document except the one it was written for.

It also carries a trap. `--include-in-header` on the command line overrides the `header-includes` metadata field, so a document declaring this in its frontmatter:

```yaml
header-includes:
  - \def\docsubtitle{Architecture and Design Documentation}
```

loses it entirely. No warning, no error, exit code zero. A `\providecommand{\docsubtitle}{}` fallback in the theme then supplies an empty string, and the cover renders with the subtitle line missing. A cover with no subtitle looks like a design decision rather than a bug.

Working around that means generating a header file of `\def` statements and passing it ahead of the theme, so the `\providecommand` fallbacks do not clobber it. Which is template variables reimplemented in bash, and only ever necessary because the pipeline started in the wrong place. Everything below assumes real templates.

A `--defaults` file is worth knowing about separately. It holds the whole invocation as YAML, and repeatable options including `--include-in-header` combine with command-line values rather than replacing them. It does not remove the choice above, but it does make the driver thinner.

## Metadata, variables, and which one to reach for

Pandoc has two ways to pass values in and they are not interchangeable, which took an embarrassingly long time to internalise.

`-M key=value` sets document metadata. Pandoc itself reads it, and so do the built-in templates. Things like `numbersections`, `colorlinks`, `documentclass`, `fontsize`, `lang` and `toc-title` are interpreted, meaning pandoc changes its behaviour based on them.

`-V key=value` sets a template variable. It is a string handed to whatever template is in play, and pandoc attaches no meaning to it.

The failure mode is passing `-V numbersections=true` and getting unnumbered sections. The variable is set, the template can see it, and pandoc's numbering logic never consults it because that logic reads metadata. No error, and the document looks like someone chose not to number the sections.

The rule that has held up: `-M` when pandoc or its default template needs to interpret the value, `-V` when it is a string your own template interpolates. Brand colours, palettes, client names and logo paths are all `-V`. Everything structural is `-M`.

For anything more than a handful of values, a metadata-only YAML file passed as the first positional input beats a wall of `-M` flags:

```yaml
---
title: "Data Platform on Azure: Solution Design"
subtitle: "Deployment for the analytics programme"
author: "Digital and Data Platforms"
date: "July 2026"
subject: "Solution design"
keywords: [Azure, Solution Design, Analytics]
lang: en-AU
toc-title: "Contents"
linkcolor: brandberry
---
```

```bash
pandoc template/metadata.yaml src/*.md ...
```

It is reviewable in a diff, it lives next to the content, and it does not need escaping.

## Configuration that does not live in the LaTeX

With templates, brand configuration can sit in JSON and never touch a `.tex` file.

```json
{
  "themes": {
    "_comment": "Keys are the LaTeX colour names used by that theme's templates; values are hex without #. The pipeline injects these as \\definecolor overrides AFTER the template's built-in defaults, so values here always win.",
    "classic": {
      "Primary":   "007788",
      "Secondary": "66BBBB",
      "CodeBg":    "F2F6F7",
      "TableHead": "007788"
    }
  }
}
```

The keys are the LaTeX colour names themselves, which removes a mapping table that would otherwise need keeping in sync. `jq` turns the selected theme into `\definecolor` calls:

```bash
palette=$(jq -r --arg t "${theme:-classic}" '
    .themes[$t] // {} | to_entries[]
    | select(.key | startswith("_") | not)
    | "\\definecolor{" + .key + "}{HTML}{" + .value + "}"
  ' "$BRAND_FILE" | tr '\n' ' ')
```

That string arrives as `-V palette=...` and the template emits it immediately after the theme include:

```latex
\input{tools/theme/palette.tex}
$if(palette)$ $palette$ $endif$
```

The mechanism is override by ordering rather than substitution, and it is the part of this pipeline I would carry into anything similar. The theme keeps a full set of working defaults, so it still compiles when someone runs `xelatex` on it directly to debug something. The JSON then redefines whatever it names, so configuration always wins, and retuning a palette became a JSON edit and nothing else.

The `startswith("_")` filter lets the schema document itself inside the file it describes. A `_comment` key gets skipped rather than turned into a broken `\definecolor`, so the contract sits where someone editing colours will see it.

### Themes swap templates, not just colours

A theme is more than a palette. Selecting one also selects a template, resolved by filename:

```bash
template="$TEMPLATE_DIR/${output}-template.tex"
if [ -n "$theme" ] && [ "$output" != "docx" ]; then
  themed="$TEMPLATE_DIR/${output}-${theme}-template.tex"
  if [ -f "$themed" ]; then
    template="$themed"
  else
    warn "theme '$theme' has no $output template, using default"
  fi
fi
```

The JSON key and the template filename are independent, which is the sharp edge. A document declaring `theme: aurora` where `slide-aurora-template.tex` does not exist gets the classic layout wearing aurora colours, and a warning rather than an error. Right default for a pipeline where a missing deck template should not block a document render, as long as the warning gets read.

### The third override layer

Client details come from a small file per client:

```yaml
name: Acme Corporation
short_name: Acme
logo: clients/acme/logo.pdf
```

Parsing it with `grep -m1 '^logo:' | sed ...` rather than a YAML library works because the schema is flat and unquoted, and breaks the day someone indents a key. A reasonable trade for two fields, as long as it is a deliberate one.

The natural extension, which I designed and never wired up, is a per-client colour override sitting after the theme palette. Template defaults, then theme JSON, then client overrides, each redefining what it names. The mechanism exists and only the third call site is missing.

A wrong logo path can either render an unbranded document silently or stop the build. Hard-failing is better, because a missing logo is not a formatting preference.

## What decides the output type

Two designs work here, and which one fits depends on the shape of the content.

Frontmatter dispatch suits a collection of individually different documents. Each file declares what it is:

```yaml
output: slide       # slide | document | docx
theme: aurora       # optional, selects <output>-<theme>-template.tex
client: acme        # optional, loads clients/acme/meta.yaml
skip: true          # optional, explicit exclusion
```

The driver runs `pandoc -t json` over the file and reads metadata out of the AST:

```bash
get_meta() {
  local json="$1" key="$2"
  printf '%s' "$json" | jq -r --arg k "$key" '
    .meta[$k] // empty
    | if   .t == "MetaBool"    then (.c|tostring)
      elif .t == "MetaString"  then .c
      elif .t == "MetaInlines" then [.c[] | if .t=="Str" then .c elif .t=="Space" then " " else "" end] | join("")
      else "" end'
}
```

The shapes are worth being precise about, because I had this wrong for a while. Both `output: slide` and `output: "slide"` arrive as `MetaInlines`, since quoting makes no difference. `MetaString` comes from `-M key=value` on the command line, not from frontmatter at all:

```
$ printf -- '---\na: slide\nb: "slide"\nc: true\n---\n\nx\n' | pandoc -t json | jq -c '.meta|map_values(.t)'
{"a":"MetaInlines","b":"MetaInlines","c":"MetaBool"}
```

The branch that matters is `MetaBool`, because missing it means `skip` never works, and the symptom is a file rendering that was supposed to be ignored. The `else ""` fallback matters too: `MetaList`, `MetaMap` and `MetaBlocks` all land there and read as empty, so a `client:` written as a nested map silently resolves to no client.

Treating a missing `output:` as skip makes this design safe to point at a whole repository. Planning notes, READMEs and meeting minutes are ignored by default, with no exclusion list to go stale.

Convention dispatch suits content sets that already have a structure. The directory decides what its contents become:

```
courses/<slug>/
├── course.conf              required, plain shell assignments
├── handbook/NN-*.md         concatenate into one A4 document
├── sop/XXX-NN-*.md          concatenate into a procedure pack
├── decks/<name>.md          each becomes its own deck
└── assets/svg/*.svg
```

Discovery collapses to a few lines, and adding a content set means adding a folder rather than editing the script:

```bash
list_courses() {
  local d
  for d in "$COURSES_DIR"/*/; do
    [ -f "${d}course.conf" ] || continue
    basename "$d"
  done
}
```

Concatenation order is filename order, which is the whole reason for the numeric prefixes: `find "$src" -maxdepth 1 -name '*.md' | sort` and nothing cleverer.

Concatenating many files into one document needs `--top-level-division=section`. Each source file opens with a single `#`, and without that flag pandoc maps `#` to `\chapter`, which the `article` class does not have. With it, one file becomes one numbered section and the structure comes out as written.

Table of contents depth and numbering vary by output, which is a good argument for keeping them in the driver rather than in the content. A handbook wants `--toc-depth=2` and numbered sections. A pack of standard operating procedures wants `--toc-depth=1` and numbering switched off, because each procedure already carries its own identifier and a second number next to it reads as a mistake.

Numbering has one trap. Setting `\setcounter{secnumdepth}{0}` inside a template silently overrides a command-line `--number-sections`, because the template runs later. Numbering needs to live in one place, and the template will win any disagreement.

Under convention dispatch the body content can carry no frontmatter at all. Per-document configuration goes into a file the driver sources directly:

```bash
COURSE_TITLE="Version Control for Analysts"
COURSE_CLIENT=""            # blank for generic material
COURSE_LOGO=""              # relative to this directory, blank for none
COURSE_AUTHOR="Platform Engineering"
HANDBOOK_SUBTITLE="For people who have never used it"
SOP_TITLE="Procedures"
NUMBER_SECTIONS="true"
```

Defaults get set before the `source`, so a partial or empty config still renders. Sourcing it inside a subshell keeps one content set from leaking settings into the next when the driver renders several in a row, which is the sort of bug that only appears once there are two of something.

Feeding the client name through to the PDF subject field turned out to be worth doing, because a file that gets separated from its folder still says who it was written for.

## Splitting the theme

Four files rather than two.

```
tools/theme/
├── palette.tex     colours, \providecommand fallbacks, conditionals
├── fonts.tex       font resolution only
├── doc.tex         A4 article: geometry, titlesec, fancyhdr, cover
└── deck.tex        beamer: headline, frametitle, footline, dividers
```

`doc.tex` and `deck.tex` each `\input` the first two. The alternative is duplication, and duplication here drifts in a way that hides. A package list, font block and set of pandoc shims copied into every theme file is forty-odd lines maintained in parallel. A palette copied into two theme files lets the two outputs disagree on a colour by one hex digit, and since a slide deck and an A4 document are rarely opened side by side, a difference like that can survive for a long time.

`palette.tex` is also where the conditionals belong:

```latex
\providecommand{\docsubtitle}{}
\providecommand{\docclientlogo}{}

% \hasclientlogo{<then>}{<else>}
\newcommand{\hasclientlogo}[2]{\ifx\docclientlogo\empty #2\else #1\fi}
```

The empty-default idiom is what lets a single cover page handle both a co-branded client document and a generic one, without the driver choosing between two templates.

## Documents

The A4 side is an `article` with the usual suspects: `geometry`, `titlesec` for headings, `fancyhdr` for running heads, `booktabs` and `longtable` for tables, `hyperref` last. Last matters. Under `--include-in-header` it cannot be last, because pandoc's default template calls `\hypersetup` after inserting your header, so link colours have to be deferred with `\AtBeginDocument{\hypersetup{...}}`. Owning the template makes the problem disappear.

A handful of document-level settings do most of the work of making concatenated markdown read like a real document rather than a long web page.

A page break before every top-level section is what separates the two. Markdown has no concept of it and `\newpage` scattered through content is unpleasant to maintain, so it belongs in the preamble:

```latex
\usepackage{etoolbox}
\pretocmd{\section}{\clearpage}{}{}
```

Figures drift. Pandoc emits floating `figure` environments, and LaTeX will happily move one several pages from the paragraph that introduces it. In a document where every figure is referenced by the sentence above it, that is always wrong:

```latex
\makeatletter\def\fps@figure{H}\makeatother
```

Tables need help too. Pandoc's `longtable` output is dense by default, and a small amount of breathing room applied globally beats editing content:

```latex
\usepackage{etoolbox}
\AtBeginEnvironment{longtable}{\small\renewcommand{\arraystretch}{1.25}}
```

Classification banners come up constantly in government and enterprise work, and they are a hard requirement rather than a nicety. `fancyhdr` handles both ends:

```latex
\fancyhead[C]{\footnotesize\color{brandgrey}OFFICIAL: SENSITIVE}
\fancyfoot[C]{\footnotesize\color{brandgrey}OFFICIAL: SENSITIVE}
```

`fancyhdr` has no colour option for its rules, so recolouring the header line means patching the command:

```latex
\usepackage{etoolbox}
\patchcmd{\headrule}{\hrule}{\color{brandpink}\hrule}{}{}
```

Table of contents styling comes from `tocloft`. One thing to watch: `\setcounter{tocdepth}{2}` in the preamble and `--toc-depth=2` on the command line are two levers on the same mechanism, and they can disagree. Picking one is easier than remembering which wins.

Page footers showing `page 3 of 12` need a total. `zref-lastpage` with `zref-user` is the portable option, since `lastpage.sty` is absent from some local TeX installations even where CI has it, and discovering that mid-render is tedious.

## Decks

Beamer is a different enough target that sharing a palette is about as far as the sharing goes.

Three settings determine the structure of a deck, and one of them is the single most consequential line in the whole beamer setup:

```yaml
aspectratio: 169
classoption: t
slide-level: 2
```

`slide-level: 2` makes `##` a slide and `#` a section divider. Without it every `#` becomes a slide, and any `\AtBeginSection` divider hook never fires because there are no sections. This one is a pandoc writer setting rather than a template setting, so it applies whichever design is in play, and it can equally be passed as `--slide-level=2`.

`classoption: t` top-aligns frame content. Beamer centres vertically by default, which makes short slides look randomly positioned relative to long ones.

`aspectratio: 169` is where the two designs diverge. Under `--include-in-header`, pandoc's default beamer template reads the metadata and passes it to `\documentclass`. Under a custom template, the template carries its own `\documentclass[aspectratio=169,10pt]{beamer}` and the frontmatter value is ignored with no warning. A deck asking for 4:3 renders at 16:9 and nothing says why. Anything belonging to `\documentclass` becomes the template's business the moment you own the template.

Section dividers are worth the small amount of machinery:

```latex
\AtBeginSection[]{%
  \begin{frame}[plain,noframenumbering]
    ... full-bleed divider using \insertsectionhead ...
  \end{frame}}
```

`plain` drops the chrome, and `noframenumbering` keeps dividers out of the frame count so the numbering matches what a reader would count.

The three chrome hooks are `headline`, `frametitle` and `footline`. Each needs guarding so the title slide comes out clean:

```latex
\setbeamertemplate{headline}{%
  \ifnum\insertframenumber>1
    \begin{tikzpicture}[overlay,remember picture] ... \end{tikzpicture}%
  \fi}
```

Decorations attached to `frametitle` have to be drawn with `[overlay]`, otherwise the tikz picture occupies space on the line and pushes the title into a wrap or strikes through it.

Two more. `\usefonttheme{professionalfonts}` is needed or beamer substitutes its own maths and sans fonts over a careful fontspec setup. And frame numbering uses beamer's counters, so a footer reads `\insertframenumber\,/\,\inserttotalframenumber` and the `zref-lastpage` mechanism from the document side does not apply.

Logos need a second version for dark backgrounds. An ink-coloured wordmark on a full-bleed dark divider disappears entirely, which shows up only on divider slides and is easy to miss when scrolling a deck.

## Cover pages

Three approaches, and they suit different working arrangements.

Drawing the cover in TikZ inside the template keeps everything in one place and parameterises cleanly, since the title, subtitle, client name and logo are all template variables. It is the right default when whoever maintains the pipeline also owns the design.

Injecting a body prelude works under `--include-in-header`, where there is no template to edit:

```bash
pandoc ... \
  --include-in-header=<(echo '\renewcommand{\maketitle}{}') \
  --include-before-body=template/cover-body.tex
```

The process substitution neutralises the default title block without a temporary file, and `cover-body.tex` then draws whatever it likes. It is also a neat demonstration of the ordering rule from earlier, since that one-line header has to arrive before anything that depends on it.

Treating the cover as external artwork is the third, and it is the one to reach for when a designer owns the cover. The cover is an SVG, rendered to PDF for the LaTeX path and PNG for the Word path, then dropped in whole:

```latex
\usepackage{pdfpages}
\includepdf[pages=1]{template/cover.pdf}
```

There is a trap in generating it. `rsvg-convert` does not follow external image references, so a logo included by path renders as nothing at all, silently. Inlining it as a data URI first is the fix:

```bash
LOGO_B64=$(base64 -w0 assets/logo-white.png)
sed "s|LOGOPLACEHOLDER|data:image/png;base64,$LOGO_B64|" \
  template/cover.svg > template/.cover-embedded.svg
rsvg-convert -f pdf -o template/cover.pdf template/.cover-embedded.svg
rsvg-convert -f png -w 1240 -o template/cover.png template/.cover-embedded.svg
rm -f template/.cover-embedded.svg
```

The placeholder token sits in the committed SVG's `xlink:href`, so the file opens correctly in a vector editor while the pipeline substitutes the real image at build time. The one source then produces both covers, which is most of the argument for doing it this way.

## Word output

Word is a separate pipeline wearing the same clothes, and the most useful thing to know is that almost nothing described above applies to it. The docx path drops the template, all the `-V` brand variables and the palette. Templates are a LaTeX concept. Everything the driver assembles for a PDF is inert here.

What controls docx styling is a reference document, and the usual approach is committing a `reference.docx` that somebody branded in Word once. It works and it is a binary blob in the repository that cannot be diffed, reviewed or explained.

Generating it from a script is better on every axis except initial effort:

```python
# pandoc --print-default-data-file reference.docx > base.docx
# unzip, patch word/styles.xml, rezip
```

The patch targets `docDefaults` for the base font, then `Heading1` through `Heading4`, `Title`, `Subtitle`, `TOCHeading`, `VerbatimChar`, `SourceCode` and `Hyperlink` for the rest. It is about a hundred lines of regex against XML, which is not elegant, and it turns Word branding into something reviewable that regenerates from scratch when the palette changes.

The table of contents needs different handling too. Pandoc's `--toc` produces a static list of headings in docx, which does not update, does not link, and goes stale the moment anyone edits the document. A native Word TOC field is a raw OpenXML block instead:

````markdown
```{=openxml}
<w:sdt><w:sdtContent><w:p>
  <w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>
  <w:r><w:instrText xml:space="preserve">TOC \o "1-2" \h \z \u</w:instrText></w:r>
  <w:r><w:fldChar w:fldCharType="separate"/></w:r>
  <w:r><w:t>Right-click and choose Update Field.</w:t></w:r>
  <w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p></w:sdtContent></w:sdt>
```
````

That needs `--from markdown+raw_attribute`. Word prompts to update fields when the document opens, which is worth mentioning in a README before somebody reports it as a bug.

Titles are omitted from the metadata on this path so the branded cover image lands as page one, with the TOC field immediately after it. Syntax highlighting comes from `--highlight-style` rather than `--listings`, since `listings` is a LaTeX package and means nothing here.

## Fonts

This one cost real time. Setting a font by family name looks correct:

```latex
\setsansfont{Nimbus Sans}
```

and fontconfig can hand XeLaTeX the Type 1 face instead of the OTF. The good outcome is an error along the lines of `Cannot proceed without the font: NimbusSans-Bold.t1`. The bad outcome is bold rendering at regular weight with no complaint, which nobody catches on screen and everybody notices in print.

The fix is addressing files rather than families. Setting a path and extension once, then naming faces by suffix, keeps it readable:

```latex
\defaultfontfeatures{Path = brand/fonts/, Extension = .otf}
\setsansfont{Inter}[
  UprightFont     = *-Light,
  BoldFont        = *-Medium,
  ItalicFont      = *-LightItalic,
  BoldItalicFont  = *-MediumItalic ]
\defaultfontfeatures{}   % reset, or it leaks into later font calls
```

That reset matters. Leaving `Path` set means the next `\setmonofont` call goes looking in the wrong directory.

Where fonts come from the system rather than the repository, a fallback cascade covers the distributions that disagree on paths:

```latex
\IfFileExists{/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf}{%
  ... Debian path ...
}{%
  \IfFileExists{/usr/share/fonts/urw-base35/NimbusSans-Regular.otf}{%
    ... Fedora path ...
  }{%
    \setsansfont{Nimbus Sans}%  last resort, family name
  }%
}
\renewcommand{\familydefault}{\sfdefault}
```

Bundling the OTFs in the repository is better again. CI then needs no font packages and the render stops depending on what each machine happens to have installed. It is also easy to half-do: shipping a fonts directory while the preamble still resolves a different family by name gets neither reproducibility nor the fonts you shipped, and nothing complains. A preflight check that the intended font resolved is cheap insurance.

Two smaller things. A third weight is reachable through `FontFace` when a family has one, which is how a SemiBold becomes the bold face while the real Bold stays available for emphasis inside headings:

```latex
\setsansfont{SourceSans3}[ FontFace = {mb}{n}{*-SemiBold} ]
```

And monospace wants a different optical scale per output. `Scale=0.92` on slides against `Scale=0.94` in a document is not fussiness, it is that the sans face is set larger relative to the page on a slide, so the same mono at the same scale reads heavier. That belongs in the per-output theme file rather than the shared one.

## Diagrams

How pandoc handles an `.svg` depends on the writer, and that changed underneath me without my noticing. On pandoc 3.6.4 the LaTeX writer emits `\pandocbounded{\includesvg[keepaspectratio]{g.svg}}`, which is the `svg` package shelling out to Inkscape and needing `--shell-escape`. The docx writer is the one reaching for `rsvg-convert`. Older pandoc converted through librsvg on the LaTeX path too, which is where a lot of advice comes from, including mine.

Two things follow. Tectonic offers no shell-escape, so `\includesvg` and the fast engine are mutually exclusive. And an Inkscape dependency in CI is worse than the TeX Live install Tectonic was brought in to avoid.

Converting to PDF in the driver sidesteps both. Pandoc then sees an ordinary image, and the conversion is one place to inspect when a figure comes out wrong:

```bash
for svg in assets/svg/*.svg; do
  rsvg-convert -f pdf -o "${svg%.svg}.pdf" "$svg"
done
```

Which puts librsvg back in, deliberately this time. Authoring figures as PDF, or committing PDF alongside the source, removes the question entirely at the cost of a reviewable diff.

The figure contract that grew out of this is a portability policy rather than a list of things librsvg cannot do. Checking again while writing this, librsvg 2.61.4 renders gradients, honours `<style>` blocks with `class`, and draws `marker-end` arrowheads, so several of the rules are stricter than it now requires:

> Presentation attributes only. No `<style>` blocks, no `class`, no `style="..."`. No gradients, filters, masks, patterns, or `<use>`. No external references. Text positioned on its baseline, do not rely on `dominant-baseline`. Avoid `marker-end`. Explicit `width` and `height` with a matching `viewBox`. A white background rectangle as the first child. Nothing may touch or overflow the canvas edge, because the converter clips the overflow and reports no error.

Keeping the strict version is still defensible, because the thing that bit was not any single feature. It was that converters change, and a figure set written to the intersection of what all of them handle survives that.

The edge-clipping rule is the one that catches people: a diagram correct in a browser loses its rightmost label in the PDF, with no error to search for. Fixed canvas widths, 1200 units full-width and 800 narrow, keep figures consistent, and the SVG `font-family` matches the body text so diagrams do not read as pasted in.

The same problem exists in text. Box-drawing characters like `├`, `│` and `└`, and arrows like `→` and `▼`, are absent from Latin Modern Mono and render as blank space rather than a missing-glyph box. An ASCII tree in a code block comes out as an indented list with nothing joining it.

Centring and sizing happen through raw LaTeX passthrough rather than a filter:

````markdown
```{=latex}
\begin{center}
```
![](assets/svg/hero-flow.svg){width=95%}
```{=latex}
\end{center}
```
````

Styled text has to sit entirely inside one raw block. Splitting `{\small` and its closing brace across two blocks makes pandoc print the braces literally.

Image paths are repo-root relative, because that is where pandoc runs. `--resource-path` is the alternative, though anchoring the script is one line and also covers the `\input` statements in the themes, which `--resource-path` does not.

Lua filters never earned their place. The hook exists, raw blocks covered every case, and the one filter that got written turned out to be leftover scaffolding for an HTML output that no longer exists.

## Code blocks, tables and typography

Two routes exist for code. With `--listings`, pandoc hands code to the LaTeX `listings` package and styling lives in `\lstset`. Without it, pandoc emits its own `Shaded` and `Highlighting` environments and styling means redefining those.

The choice is pipeline-level rather than per-document, because it decides where the code palette lives: `\lstset` and `\lstdefinestyle` under listings, a `Shaded` redefinition plus a highlight style under the other. Switching later means rewriting whichever one exists.

Restyling `Shaded` needs a guard. Pandoc only defines the environment when the document contains at least one highlighted block, so an unguarded `\renewenvironment` breaks every document that happens to have no code in it:

```latex
\AtBeginDocument{%
  \@ifundefined{Shaded}{}{%
    \renewenvironment{Shaded}{\begin{snugshade*}}{\end{snugshade*}}%
  }%
}
```

Anything using `\@` in a preamble needs `\makeatletter` and `\makeatother` around it. A bare `\@ifpackageloaded` outside that wrapping calls the spacefactor command instead, and the crash lands at `\begin{document}`, well away from the line responsible.

Long identifiers containing underscores overrun the margin, because `\texttt` will not break at an underscore. Patching the command beats hand-wrapping every occurrence in the content:

```latex
\let\origtexttt\texttt
\renewcommand{\texttt}[1]{{\def\_{\char`\_\discretionary{}{}{}}\origtexttt{#1}}}
```

That patch only applies to the pandoc-highlighting route. Under `--listings` inline code comes out as `\passthrough{\lstinline!...!}` and never reaches `\texttt`, so the equivalent fix belongs in `\lstset` instead. Another consequence of the listings choice being a pipeline-level one.

Tables need `calc` and `longtable`. Pandoc emits `\real{}` and calc arithmetic for relative column widths, so without `\usepackage{calc}` a table fails with `Missing number, treated as zero` and an error location that points nowhere useful. Pandoc's own default template loads `calc` for exactly this reason, so the failure only appears in a custom template that dropped it on the way through.

That generalises. A custom template starts as a fork of `pandoc -D latex`, and every package quietly removed from it is a latent failure waiting for the first document that needs the feature. Diffing the template against `pandoc -D latex` after a pandoc upgrade is a cheap habit.

Cover titles inside a tikz node need more than `\hyphenpenalty=10000`, which is enough for body text and not inside a node:

```latex
\hyphenchar\font=-1\relax\raggedright
```

A literal `$\bullet$` inside a pandoc template has to be written `$$\bullet$$`, because single dollars are template syntax.

The theme stays dependency-light deliberately. Leaving out `tcolorbox`, `mdframed`, `fvextra` and `seqsplit` keeps the CI package set small, and a callout built from `savebox` and `colorbox` is a dozen lines:

```latex
\newsavebox{\calloutbox}
\newenvironment{callout}[1][Note]
  {\begin{lrbox}{\calloutbox}\begin{minipage}{0.94\linewidth}\textbf{#1}\par\medskip}
  {\end{minipage}\end{lrbox}%
   \begin{center}\colorbox{litegrey}{\usebox{\calloutbox}}\end{center}}
```

Watermarking every page except the cover uses `\AddToShipoutPicture`, and the obvious guard fails. A `\ifnum\value{page}>1` test misses single-page bodies, because `titlepage` resets the page counter. A flag set after the cover is reliable:

```latex
\newif\ifCoverDone
% ... after \end{titlepage}: \CoverDonetrue
\AddToShipoutPicture{\ifCoverDone ... \fi}
```

## The engine

XeLaTeX with `texlive-xetex`, `texlive-latex-extra`, `texlive-pictures` and `texlive-fonts-recommended` runs to a couple of gigabytes installed on every CI run, with `texlive-latex-extra` the bulk of it. Caching the `.deb` files helps with download time and nothing else, because the unpack still happens every time.

Tectonic is a single musl binary that fetches only the packages a document needs and caches them. Moving to it removed the TeX Live install completely. Three things needed getting right.

The version is pinned to a specific release rather than tracking latest, so a document that rendered last month still renders this month. Pinning the binary is not the same as pinning the package bundle it downloads from, though, and reproducibility depends on both. The bundle URL takes a version too, and setting it is the difference between a pinned toolchain and a mostly pinned one.

The cache key is a hash of the theme files, since those determine which packages get pulled:

```yaml
- uses: actions/cache@v4
  with:
    path: ${{ github.workspace }}/.tectonic-cache
    key: tectonic-${{ runner.os }}-${{ hashFiles('tools/theme/*.tex') }}
    restore-keys: tectonic-${{ runner.os }}-
```

`TECTONIC_CACHE_DIR` is set explicitly. The default path has varied in case across versions, `~/.cache/tectonic` against `~/.cache/Tectonic`, and caching the wrong one fails silently. Every run re-downloads the package set and every run still reports success, so the only symptom is a build that never gets any faster.

Nothing in the theme files is Tectonic-specific, which keeps `pandoc --pdf-engine=xelatex` working locally for anyone who already has TeX Live and does not want a second toolchain.

## The driver script

The first thing the script does is anchor itself to the repository root:

```bash
cd "$(dirname "${BASH_SOURCE[0]}")/.."      # or: cd "$(git rev-parse --show-toplevel)"
```

Every image path in the markdown and every `\input` in the themes is repo-root relative, because that is where pandoc runs. Without this line, running the script from its own directory breaks every `\includegraphics`, and a LaTeX missing-image error is not where anyone thinks to look first.

Then `set -euo pipefail` and a preflight check:

```bash
for tool in pandoc tectonic jq; do
  command -v "$tool" >/dev/null 2>&1 || die "$tool not found"
done
```

`gh` belongs in that list on any machine that uploads to a release, along with a token to authenticate it. Leaving it out means the render succeeds and the publish step fails, which is a worse place to find out.

Output directories get created as they are needed rather than up front, since the output tree mirrors a source tree that changes:

```bash
out="$OUTPUT_DIR/${rel%.md}.pdf"
mkdir -p "$(dirname "$out")"
```

That check sits after any `--list` branch rather than before it, because listing what a repository contains should not require a TeX engine. Failing on a machine with only pandoc is a papercut that recurs every time someone new clones the repo.

Outputs get accumulated and summarised at the end, because a run producing six PDFs across two content sets is otherwise silent about what it did:

```bash
PRODUCED+=("$out")
...
for f in "${PRODUCED[@]}"; do
  [ -f "$f" ] && printf '  %-58s %s\n' "$f" "$(du -h "$f" | cut -f1)"
done
```

Generated headers get cleaned up by a trap scoped to the function, so a failure partway through leaves nothing behind:

```bash
header="$(mktemp)"
# shellcheck disable=SC2064
trap "rm -f '$header'" RETURN
```

Rendering runs serially. Parallelism is tempting and breaks two things at once: fanning out with `xargs -P` and `bash -c` means `set -euo pipefail` does not reach the children, so a failed render gets swallowed, and any variable that was not exported arrives empty.

Those two combine into the best example of the failure this pipeline keeps producing. A brand config path assigned but not exported becomes empty in the child, the `jq` call reading it fails into an empty palette, every theme colour falls back to its template default, and the run reports success. CI passes explicit filenames so it takes the serial branch and never shows the problem. Only local full-repository renders come out wrong, and they come out looking like a deliberate choice of colour scheme.

Half a dozen documents render in a few seconds with a warm Tectonic cache. It was never the bottleneck.

## Validation

Cheap checks catch the errors a TeX engine cannot see, where the document compiles perfectly while saying the wrong thing. A structure validator runs on pull requests, in the order of 170 lines of bash:

- every `![](...)` target exists on disk
- procedure and chapter cross-references resolve to something real
- quoted document titles referenced from other documents still match their source
- naming conventions hold

Two things came out of getting this wrong first. The validator greps all files rather than only markdown, because renumbering a procedure set leaves stale identifiers in diagrams and code samples that a markdown-only check cannot see. And it runs `set -uo pipefail` without `-e`, so it counts every problem in one pass instead of stopping at the first and needing ten runs to clear.

A validator loop also needs a guard against silently running nothing:

```bash
found=0
for v in courses/*/validate.sh; do
  [ -f "$v" ] || continue
  found=1
  bash "$v"
done
[ "$found" = 1 ] || die "no validators found, this is a configuration error"
```

A glob matching nothing produces a green tick indistinguishable from a passing check.

## CI

The render runs on release rather than on every push.

LaTeX in pull request CI is slow and the failures are frequently noise. Moving to `on: release: [published]` took a toolchain install out of every pull request while the structure validator carries on running against every change.

The trade-off is real: a theme or figure regression now surfaces at release rather than at review. Two things make it tolerable. A `workflow_dispatch` input triggers the render against a branch on demand, and the pull request template carries the requirement directly, where the central checkbox reads "I have looked at the rendered PDF, not only the markdown."

Where rendering on push is worth keeping, only changed files need doing, which means a detection step ahead of the render. It has one trap worth knowing before it costs a morning. On the first push to a new branch, `github.event.before` is all zeros, so a naive `git diff` against it finds nothing, renders nothing and reports success:

```bash
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
  scan_all
else
  git diff --name-only "$BEFORE" "$SHA" | grep '\.md$'
fi
```

A pull request comment listing what would be rendered is cheap, as long as it edits itself rather than accumulating: `gh pr comment "$PR" --edit-last --body "$BODY" || gh pr comment "$PR" --body "$BODY"`.

Pandoc itself caches well, unlike apt archives. Extracting the `.deb` into `~/.local/bin` and caching that directory is a real hit, because there is no unpack step on restore. A `concurrency` group with `cancel-in-progress` stops a fast follow-up push racing the run it superseded.

Releases are scoped by tag prefix so a fix to one document set does not rebuild everything:

```bash
TAG="${{ github.event.release.tag_name }}"
target="${TAG%%/*}"        # handbook/1.4.0 renders one content set
                           # 1.4.0 renders all of them
```

Outputs go to the release and to artifacts, so non-release runs still leave something to look at:

```yaml
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: rendered-docs-${{ github.run_number }}
    path: dist/
    retention-days: 30
    if-no-files-found: ignore
```

Release assets are a flat namespace, so a nested output tree gets flattened on upload:

```bash
while IFS= read -r -d '' f; do
  safe="$(echo "${f#dist/}" | tr '/' '__')"
  cp "$f" "/tmp/$safe"
  gh release upload "$TAG" "/tmp/$safe" --clobber
  rm -f "/tmp/$safe"
done < <(find dist -name '*.pdf' -print0)
```

`find` rather than `dist/**/*.pdf` on purpose. Bash treats `**` as a plain `*` unless `globstar` is set, so against a `dist/` holding `top.pdf`, `c/one.pdf` and `a/b/deep.pdf` that glob matches `c/one.pdf` alone, uploads it, and exits zero. In a step whose whole job is flattening a nested tree, that is a poor result.

A page-count table written into the job summary with `pdfinfo` costs nothing and makes a document that suddenly halves in length visible without anyone downloading a file.

Two GitHub Actions behaviours cost an afternoon each. A job-level `permissions:` block is an allowlist, not a set of overrides, so adding `pull-requests: write` alone silently revokes `contents` and `actions/checkout` fails with `Repository not found`. And a `run: |` block ends at the first line indented less than its first content line, so a bash string containing column-zero markdown headings terminates it early while the YAML stays valid. `printf` on one line avoids the second.

## Outputs

`dist/` is in `.gitignore` and stays there. Distribution is release assets and CI artifacts. A deny-by-default gitignore with explicit allowlisting makes the intent unambiguous and stops generated files arriving by accident:

```gitignore
*
!*/
!*.md
!tools/**
!brand/**
!clients/**
!.github/**
dist/
```

The `!*/` line is not optional and I shipped this without it for longer than I would like. Git will not re-include a file whose parent directory is excluded, and a bare `*` excludes the directories as well as the files, so it never descends far enough for `!brand/**` to fire. Building that tree and running `git add -A` against the version without `!*/` stages exactly one file, the top-level `README.md`, and reports nothing wrong. Which is the same failure this whole post is about, sitting in my own `.gitignore`.

Output paths either mirror the source tree with the extension swapped, or follow `dist/<slug>/<slug>-<name>.pdf` under convention dispatch. The mirrored version has one sharp edge: a slide deck and an A4 document both produce `.pdf`, so two source files sharing a basename in one directory overwrite each other without complaint.

## Where the effort went

The thing that keeps recurring across all of this is that documentation pipelines fail quietly. A missing subtitle, a colour that reverted to a default, a bold weight that did not take, an arrowhead clipped at the canvas edge, a variable passed as `-V` when pandoc only reads `-M`. Every one of those compiles cleanly and exits zero. Almost none of the effort went into making documents look right, and almost all of it went into making the wrong output detectable.

The idea I would keep is override by ordering. Templates hold defaults that work on their own, and configuration redefines what it names afterwards. That keeps the LaTeX independently testable, which matters more than it sounds like it should, because debugging a template through three layers of shell quoting is miserable.

The thing I would do differently is not stay with `--include-in-header` for as long as I did. The generated `\def` header, the `\providecommand` fallbacks, the ordering requirement between two header files, the deferred `\hypersetup`: all of it exists to simulate something `--template` provides directly, and none of it would have been written if the pipeline had started in the right place.
