I wanted a printable PDF of agentpatterns.ai — 795 markdown files across 16 sections, maintained as a mkdocs site. This is the bug log of getting that working on Windows with pandoc + xelatex.


The goal


Why pandoc + xelatex

mkdocs generates a website. For print-quality PDF from markdown, pandoc + a LaTeX engine is the standard path. Eisvogel is the go-to pandoc LaTeX template — book layout, chapter headings, code highlighting, title page.

Alternatives I tried and abandoned:

Tool Why it failed
mkdocs-with-pdf Uses WeasyPrint which needs GTK on Windows — heavy native dependency
Browser print-to-PDF 795-page HTML file hangs Chrome before it finishes loading
Typst (default engine) Pandoc converts [@cite] markdown to #cite() typst; fails without a bibliography

xelatex is the boring choice. It works.


Bug log

WinError 206: filename too long

Pandoc accepts input files as CLI arguments. 795 paths × ~80 chars each = 63k chars. Windows caps CreateProcess at ~32k chars.

FileNotFoundError: [WinError 206] The filename or extension is too long

Fix: Use pandoc’s --defaults file. Move input-files into a YAML file and pass pandoc --defaults=tmpfile.yaml. The file path stays short; the content is unlimited.

defaults = {
    "input-files": [str(f) for f in files],   # 795 paths, in the YAML file
    "from": "markdown",
    "to": "pdf",
    ...
}
defaults_file.write_text(yaml.dump(defaults))
subprocess.run(["pandoc", f"--defaults={defaults_file}"])

lua-filters is not a valid defaults key

Error in $: Unknown option "lua-filters"

The pandoc defaults file does not accept lua-filters (plural) or lua-filter (singular) as keys. Pass lua filters as CLI arguments instead:

extra_args = [f"--lua-filter={path_to_filter}"]
subprocess.run(["pandoc", f"--defaults={defaults_file}"] + extra_args)

cp1252 decode crash

MiKTeX outputs non-cp1252 bytes to stderr. subprocess.run with text=True uses the system default encoding:

UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d

Fix: Explicit encoding:

subprocess.run([...], encoding="utf-8", errors="replace")

Bell character in source markdown

! Text line contains an invalid character.
l.100460 ...7;notify;Claude Code;Build finished^^G

A code block in the source contained a bell character (0x07 / ^^G) — likely copied from terminal output. The existing lua filter stripped control characters from Str nodes but not Code or CodeBlock nodes.

Fix: Extend the lua filter:

local CTRL = "[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]"

function Str(el)      el.text = el.text:gsub(CTRL, "") ; return el end
function Code(el)     el.text = el.text:gsub(CTRL, "") ; return el end
function CodeBlock(el) el.text = el.text:gsub(CTRL, "") ; return el end

The root cause matters: the character was in a CodeBlock, not a Str. Extending the filter to only Str would have left the bug in place.

Typst: #cite without bibliography

Typst is faster than xelatex (~8s vs ~45s per section). But pandoc converts [@label] markdown citation syntax to #cite(<label>) in typst, which requires a bibliography declaration.

Fix: Add citeproc: true to the pandoc defaults. Pandoc processes citations to inline text before generating typst output — no #cite() calls reach the typst engine.

defaults["citeproc"] = True

Mermaid diagrams rendered as code blocks

Mermaid fenced blocks (```mermaid ) pass through pandoc as code blocks. xelatex renders them as monospaced text.

Fix: Pre-process with mmdc (mermaid CLI). For each markdown file containing mermaid blocks, run:

mmdc -i input.md --outputFormat=pdf --pdfFit -o preprocessed.md

mmdc replaces mermaid blocks with PDF image references. The preprocessed file and images land in the same directory. On Windows, mmdc is an npm .cmd shim — subprocess needs the full path:

subprocess.run(
    [r"C:\Users\...\AppData\Roaming\npm\mmdc.cmd",
     "-i", str(path), "--outputFormat=pdf", "--pdfFit", "-o", str(out_md)],
    ...
)

Mermaid images not embedded

The preprocessed markdown references images as ./name-1.pdf (relative to the preprocessed file in mermaid-cache/). Pandoc resolves image paths relative to CWD, not the source file, even with --file-scope.

Fix: Add the cache directory to resource-path:

defaults["resource-path"] = [str(mermaid_cache_dir), str(repo_path)]

Colored chapter and section headings

Eisvogel supports LaTeX customization via header-includes in the metadata YAML. Add sectsty:

header-includes:
  - \usepackage[HTML]{xcolor}
  - \definecolor{ap-orange}{HTML}{E8381A}
  - \definecolor{ap-blue}{HTML}{1A5276}
  - \usepackage{sectsty}
  - \chapterfont{\color{ap-orange}}
  - \sectionfont{\color{ap-blue}}
  - \subsectionfont{\color{ap-blue}}

Result

python build.py
# Sections: 16, Files: 795
# Mermaid: pre-rendered N files via mmdc
# Done in 9m30s -> agentpatterns-encyclopedia.pdf

What I’d do differently

Don’t start with the default pandoc approach for large multi-file inputs on Windows. The WinError 206 trap is invisible until you hit it. Start with --defaults from day one.

Pre-process mermaid before the build, not inside the build. The mmdc step is slow (~3s/file) and sequential. It belongs in a separate cacheable step with a content-hash cache key.

Verify each fix before moving to the next one. I spent 30 minutes trying mkdocs-with-pdf and browser print-to-PDF because I hadn’t confirmed the mermaid embedding was working. A 2-minute visual check would have saved the detour.


Source

Build scripts: github.com/ankitg12/agentpatterns-build Dependencies: pandoc 3.9, xelatex (MiKTeX), mmdc 11.12, python 3.14, pyyaml.