A template repository for writing documents in markdown and producing a
styled PDF from them. Clone it, edit one markdown file and one config
file, run make.
The point is that a document's source stays plain markdown: it diffs, reviews and merges as prose, and none of the layout — fonts, colours, headers, footers, watermark, table styling — lives in the text you're writing.
Markdown has become the default format for writing documentation: it's plain text, it diffs and reviews cleanly in git and GitHub, and it's the format most comfortable to co-write with AI agents like Claude Code, which read and edit it natively without any special tooling. Meanwhile PDF remains the format enterprises actually expect when documentation leaves the engineering org — attached to an email, sent to a customer, a vendor, or an auditor who doesn't have (and shouldn't need) access to a repo or a rendered docs site.
Those two facts pull in different directions unless something bridges them. This project is that bridge: it lets you keep writing and collaborating in markdown — your source of truth, editable by humans and AI agents alike — while giving you a one-command path to a professionally styled PDF whenever you need to hand the document to someone non-technical or outside your organisation.
make check # confirm pandoc, typst and perl are installed
make open # build the PDF and open itThen:
- Write your document in
document.md. Its YAML frontmatter sets the title, subtitle, authors and date. - Set the look in
doc.conf— paper size, orientation, font, colours, logo, footer, watermark, table of contents. - Rebuild with
make, ormake watchto rebuild on every save.
document.md ships as a worked example of every feature the pipeline
supports. Build it once to see what each one looks like, then replace
the content with your own.
document.md ──[pandoc, applying template.typ]──► out.typ
──[perl: fix table column widths]──► out.typ
──[typst compile]──────────────────► build/document.pdf
- pandoc converts markdown to Typst markup, filling in
template.typwith values fromdoc.conf. - A perl step rewrites table column widths in the generated
out.typ(see Tables below for why). - typst compiles that to the PDF.
All three steps live in build.sh, which is the
documentation of the pipeline as much as the implementation of it.
out.typ and everything in build/ are artifacts. They're regenerated
on every run, they're gitignored, and hand-editing them achieves
nothing — the next build overwrites them.
// widths: 1,3
| Path | Purpose |
|---|---|
document.md |
The document source. This is the file you edit. |
doc.conf |
All the styling knobs. Edit this to rebrand. |
template.typ |
The Typst template, for anything doc.conf can't reach. |
build.sh |
The build pipeline. |
Makefile |
Convenience targets around build.sh. |
assets/ |
Logo and any images your markdown references. |
build/ |
Generated PDFs (gitignored). |
CLAUDE.md |
Notes for Claude Code on the pipeline's quirks. |
| Command | Effect |
|---|---|
make |
Build the PDF if the source, template or config changed. |
make watch |
Rebuild whenever any of those three files is saved. |
make open |
Build, then open the PDF. |
make typ |
Build and keep out.typ for inspection. |
make clean |
Remove build/ and out.typ. |
make check |
Report whether pandoc, typst and perl are on PATH. |
build.sh can also be called directly:
./build.sh # build $SOURCE from doc.conf
./build.sh notes.md # build a different markdown file
./build.sh -o build/final.pdf # override the output path
./build.sh --keep-typ # keep the intermediate Typst file
CONFIG=print.conf ./build.sh # use an alternative configThat last form is how you keep several variants of one document — say a
watermarked internal draft and a clean external release: copy doc.conf
to release.conf, change what differs, and pass CONFIG=release.conf.
Standard markdown works. A few things are worth knowing.
---
title: Your Document Title
subtitle: An optional subtitle
author:
- First Author
- Second Author
date: 29 July 2026
---Delete any field you don't want; delete the whole block for a document
with no title block at all. TITLE_PAGE="true" in doc.conf puts the
title on a page of its own.
With PAGEBREAK_ON_RULE="true", a markdown thematic break (--- on its
own line) becomes a page break. This is a good fit for documents where
each section should start a fresh page. Set it to "false" and you get
a centred rule instead.
This is the one place the pipeline needs help from you.
Pandoc sizes table columns from the character width of the markdown source's pipe padding — not from the text as it actually renders in the target font and page size. The result is routinely columns that are too narrow, with text overflowing the cell.
build.sh therefore rewrites every table's widths. By default each
column gets an equal fraction of the page, which always fits. To choose
the proportions yourself, put a marker block immediately above the
table, with one number per column:
```{=typst}
// widths: 1,3,1
```
| Setting | What it does | Default |
| ------- | ------------ | ------- |
| ... | ... | ... |Cell alignment follows the markdown (:---, ---:, :---:).
Columns you don't align explicitly get TABLE_DEFAULT_ALIGN from
doc.conf (left by default; pandoc's own default centres them).
Reference them normally — paths are relative to the repository root,
because that's where the intermediate out.typ is generated:
PNG, JPG and SVG all work.
Captions and placement. An image alone in a paragraph with alt text becomes a numbered, centred figure and the alt text becomes its caption. An image with empty alt text, or one sitting in a line of prose, stays an inline image at that point in the text — no caption, no number, not centred. Adding a trailing backslash after a captioned image also demotes it to inline and drops the caption, which is the documented way to say "image, but not a figure":


Some prose with an  in the middle of it.
\Sizing. Sizes go in a brace attribute after the image, using
pandoc's width and height keys. There must be no space between the
number and the unit:
// widths: 2,3
| Markdown | Result |
|---|---|
{width=60%} |
60% of the text width — the most useful form, because it keeps working if you change paper size or margins. |
{width=8cm} |
A fixed 8cm wide. mm, in and inch also work. |
{height=4cm} |
Fixed height, width follows the aspect ratio. |
{width=400} |
Unitless means pixels, converted to a physical size using IMAGE_DPI from doc.conf (96 by default, so 400px ≈ 4.17in). Prefer % or cm. |
{width=8cm height=3cm} |
Both bounds. The image is scaled to the largest size that fits inside them, keeping its aspect ratio. |
 |
The image's own natural size, which for a screenshot or a high-resolution photo is usually far too big. |
Giving both width and height letterboxes rather than crops, because
template.typ sets image(fit: "contain"). Typst's own default is
"cover", which would silently crop the image to fill the box — if you
actually want that, override it in a raw Typst block.
% is relative to the available width, so it means something different
inside a table cell or a two-column layout than it does at the top level
of the page.
Attributes can also carry a class or id ({#fig-plan .wide width=50%}),
but the Typst writer ignores anything it has no use for, so only width
and height change the output here.
The authority on all of this is pandoc's Images documentation.
Anything markdown can't express can be written as raw Typst, passed straight through to the compiler:
```{=typst}
#align(center)[Written in Typst, not markdown.]
```Use it sparingly — the value of the pipeline is that the source stays readable as prose.
The build needs pandoc (3.0 or newer — the Typst writer was added in
pandoc 3.0), typst, and perl on the host. Tested with pandoc
3.9 and typst 0.15. Run make check to see what you have.
brew install pandoc typstPerl ships with macOS — no install needed.
sudo apt-get update
sudo apt-get install -y pandoc perlNote that the apt-get pandoc can lag well behind upstream, and
anything older than 3.0 will not work here. Check with
pandoc --version; if it's too old, take the .deb from the
official pandoc installation page.
Typst is not in the Debian/Ubuntu APT repositories. Install it from Snap:
sudo snap install typstOr, to avoid Snap (or its file-access restrictions), download the
prebuilt binary from the
Typst releases page — e.g.
typst-x86_64-unknown-linux-musl.tar.xz — and put it on your PATH.
Sources:
- Pandoc installation guide
- Pandoc 3.0 release notes (adds the Typst writer)
- Install Typst on Ubuntu via the Snap Store
- Typst GitHub releases
- Typst documentation
Package availability and version numbers change; if a command here fails, check the linked pages rather than trusting this file.
FONT in doc.conf must name a font installed on the machine running
the build, otherwise Typst falls back and the PDF won't look as
intended. The default, Libertinus Serif, ships with Typst. If you set
a brand font, either commit it under assets/fonts/ and pass
--font-path assets/fonts to typst compile in build.sh, or make
sure everyone who builds the document has it installed.
| Symptom | Cause |
|---|---|
Error compiling template from pandoc |
A stray $ in template.typ. It's pandoc's variable delimiter — double it, and never use Typst math syntax in that file. |
| Table text overflows its cell | The table's // widths: marker doesn't have one number per column. |
unknown variable from typst |
A #let used before it's defined, or a raw-Typst block referring to something the template doesn't define. Run make typ and read out.typ. |
| Image not found | Paths resolve from the repository root, not from the markdown file's directory. |
| Image comes out enormous | No width given, so it's at natural size. Add {width=60%}. |
| Image size attribute ignored | A space crept between the number and the unit (width=8 cm), or the braces aren't directly after the closing ). |
| Image is cropped | Something overrode image(fit: "contain") back to Typst's "cover" default. |
maximum show rule depth exceeded |
Something in template.typ reconstructs an element from inside that element's own #show rule. See CLAUDE.md. |