Pandoc Lua filter that adds a visible draft mark to document output. Useful for editorial workflows where you need to track revision status per document and see it in printed or PDF output.
Copyright 2026 Pedro Luis Barrio under GPL-3.0-or-later, see LICENSE file for details.
Maintained by plbarrio.
Pandoc >= 2.19.1 · Quarto >= 1.4.0 (for Quarto usage)
Typst output needs Pandoc >= 3.1.3 (Pandoc's Typst writer didn't exist before that) — CI tests against 3.1.3 and latest for this reason, even though the filter's own Lua code only requires 2.19.1.
pandoc --lua-filter=draft-mark.lua input.md -o output.pdfDeclare in _quarto.yml:
filters:
- draft-mark.luaOr install as an extension:
quarto add plbarrio/draft-markEach document carries its own mark string in the frontmatter. A global flag controls whether marks are visible in the output. This separation allows you to:
- Keep the mark in the document at all times (no renaming, no git noise)
- Show marks when compiling drafts for review
- Hide marks for final production output — with no changes to the source files
The mark is inserted right after the first Header in the document (or
at the top, if the document has none).
draft-mark: "rc1"Any string is valid: draft, rc1, rev-joan, 1.0.0, borrador, etc.
If absent, no mark is emitted even when the global flag is on.
draft-mark-show: trueSet this in the master YAML, a defaults file, or passed via
--metadata-file (or -M draft-mark-show=true on the command line —
it's a top-level key, so it works fine with -M). When false or
absent, no marks are emitted.
The two keys are independent — they live in different files and do
not conflict. This is intentional: draft-mark belongs to each document,
draft-mark-show belongs to the build configuration.
000_book.yaml ← draft-mark-show: true (review builds)
000_book_final.yaml ← draft-mark-show: false (production)
101_poem.md ← draft-mark: "draft"
102_poem.md ← draft-mark: "rc1"
103_poem.md ← draft-mark: "rev-joan"
104_poem.md ← (no mark — considered done)
Review build:
pandoc --metadata-file=000_book.yaml --lua-filter=draft-mark.lua ...Production build:
pandoc --metadata-file=000_book_final.yaml --lua-filter=draft-mark.lua ...The mark is inserted after the first heading, in a subdued style (gray, small text), as a margin note for LaTeX/ConTeXt/Typst so it has no layout impact.
| Format | Output |
|---|---|
| html/epub | <div class="draftmark" data-mark="rc1"><p>rc1</p></div> |
| latex | \marginpar{\color{gray}\ttfamily\small rc1} |
| context | \draftmark{rc1} |
| typst | #place(right, dy: -1em, text(size: 0.8em, fill: gray, [rc1])) |
.draftmark {
text-align: right;
font-size: 0.75em;
color: #999;
font-family: monospace;
margin-bottom: 0.5em;
}Requires \usepackage{color} or \usepackage{xcolor} in the preamble.
\usepackage{xcolor}\draftmark{...} is a plain macro call — define it with \definehighlight
(or any command taking one argument) in your ConTeXt style file:
\definehighlight[draftmark][style=\tfx, color=gray]
make test # run all tests (plain pandoc)
make test-quarto # run all tests via `quarto pandoc`
make generate-tests # regenerate expected output| File | What it covers |
|---|---|
draft-mark-on.md |
mark visible after the first Header when show is true |
draft-mark-off.md |
mark hidden when show is absent |
draft-mark-none.md |
show is true but no mark defined — nothing emitted |
draft-mark-existing.md |
a Div.draftmark already present (as left by a collection.lua per-source pass) — insertion is skipped, the existing mark is still rendered |
Internally the filter works in two stages so it also behaves correctly
when run per-source inside a collection.lua build (where sources are
processed with FORMAT=json before the final output format is known):
- Insertion (
Pandocpass) always builds a genericDiv.draftmarkand places it after the firstHeader, regardless ofFORMAT. ADivis a plain Pandoc block, so it survives a json/native round-trip untouched. If the document already contains aDiv.draftmark(e.g. because this is an already-merged collection build where each source got its own mark), insertion is skipped — seedraft-mark-existing.mdabove. - Rendering (
Divpass) convertsDiv.draftmarkinto the format-native markup (\marginpar{},\draftmark{},#place(...)), but only onceFORMATis a real output format. It no-ops forjson/native, so it's safe to also run at the per-source stage.
This mirrors the class-whitelist/environment mechanism used by the
sibling container-writer.lua filter (wrap a Div/Span by class
into format-native markup) — but container-writer only wraps
elements that already exist in the AST with a known class; it doesn't
read metadata, decide whether to show anything, pick the mark text, or
find where to insert it. Those three things (draft-mark-show,
draft-mark, and locating the first Header) are this filter's actual
job, so it keeps its own rendering step instead of depending on
container-writer being present. If your build already loads
container-writer.lua, you can replace the rendering step with a
draftmark whitelist entry there instead — but that trades a few lines
of Lua for a hard dependency, so it's left as a standalone default here.
A book-wide (as opposed to per-document) mark is better handled outside
this filter entirely: reference the plain $draft-mark$ metadata field
directly in a title-page template (e.g. via frontmatter.lua), with
the format-specific wrapping written into the template itself.
- Only the first
Headeris used as the anchor point — the mark is not repeated at every heading level. container-writer's environment mechanism could in principle wrapDiv.draftmarktoo (see Design notes) — but that's a deliberate trade-off, not a limitation to fix.
Issues and PRs welcome at the project repository.
GPL-3.0-or-later. See LICENSE.
- Pandoc — universal document converter
- Quarto — open-source scientific publishing system
- Lua — lightweight embeddable scripting language
- Pandoc Lua filters — official documentation
- Quarto extensions — official documentation