Render PGF/TikZ diagrams in Quarto.
quarto add danmackinlay/quarto_tikzThis installs the extension under _extensions. If you're using version
control, check that directory in.
Upgrading a document written against the pre-1.0 block syntax? See CHANGELOG.md — 1.0.0 is the release that changed it.
You need a TeX distribution (TeX Live or MacTeX) on your PATH.
pdflatex is invoked by default; set tikz.tex-engine for lualatex
or xelatex (e.g. for fontspec / complex Unicode scripts).
For HTML and other non-PDF outputs you also need a PDF/DVI → SVG
converter — by default inkscape (≥ 1.0), overridable via
tikz.svg-engine. Rendering only to PDF needs no converter at all: the
intermediate PDF is embedded directly. See the
configuration reference for both, and the
Inkscape-free CI recipe
for minimal CI images.
Under renderer: tikzjax (HTML output only) none of the above is
required: rendering happens in the reader's browser via WebAssembly.
See Renderers.
Create a code block with class .tikz:
```{.tikz}
%%| filename: stick-figure
%%| caption: A Stick Figure
\begin{tikzpicture}
% Head
\draw (0,0) circle (1cm);
% Body
\draw (0,-1) -- (0,-3);
% Arms
\draw (-1,-2) -- (0,-1) -- (1,-2);
% Legs
\draw (-1,-4) -- (0,-3) -- (1,-4);
\end{tikzpicture}
```This should appear in the output as an image
There are two ways to give a diagram a caption, and they exist for different goals. Pick by whether you need to refer to the figure from prose.
1. %%| caption: inside the block — for a caption only. As in the
example above: the filter wraps the image in a figure carrying that
caption. The figure's id and name come from %%| label: and
%%| name: (and any fig--prefixed directive or fence attribute), but
those don't reliably survive Quarto's float handling, so a figure made
this way is not dependably cross-referenceable with @fig-….
2. Quarto's native fenced div — for @fig-… cross-references.
::: {#fig-stick}
```{.tikz}
%%| filename: stick-figure
\begin{tikzpicture}...\end{tikzpicture}
```
A Stick Figure
:::Here Quarto owns the float, so @fig-stick resolves correctly. Use this
whenever you need to reference the figure in text; it is the pattern the
bundled example.qmd uses.
| You want… | Use |
|---|---|
| Just a caption under the diagram | %%| caption: |
A @fig-… cross-reference in prose |
Quarto fenced div ::: {#fig-…} |
| Both caption and cross-reference | Fenced div, with the caption as the div's last line |
Warning
Don't do both at once. Set %%| caption: and wrap the same
block in a captioned ::: {#fig-…} div, and the filter's figure gets
nested inside Quarto's — a figure-within-a-figure with a doubled or
empty caption. With the fenced div, leave %%| caption: out and let
the div's last line be the caption.
Two rendering pipelines are available. Both consume the same .tikz block syntax, so switching between them rewrites no blocks. They answer how a diagram is drawn; a separate option, latex-passthrough, answers whether to draw it at all. The two are independent, so a project can pass source through to its PDF build and still render the same diagrams for the web.
renderer: latex(default) — compiles each block server-side via the configuredtex-engine(defaultpdflatex) and, for non-PDF outputs, the configuredsvg-engine(defaultinkscape). For PDF output, the intermediate PDF is embedded directly. Works for every output format Quarto supports. Requires a TeX distribution; for non-PDF outputs also a PDF/DVI → SVG converter.renderer: tikzjax— emits a<script type="text/tikz">…</script>tag and loads TikZJax so the reader's browser renders the diagram client-side via WebAssembly. No LaTeX or SVG converter needed, but only works for HTML output and adds ~3 MB of JS/WASM to the first page load. Non-HTML outputs (PDF, docx, …) droptikzjaxblocks with a warning — userenderer: latexfor those.
Pick the renderer document- or project-wide:
tikz:
renderer: tikzjax…or per-block:
```{.tikz}
%%| renderer: tikzjax
\begin{tikzpicture}…\end{tikzpicture}
```The TikZJax assets come from https://tikzjax.com/v1/ by default. Set tikz.tikzjax-url to self-host or to pin a fork (e.g. drgrice1/tikzjax); the URL you supply must serve both tikzjax.js and fonts.css at its root.
By default a rendered diagram reaches an HTML page as
<img src="….svg">, and a browser renders an SVG referenced that way in
secure static mode: the document inside the image is walled off from
the host page. Labels are neither selectable nor findable with
find-in-page, screen readers see no further than alt, and page CSS
cannot reach the diagram at all.
embed: inline emits the SVG as markup instead:
tikz:
embed: inline…or per-block with %%| embed: inline. It applies only to HTML-family
output — PDF, docx and the rest keep a real image — and changes only how
the SVG is delivered, never the bytes, so switching it on invalidates no
cache entry.
This is what makes svg-engine: dvisvgm worth choosing. dvisvgm
emits real <text> elements and embeds fonts as WOFF, where inkscape
and pdftocairo outline every glyph to a path. Through an <img> that
advantage is unreachable by construction, so the choice collapses to
file size; inlined, it is the whole difference. %%| alt: becomes the
SVG's <title>, an accessible name that does not suppress the text
inside it, and every <svg> carries class="tikz-svg" as a styling
hook.
Note
Inside an <img>, an SVG's ids, CSS classes and @font-face
families are sandboxed; inlined they are page-global, and every
converter repeats the same names from diagram to diagram. So the
filter renames them per diagram before emitting — without that,
pdftocairo's <use xlink:href="#glyph-0-0"> silently picks up the
first diagram's glyphs, and dvisvgm's text.f0 gets redefined by
a later diagram's font and size. If you post-process the emitted SVG
yourself, do not assume the names match what the converter wrote.
To reuse a \tikzset or a \usepackage block across many diagrams, put
it in a file alongside your .qmd and \input (or \usepackage) it
from each TikZ block. The extension sets TEXINPUTS before invoking the
TeX engine, so lookups resolve in this order:
- The directory containing the source
.qmd. - The extension's own directory (
_extensions/tikz/), so shared style files can ship with the extension itself. - Your system's default LaTeX search path, so
\usepackage{tikz}and friends keep working.
So a shared-styles.tex next to your .qmd holding
\tikzset{myedge/.style={->, red, thick}} is reachable from any block:
```{.tikz}
\input{shared-styles.tex}
\begin{tikzpicture}
\draw[myedge] (0,0) -- (2,0);
\end{tikzpicture}
```The same mechanism works for a bundled .sty file (e.g.
\usepackage{shared-styles}) placed either next to the .qmd or inside
_extensions/tikz/.
A minimal worked document: example.qmd.
Compiling TikZ is slow — every diagram is at least a TeX compile and,
for non-PDF outputs, an SVG conversion. An optional content-addressed
cache avoids recompiling unchanged diagrams. Enable it from your
project's _quarto.yml (or any single document's front-matter):
tikz:
cache: trueBy default, cached SVGs are written to a per-user cache directory:
- Linux/macOS:
$XDG_CACHE_HOME/tikz-diagram-filter/(falls back to~/.cache/tikz-diagram-filter/ifXDG_CACHE_HOMEis unset). - Windows:
%USERPROFILE%\.cache\tikz-diagram-filter\.
Cache files are named <basename>.<short-hash>.<ext> (e.g.
stick-figure.318b4ef1.svg). The basename comes from the block's
%%| filename: directive (or tikz if you didn't set one); the short
hash covers the TikZ code plus the per-block options, the TeX engine,
the SVG engine, the template and the output format, so toggling any of
those produces a different file. Options are encoded canonically before
hashing — keys sorted, each key and value length-prefixed — so that
neither Lua's unspecified iteration order nor two similar option sets
can collide the names.
The code half has the %%| directive lines removed first. They are TeX
comments, so they cannot change a rendered byte, and the ones that do
influence compilation — additionalPackages, header-includes,
renderer, opt-* — are folded into the options half separately, as
the values they resolve to rather than as the text you wrote. So a
directive resolving to its default is invisible to the key: a no-op
%%| renderer: latex, or a value the filter rejected and warned about,
hashes the same as no directive at all. embed and latex-passthrough
never reach it either — the first changes only how already-rendered
bytes are delivered, and under the second nothing is cached. What is
left is presentation: caption, alt, label, name, filename.
Adding a caption, or migrating from {.tikz filename='x'} to
%%| filename: x, therefore leaves the entry intact rather than
silently orphaning it.
Every artifact a diagram produces is named that way — cache entries, the
mediabag, a save-tex tree alike — so an ls of any of them says which
diagram each file came from, and two blocks sharing an explicit
filename still get a file each.
Note
A release that changes the key rekeys the cache: the first render
after upgrading recompiles every diagram and orphans the old entries,
which are harmless — rm -rf the cache dir to tidy up. If you commit
an in-tree cache-dir, do that render on a machine that has TeX and
expect one large diff, or a TeX-less build host will fail on the next
build. CHANGELOG.md says which releases did.
tikz.cache-dir: <path> overrides the location. For solo local
development leave it unset; the user-level default is what you want.
For projects deployed to a build host that has no TeX or Inkscape
(Netlify, etc.), set an in-tree cache-dir and commit it — see the
cached deployment recipe.
Cleanup of the user-level cache is manual — rm -rf the cache dir
occasionally, or find <cache-dir> -mtime +30 -delete for a time-based
sweep. (Entries are not touched on hit, so -mtime reflects last write
rather than last use:
#10, PRs
welcome.)
A proper Quarto language engine would handle all this more cleanly than a homegrown cache, but writing one is more work than this project can justify right now.
A diagram that cannot be rendered — no TeX engine on the machine, a LaTeX error in the block, a missing SVG converter — is reported once, naming the figure and the cause, and its block is left in the output as its own source. The rest of the document renders normally: one broken diagram is a cosmetic gap, never a failed build. That matters most on a build host that has no TeX because it renders from a committed cache.
To diagnose a block that comes out wrong or won't compile, look at the
intermediate files (.tex, .pdf or .dvi, and .log) the TeX engine
actually saw. The filter can preserve those, but only with caching
switched off:
# in the offending document's front-matter, temporarily
tikz:
cache: false
save-tex: true
tex-dir: tikz-tex # any path; defaults to 'tikz-tex'Re-render and inspect <tex-dir>/<filename>/. Running your configured
TeX engine by hand from inside that directory reproduces the exact
compilation, and the .log is usually enough to spot the problem.
cache: true and save-tex: true are mutually exclusive: a cache hit
short-circuits compilation, so no intermediates would ever be written.
Set both and the filter warns and disables save-tex. So debugging is a
brief detour — switch caching off, debug, revert. Stale tex-dir trees
are safe to delete afterwards, and worth adding to .gitignore.
Under PDF output the extension skips SVG conversion entirely and embeds
each diagram's intermediate PDF via \includegraphics. That preserves
vector fidelity and fonts, and means no SVG converter is required when
you only render to PDF. (For HTML and the rest, that PDF — or a DVI,
under svg-engine: dvisvgm — goes through the configured converter
instead.) If you'd rather ship the TikZ source than an embedded PDF, see
LaTeX passthrough.
Blocks with renderer: tikzjax are dropped with a warning under PDF or
any other non-HTML output, since client-side JS can't run there — unless
latex-passthrough: true, which is checked first and hands the source
to the host document, so a tikzjax project still gets its diagrams into
the PDF. Mixing renderers in one document is fine: only the
tikzjax-tagged blocks are skipped.
Under LaTeX output, latex-passthrough hands the TikZ source to the
host document as raw LaTeX instead of compiling it. The diagram is
typeset by the same LaTeX run as the surrounding text: no standalone
compile, no figure files, no \includegraphics. Fonts and sizing match
the document automatically, and the shipped .tex keeps the diagram's
source rather than an opaque binary — a couple of KB of TikZ against
tens of KB of rendered PDF, which is what an arXiv-style source
submission wants.
tikz:
latex-passthrough: true…or per-block, mixing freely with compiled blocks in the same document:
```{.tikz}
%%| latex-passthrough: true
\begin{tikzpicture}…\end{tikzpicture}
```It is not a renderer, and deliberately so. It applies to exactly one
output family — quarto.doc.isFormat('pdf'), covering format: pdf and
format: latex alike — and says nothing about the others, where
renderer still decides. So the flag is safe to set project-wide, and
the combination you probably want for a paper with a web preview is two
lines:
tikz:
renderer: tikzjax # HTML: rendered in the reader's browser
latex-passthrough: true # LaTeX: source handed to the host documentThat builds the PDF with no TeX subprocess and the site with no TeX
installed at all. With the default renderer: latex instead, the HTML
build compiles each diagram to SVG as usual. For a different setting per
output format, tikz: merges from format-level metadata like any
other key — that is Quarto's job, not this filter's:
format:
html: {tikz: {renderer: tikzjax}}
pdf: {tikz: {latex-passthrough: true}}A captioned block becomes a figure environment, which is a TeX
group — and that single fact explains everything below. PGF records
"library loaded" globally while a library's own definitions stay local
to the group, and a \tikzset is local too. So anything declared inside
a captioned block is discarded at \end{figure}; in the library case
every later load is also suppressed, so a different, later block fails,
with an error naming PGF math rather than library loading.
The filter therefore hoists into the host preamble via
include-in-header:
\usepackage{tikz}— Quarto's LaTeX template does not load it.- The block's
%%| additionalPackages:and%%| header-includes:text, verbatim. A\tikzsetyou want document-wide belongs here. - Every
\usetikzlibrary{…}in the block body, and likewise\usepgfplotslibrary,\usepgflibraryand\usetikzmarklibrary, emitted one library per line.
A load alone on its line (leading whitespace and a trailing % comment
are fine) is moved. One sharing its line with other code is
copied — excising it could break the drawing, and PGF's loaders are
idempotent, so the body's copy becomes a no-op. Three shapes are
reported rather than hoisted, each warning with the block and the line:
a loader inside a tikzpicture (it may be part of the drawing), one
whose argument does not brace-balance, and \usepackage or
\usegdlibrary anywhere in the body. Put those in
%%| additionalPackages:, which is hoisted verbatim and always works.
Hoisted text is deduplicated by exact string, so a dozen blocks opening
with the same loads collapse to one line each — but two that differ
only partially emit both. Harmless for a repeated \usepackage{x}; an
options clash (\usepackage[a]{x} against \usepackage[b]{x}) is a
LaTeX error, so keep package options consistent across a document.
%%| directive lines are stripped from the emitted LaTeX; they are
filter input, not part of the picture.
Captions, and what passthrough ignores. A %%| caption: block
becomes a figure with \centering and a \caption, exactly as under
either renderer; without a caption it is emitted bare, leaving placement
to the caller. (The cross-referencing
caveats apply here too.) Since nothing
is compiled, cache, save-tex, tex-engine, tex-template,
svg-engine and svg-command have no effect, and neither do
filename, alt, or image attributes such as width — scale from
inside the block instead (\begin{tikzpicture}[scale=2]). \input-ing
a shared file still works, but resolution is the host LaTeX run's
business now, so keep such files beside the .tex you compile.
Rendering runs external programs (a TeX engine, and an SVG converter).
The options choosing which binary runs — tikz.tex-engine,
tikz.svg-engine, tikz.svg-command — are read only from
document/project metadata, never from per-block attributes or %%|
directives, so a hostile diagram body alone can't run a command.
Those programs are launched directly (pandoc.pipe), never through a
shell, so a value like svg-engine: "x; rm -rf ~" names a program that
does not exist rather than a pipeline that runs.
Don't render untrusted documents. Metadata is trusted input: an
attacker controlling the YAML front-matter can point svg-command at
any program. Separately, TeX can shell out via \write18 if
shell-escape is enabled (this filter doesn't pass -shell-escape, but a
wrapper or site texmf.cnf might). To harden a pipeline, pin the
tikz: block in _quarto.yml so documents can't override it (the
hardening pattern from pandoc-ext/diagram).
Document- or project-level options (set under tikz: in the YAML
front-matter or _quarto.yml):
Note on document- vs project-level merging. A
tikz:block in a document's front-matter replaces the project-level one wholesale — Quarto does not deep-merge user-defined config keys. So a per-doctikz: { cache: true }meant as a benign repeat drops every other setting (cache-dir,svg-engine,renderer, …) from_quarto.ymland silently falls back to the filter defaults. If you need a per-doc override, repeat every project-level setting you still want; otherwise omit the doc-leveltikz:block entirely. (filters: - tikzis separate: required per-doc, and unaffected by this.)
Booleans accept true/false, yes/no and 1/0, quoted or not;
anything else warns and leaves the default in place.
-
cache— boolean, defaultfalse. Enable the on-disk SVG cache. -
cache-dir— path. Defaults to$XDG_CACHE_HOME/tikz-diagram-filter(or your platform's per-user cache equivalent). Leave unset unless you have a specific reason. -
save-tex— boolean, defaultfalse. Preserve intermediate.tex/.pdf/.logfiles for debugging. Ignored (with a warning) whencache: true. -
tex-dir— path, defaulttikz-tex. Where preserved intermediates land whensave-texis on. -
tex-template— path. If set, the contents of this file replace the built-in\documentclass[tikz]{standalone}template. Useful for loadingfontspec,babel, custom colour packages, etc. The template is a Pandoc template, so it must include$additional-packages$,$for(header-includes)$ $it$ $endfor$, and$body$. Path is resolved relative to the qmd directory. -
tex-engine— string, defaultpdflatex. The LaTeX executable to invoke:pdflatex,lualatex,xelatex, or any other TeX engine on yourPATH. Reach forlualatex/xelatexwhen you needfontspec, Unicode shaping or modern font features — see the non-Latin scripts recipe. -
svg-engine— string, defaultinkscape. PDF/DVI → SVG converter:inkscape— the default; consumes the PDF the TeX engine produced.pdftocairo— frompoppler-utils. Lightweight alternative if you don't want to install Inkscape; also consumes PDF.dvisvgm— consumes a DVI (the filter asks the TeX engine for one in that mode) and embeds fonts as WOFF, keeping text in the rendered SVG selectable. Requires a TeX-Live-integrateddvisvgm; standalone packages may fail to find PostScript prologue files.
svg-enginehas no effect under LaTeX output, where the TeX run's own PDF is embedded directly and no converter runs at all. -
svg-command— string or list. Escape hatch for wiring any other PDF → SVG converter (pdf2svg, apymupdfscript,mutool draw, …) without us having to bless each tool individually. The first element is the executable, the rest are arguments, with{input}and{output}substituted with the intermediate PDF and the target SVG. A command naming neither placeholder is rejected. When set,svg-commandtakes precedence oversvg-engine— setting both warns, andsvg-engineis ignored.{input}is always a PDF: a custom command cannot consume the DVI thatsvg-engine: dvisvgmwould otherwise ask the TeX engine for.Two YAML forms are accepted —
svg-command: "pdf2svg {input} {output}", or the equivalent list, which is what you want if any path may contain whitespace:tikz: svg-command: [pdf2svg, "{input}", "{output}"]
-
renderer— string, defaultlatex. Picks the rendering pipeline:latex(server-sidetex-engine+svg-enginechain above) ortikzjax(client-side WebAssembly rendering, HTML output only). See Renderers. An unrecognized value warns and is ignored, so the next source down applies — for a%%|directive that is the document-levelrenderer, and for the document-level setting it is thelatexdefault. The same holds forembed. -
latex-passthrough— boolean, defaultfalse. Under LaTeX output (format: pdforformat: latex), emit the TikZ source into the host document instead of rendering it. Independent ofrenderer, which continues to govern every other output format. See LaTeX passthrough. -
embed— string, defaultimg. How a rendered SVG reaches an HTML page:img(an<img src=…>reference) orinline(the SVG as markup, with its internal names namespaced per diagram). HTML-family output only; everything else keeps a real image. Does not affect the cache. See Inline SVG for HTML. -
tikzjax-url— string, defaulthttps://tikzjax.com/v1. Base URL for the TikZJaxtikzjax.jsandfonts.cssassets whenrenderer: tikzjax. Override to self-host or pin a fork.
Per-block directives (set inside the TikZ code block as %%| key: value lines, as in example.qmd). These may also be
given as code-block fence attributes ({.tikz filename=…}, the
deprecated pre-1.0 form) — but if a key is set both ways the %%|
directive wins and the fence attribute is ignored with a warning:
{.tikz filename='bayesnet-3'} wrapped around a %%| filename: bayesnet-4 renders as bayesnet-4. Don't set the same option in
both places:
filename— basename for the generated.tex/.pdf/.svg. Defaults to a hash of the code.caption— figure caption (Markdown). Produces a caption only, not a reliable cross-reference target — see Captions and cross-references.alt— image alt text.label— the figure'sid;name— the figure'sname. Not dependably honoured for cross-references; see Captions and cross-references.additional-packages(also accepted asadditionalPackages) — extra\usepackage{…}lines added to the preamble of the synthesized LaTeX document (underlatex-passthrough, to the host document's preamble).header-includes— additional raw LaTeX inserted into the preamble (same destinations asadditionalPackages).renderer,latex-passthrough,embed— per-block overrides of the document-level settings above, taking the same values. So a single diagram can be passed through in an otherwise compiled document, or compiled in an otherwise passed-through one.
Attributes prefixed with fig-, image-/img-, or opt- are routed
to the figure, the image, or the per-block options respectively, whether
they are written as %%| directives or on the code block fence.
A %%| directive that names none of the above warns: an unrecognised
name is passed through as an image attribute (say so explicitly with
%%| image-…: to silence it), and one that names a document-level
option — %%| cache: true, say — is reported and ignored, because that
option only has an effect under tikz: in the front matter or
_quarto.yml. Fence attributes are not held to this: an arbitrary image
attribute is exactly what a fence attribute is for.
Three common end-to-end setups, each fully worked.
Inkscape is heavy (~200 MB with X dependencies on Linux), and most
minimal CI images don't ship it. dvisvgm ships with TeX Live, so on
any runner that already has TeX you can avoid Inkscape entirely:
# _quarto.yml
tikz:
svg-engine: dvisvgmGitHub Actions workflow:
# .github/workflows/build.yml
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: quarto-dev/quarto-actions/setup@v2
with:
tinytex: true
- run: tlmgr install dvisvgm
- uses: quarto-dev/quarto-actions/render@v2The tlmgr install line is needed because TinyTeX is deliberately
minimal — dvisvgm is in TeX Live but not pre-installed. It costs a few
MB, nothing like Inkscape.
Caveat: blocks relying on PostScript specials (some pgfplots 3-D
constructs, certain transparency tricks) may not come out identical via
DVI. The bulk of TikZ usage — graphical models, flowcharts, causal
diagrams — is unaffected.
pdflatex can't handle complex-shaping scripts. Switch the TeX
engine to lualatex and supply a fontspec+babel template:
# _quarto.yml
tikz:
tex-engine: lualatex
tex-template: tikz-fontspec.texA minimal tikz-fontspec.tex for Arabic (or other RTL scripts —
swap the babel font for your script's preferred face):
% tikz-fontspec.tex — drop next to your qmd
\documentclass[tikz]{standalone}
\usepackage{fontspec}
\usepackage[bidi=basic]{babel}
% Arabic. `Amiri` ships with TeX Live, so no extra install needed.
\babelprovide[import=ar]{arabic}
\babelfont[arabic]{rm}{Amiri}
% Devanagari example (uncomment if you need it; needs a Devanagari
% font installed on the machine, e.g. via `tlmgr install` or system).
% \babelprovide[import=hi]{hindi}
% \babelfont[hindi]{rm}{Noto Serif Devanagari}
$additional-packages$
$for(header-includes)$
$it$
$endfor$
\begin{document}
$body$
\end{document}Then any .tikz block can use \foreignlanguage{arabic}{…} and the
other babel macros to mix scripts with the rest of its content. The
template is loaded once at filter setup and applied to every block in
the project, so individual blocks need no extra preamble. This is the
fix for "Arabic comes out garbled" under the default pdflatex
pipeline; no custom pymupdf step or forked extension required.
Many static-host build environments (Netlify, Vercel, GitHub Pages via Actions without TinyTeX) have no TeX or Inkscape and no way to install them. Cache hits never invoke a subprocess, so if every block hits the cache the build host needs nothing.
# _quarto.yml
tikz:
cache: true
cache-dir: _tikz-cache # in-tree, so it travels with the repoTrack _tikz-cache/ in git (i.e. do not add it to .gitignore) and
commit cache files alongside your .qmd changes. The build host clones
the repo, the cache comes with it, and every block returns its stored
SVG/PDF without invoking anything.
The workflow: edit a block locally, quarto render to populate the
cache, then git add _tikz-cache/ <your-qmd> and push. Because cache
filenames carry the diagram's basename, a git diff --stat of those
commits tells you which diagrams changed.
Forget the render step and the build host takes a cache miss: it logs an error and emits the raw code block in place of the diagram. The build still succeeds — the missing diagram is your signal.
The filter's pure helpers have unit tests: the %%| directive parser and
option router, the latex-passthrough library-loader matching and
move/copy split, the cache-key encoding and option resolution, the
compile failure paths, and the inline-SVG namespacing. Each suite is a
file under tests/, sharing the assertion helper in tests/harness.lua.
Run them all from the repo root:
sh tests/run.sh…or one at a time, e.g. pandoc lua tests/test_cache_key.lua.
They need nothing beyond pandoc, which the extension already requires —
no TeX distribution, no SVG converter — which is what lets CI
(.github/workflows/test.yml) run them on every push and pull request.
Created by cribbing the tricks from knitr/inst/examples/knitr-graphics.Rnw and data-intuitive/quarto-d2/.
After spending 2 days of my life getting this working, I found that there is a worked example of a tikz filter in pandoc itself.
For many diagram languages in one filter, prefer the mature generalist
pandoc-ext/diagram (Asymptote, GraphViz, Mermaid,
PlantUML, TikZ, Typst/cetz, D2); some choices here are informed by it.
This project does only TikZ, trading generality for a cache key that
folds in engine/template/format (theirs keys on source text alone, so
caching is off by default), TEXINPUTS support for shared
\input/preamble files, and a debuggable on-disk cache (the basis of
the no-TeX-on-the-build-host recipe).
Both let you pick between Inkscape, dvisvgm, and other backends.