Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

markdown → Typst → PDF

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.

Why this exists

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.

Quick start

make check      # confirm pandoc, typst and perl are installed
make open       # build the PDF and open it

Then:

  1. Write your document in document.md. Its YAML frontmatter sets the title, subtitle, authors and date.
  2. Set the look in doc.conf — paper size, orientation, font, colours, logo, footer, watermark, table of contents.
  3. Rebuild with make, or make watch to 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.

How it works

document.md ──[pandoc, applying template.typ]──► out.typ
            ──[perl: fix table column widths]──► out.typ
            ──[typst compile]──────────────────► build/document.pdf
  1. pandoc converts markdown to Typst markup, filling in template.typ with values from doc.conf.
  2. A perl step rewrites table column widths in the generated out.typ (see Tables below for why).
  3. 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.

Repository layout

// 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.

Build commands

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 config

That 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.

Writing the document

Standard markdown works. A few things are worth knowing.

Frontmatter

---
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.

Page breaks

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.

Tables

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).

Images

Reference them normally — paths are relative to the repository root, because that's where the intermediate out.typ is generated:

![An optional caption.](assets/diagram.png)

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":

![Becomes Figure 1, centred, with this as the caption.](assets/diagram.png)

![](assets/diagram.png)

Some prose with an ![inline icon](assets/icon.png) in the middle of it.

![This caption is discarded.](assets/diagram.png)\

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
![C](img.png){width=60%} 60% of the text width — the most useful form, because it keeps working if you change paper size or margins.
![C](img.png){width=8cm} A fixed 8cm wide. mm, in and inch also work.
![C](img.png){height=4cm} Fixed height, width follows the aspect ratio.
![C](img.png){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.
![C](img.png){width=8cm height=3cm} Both bounds. The image is scaled to the largest size that fits inside them, keeping its aspect ratio.
![C](img.png) 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.

Raw Typst

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.

Dependencies

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.

macOS (Homebrew)

brew install pandoc typst

Perl ships with macOS — no install needed.

Ubuntu / Debian

sudo apt-get update
sudo apt-get install -y pandoc perl

Note 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 typst

Or, 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:

Package availability and version numbers change; if a command here fails, check the linked pages rather than trusting this file.

Fonts

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.

Troubleshooting

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.

About

A template repository for writing documents in markdown and producing a styled PDF from them.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages