Skip to content

Templates and content rules

Templates control presentation; transformations and validators control Markdown. Neither requires a plugin registry or application framework.

Typoprint templates are plain Typst code. If you can write a Typst set or show rule, you can restyle every document. See Learn Typst for the references that matter.

Built-in templates

Two layouts ship with the package. Pick one with --template, Settings(template=...), or template = "..." in typoprint.toml.

editorial (default) report
Feel Book-style reading and review Compact, modern reports
Body font Libertinus Serif, justified Source Sans 3, ragged right
Headings Small caps, accent rules for parts Bold sans, numbered 1.1
Accent Terracotta #9a4a26 Blue #165d8d
Title Serif title page, or a title above the contents Tinted title block with a left bar
Tables Open, ruled header Filled header, striped rows
Quotes Left rule Tinted panel

Both share the same content pipeline, so French spacing, local links with page references, footnotes, tables, syntax-highlighted code, and Mermaid diagrams behave identically. Both support covers, contents, parts, A4 or US Letter, and French or English labels.

typoprint notes.md                         # editorial
typoprint notes.md --template report       # report
typoprint docs/ --template report --cover --toc -o handbook.pdf

Your own templates

A template is a bundle: a directory containing template.typ, plus any helper .typ modules, images, or data files it uses. Select it by path:

typoprint notes.md --template ./templates/acme -o acme.pdf
from typoprint import Settings, render

result = render("# Hi", settings=Settings(template="./templates/acme"))
[defaults]
template = "templates/acme"   # relative to this TOML file

There are three levels of ambition. Each has a complete, tested example in examples/.

Goal Approach Example
Change a color, font, or rule Wrap a built-in layout and add set/show rules template/, confidential/
Brand it: logo, cover, helper modules Wrap a built-in layout and replace parts of it branded/
Total control Write all three functions yourself memo/

The contract

template.typ must export three functions. A missing export stops rendering with a clear error; there is no silent fallback.

Export Receives Responsibility
document(meta, body) Document metadata, all chapters Page setup, fonts, show rules, title or cover, contents
chapter(meta, body) Chapter metadata, one chapter Page break, part heading, chapter title
diagram(body) A rendered Mermaid figure Size and place the figure

meta for document carries the resolved settings and metadata:

Field Meaning
title, subtitle, author, date, version Resolved metadata, or none
lang, paper "fr"/"en", "a4"/"us-letter"
cover, toc, toc-depth Whether to print a cover and contents, and its depth
chapter-level Heading level of chapter titles: 1, or 2 when the document has parts
template_options Your JSON-compatible options dictionary
link_base, font_paths, template Remaining settings

meta for chapter has the same fields, and in addition:

Field Meaning
title The chapter title as content
title-in-body true when the Markdown already opens with its own H1
part Part name when this chapter opens a part, else none
source Source file name, for running footers
first true for the first chapter
document The document-level meta above

A chapter's date and version come from that chapter's own front matter. Templates must tolerate missing optional metadata (none).

Recipe: tweak a built-in layout

The smallest template wraps editorial or report and adds Typst rules before delegating.

// templates/acme/template.typ
#import "@local/typoprint:0.1.0": editorial

#let document(meta, body) = {
  show strong: set text(fill: rgb(meta.template_options.at("accent", default: "#165d8d")))
  editorial.document(meta, body)
}
#let chapter = editorial.chapter
#let diagram = editorial.diagram

Start from examples/template/.

Recipe: add a watermark

Styles that the built-in layout does not set yourself, such as page.background, survive when you set them before delegating:

#import "@local/typoprint:0.1.0": report

#let document(meta, body) = {
  let options = meta.template_options
  let label = options.at("label", default: "CONFIDENTIAL")
  let tint = rgb(options.at("accent", default: "#b3261e")).transparentize(88%)
  set page(background: rotate(-30deg, text(size: 72pt, weight: "bold", fill: tint, label)))
  report.document(meta, body)
}
#let chapter = report.chapter
#let diagram = report.diagram
typoprint notes.md --template examples/templates/confidential \
  --template-option 'label="DRAFT"' -o draft.pdf

Note

A built-in layout's own set rules run later and win over yours. To change something it already sets, such as the font, put your rule inside the body you hand over, where it runs after the layout's:

```typst

let document(meta, body) = editorial.document(meta, {

set text(font: "DejaVu Sans Mono") body }) ```

For page-level changes such as margins or the header, start from scratch.

Recipe: brand it with a logo and cover

A bundle may contain helper modules and assets. Imports and images resolve inside the bundle, so they never widen what Markdown can read.

templates/branded/
├── template.typ
├── brand.typ      # helper module
└── logo.svg       # bundle-local asset
// brand.typ
#let default-accent = rgb("#0b6e4f")
#let logo(width: 28mm) = image("logo.svg", width: width)
// template.typ
#import "@local/typoprint:0.1.0": editorial
#import "brand.typ"

#let document(meta, body) = {
  let options = meta.template_options
  let accent = rgb(options.at("accent", default: brand.default-accent.to-hex()))
  show heading: set text(fill: accent)
  show link: set text(fill: accent)
  // Replace the built-in cover by our own, then let `editorial` do the rest.
  let inner = meta + (cover: false)
  if meta.cover {
    page(header: none, footer: none, {
      brand.logo(width: 40mm)
      v(1fr)
      text(size: 2.6em, weight: "bold", fill: accent, meta.title)
      if meta.subtitle != none { linebreak(); text(size: 1.2em, meta.subtitle) }
      v(1em)
      text(options.at("company", default: "Acme") + if meta.author != none { " · " + meta.author } else { "" })
      v(1fr)
    })
  }
  editorial.document(inner, body)
}
#let chapter = editorial.chapter
#let diagram = editorial.diagram
typoprint docs/ --cover --template examples/templates/branded \
  --template-option 'company="Acme Inc"' --template-option 'accent="#0b6e4f"' \
  -o branded.pdf

Recipe: write a layout from scratch

You can ignore editorial and report entirely. Reuse the shared building blocks for the parts you do not want to rewrite.

#import "@local/typoprint:0.1.0": foundations

#let document(meta, body) = {
  set std.document(title: meta.title, author: if meta.author != none { meta.author } else { () })
  set page(
    paper: meta.paper,
    margin: (x: 28mm, y: 24mm),
    header: foundations.running-header(meta),
    footer: foundations.running-footer(),
  )
  set text(font: "Libertinus Serif", size: 11pt, lang: meta.lang)
  set par(justify: true)
  show heading: set text(font: "DejaVu Sans Mono", weight: "regular")
  show: it => if meta.lang == "fr" { foundations.french-spacing(it) } else { it }

  // Letterhead instead of a title page.
  text(size: 1.6em, weight: "bold", meta.title)
  if meta.subtitle != none { linebreak(); text(fill: foundations.muted, meta.subtitle) }
  v(0.4em)
  foundations.byline(meta)
  line(length: 100%, stroke: foundations.rule)
  body
}

#let chapter(meta, body) = {
  pagebreak(weak: true)
  [#metadata(meta.source) <typoprint-source>]   // lets the footer name the source file
  if not meta.title-in-body { heading(level: meta.chapter-level, meta.title) }
  body
}

#let diagram(body) = align(center, body)

Full file: examples/templates/memo/.

Tip

Honor meta.cover and meta.toc if you want --cover and --toc to keep working, and keep a diagram that scales wide figures down to the page. The built-in diagram refuses figures it would have to shrink below 50%.

Building blocks

Everything below comes from the versioned @local/typoprint:0.1.0 package, bundled with the Python distribution. Keep the version aligned when upgrading Typoprint.

#import "@local/typoprint:0.1.0": editorial, report, foundations
Module Exports
editorial document, chapter, diagram, cover(meta), and the palette ink, muted, accent, rule
report document, chapter, diagram, title-block(meta), and the palette ink, muted, accent, pale, rule
foundations labels, french-spacing, byline, current-part, running-header, running-footer, muted, rule
foundations item Use
labels labels.at(meta.lang) gives translated contents and version words
french-spacing(body) French narrow no-break spaces before ;:!?» and after «; leaves code and links alone
byline(meta) Author and date line
current-part(meta) The part the reader is in, or none
running-header(meta) Header naming the part or title and the chapter, hidden on opening pages
running-footer() Footer with the source file name and page / total

Options, fonts, and configuration

Options are data your template reads from meta.template_options; values must be JSON-compatible. Supply them from any entry point:

typoprint notes.md --template ./templates/acme --template-option 'accent="#165d8d"'
from typoprint import Settings

settings = Settings(
    template="./templates/acme",
    template_options={"accent": "#165d8d"},
    font_paths=("./fonts",),
)
[defaults]
template = "templates/acme"
font_paths = ["fonts"]

[defaults.template_options]
accent = "#165d8d"

Supplying template options at a higher precedence replaces the entire dictionary.

Fonts are selected by family name inside Typst (set text(font: "...")). Typoprint uses only the fonts bundled with the package (Typst's embedded fonts and Source Sans 3) so every host renders alike; system fonts are disabled. Load more with --font-path DIR or font_paths, and keep font licenses beside the files.

How bundles are loaded

The renderer copies a custom bundle into an invocation-local Typst package named @local/typoprint-custom:0.1.0; it supplies the package manifest. Bundle-local imports and images stay inside that package and do not widen the Markdown image root. The template bundle and font directories are trusted local resources, not arbitrary uploaded code. Missing bundle files, missing exports, or Typst failures stop rendering.

Custom bundles must use already bundled packages (@preview/cmarker, @preview/merman, and @local/typoprint) and bundle-local dependencies for offline operation. Typoprint does not install template dependencies. Keep custom Typst code compatible with the pinned Typst compiler. No template path or font path is accepted from Markdown front matter.

Learn Typst

Typoprint compiles with the open-source Typst engine. These are the pages you will use most when writing a template:

Topic Link
Start here: the Typst tutorial typst.app/docs/tutorial
set and show rules: restyle anything Styling reference
Advanced styling tutorial (headers, numbering, templates) Advanced styling
Writing a template: functions with arguments Making a template
Page size, margins, header, footer, background page
Fonts, size, language, hyphenation text
Paragraph spacing and justification par
Headings and numbering heading
Tables table
Contents outline
Images image
Code blocks raw
Scripting: variables, functions, if, loops Scripting
Reading page state in headers and footers Context
Everything else Full reference
Community packages (bundle only what you vendor) Typst Universe

Typoprint's Markdown reader is cmarker and diagrams come from merman, both vendored with their licenses in the package.

Python content hooks

render and render_files accept transforms: Sequence[Transform] and validators: Sequence[Validator]. A transform has signature (markdown: str) -> str; a validator has signature (markdown: str) -> None and raises RuleError to refuse input. Each chapter runs every transform in order, followed by every validator. Front matter is part of the text passed through these hooks. Hook output is parsed for titles and metadata after transformation.

from typoprint import RuleError, render


def require_summary(markdown: str) -> None:
    if "## Summary" not in markdown:
        raise RuleError("A Summary section is required")


result = render(
    "# Draft\n\n## Summary\n\n{{product}} is ready.",
    transforms=[lambda text: text.replace("{{product}}", "Typoprint")],
    validators=[require_summary],
)

Hook text is a fresh value; input files and Document values are never rewritten. All chapters pass validation before compilation begins. Unexpected hook exceptions become RuleError with a generic message; the original exception remains chained for trusted debugging. A deliberately raised RuleError retains its developer message. Do not put confidential text in messages intended for users.

There is no automatic callback discovery in configuration. Applications explicitly import and pass their Python functions. CLI users needing Python rules can write a small script around render_files and write_pdf. See the Python API for the full reference.