Skip to content

Latest commit

 

History

History
765 lines (584 loc) · 22.9 KB

File metadata and controls

765 lines (584 loc) · 22.9 KB

Configuration guide

markdown2pdf ships with sensible defaults, six bundled theme presets, and a TOML configuration surface that controls every visual choice the renderer makes — fonts, colors, spacing, alignment, page setup, headers / footers, table of contents, title page, and per-block typography for every markdown construct.

You can ship a useful PDF with zero config. You can also write 10 lines of TOML and produce a document that looks like GitHub, a paper, or a corporate report — without touching code.

Quick start

Pick a theme and render:

markdown2pdf -p input.md --theme github -o out.pdf

Bundled themes:

Theme Feel
default Clean readable baseline (used when no theme is set)
github GitHub README rendering
academic Justified body, conservative serif feel
minimal Stripped-down typography, lots of breathing room
compact Tight spacing for high-density reference docs
modern Sharper hierarchy with a contemporary heading scale

Inspect the effective configuration for any combination:

markdown2pdf --theme academic --print-effective-config

That prints the full resolved style as TOML — copy any section into your own config and tweak.

Your own config file

Copy docs/config.toml from the repo to wherever you want and pass it with -c:

markdown2pdf -p input.md -c my-config.toml -o out.pdf

Every field is optional. theme = "github" (or any other preset) inherits a known-good baseline; you override only what you care about.

To make a config the default without passing -c every time, place it where the binary discovers it automatically: markdown2pdf.toml in the project directory, or markdown2pdf/config.toml under your user config directory (~/.config/markdown2pdf/config.toml on macOS/Linux). The MARKDOWN2PDF_CONFIG environment variable also points at one. See cli.md for the full lookup order.

Any field below can also be overridden per-run from the command line (winning over the config file and --theme) — see cli.md for --title / --font-size / --margin / -V key=value and the dotted-key syntax.

How configuration resolves

The renderer composes the final style by cascading values in this order, from lowest priority to highest:

  1. Bundled default theme — the lowest-priority baseline, always present.
  2. inherits = "..." chain — each preset can extend another (the github preset, for example, inherits from default). Cycles are detected and rejected with InheritsCycle.
  3. [defaults] block in any config — fields in [defaults] cascade into every block that doesn't override them. Useful when you want one body font across the whole doc.
  4. Per-block overrides[paragraph], [headings.h1], etc. take precedence over [defaults].
  5. CLI flag override--theme NAME overrides any theme = "..." value in the config file itself.

Pages

[page]
size = "A4"              # A4 | Letter | Legal | A3 | A5
                         # or { width_mm = 100.0, height_mm = 150.0 }
orientation = "portrait" # portrait | landscape
margins = { top = 22.6, right = 22.6, bottom = 22.6, left = 22.6 }  # mm
columns = 1              # 1 (multi-column is a follow-up)
column_gap_mm = 6.0

A Mm margin like 22.6 is millimeters; the renderer converts to PDF points internally.

Defaults cascade

Every per-block section inherits any unset field from [defaults]:

[defaults]
font_family = "Helvetica"
font_size_pt = 11.0
font_weight = "normal"  # normal | bold | numeric 100..=900
font_style = "normal"   # normal | italic
text_color = "#1B1F23"
line_height = 1.5       # multiplier of font_size_pt
text_align = "left"     # left | center | right | justify
padding = 0.0
margin_before_pt = 0.0
margin_after_pt = 0.0
indent_pt = 0.0
fallback_fonts = ["Noto Sans CJK SC", "Noto Sans Arabic", "Symbola"]

Body font

font_family in [defaults] selects the font that is loaded and embedded — a built-in alias (Helvetica, Times, Courier), a system font name, or a path to a .ttf / .otf file. A built-in alias uses a PDF base-14 font (no embedding; non-ASCII glyphs transliterate to ASCII). Any other name is resolved against the system font directories and embedded, which is required for Unicode glyphs such as .

The --default-font CLI flag overrides this; when it is omitted the config's font_family is used.

Fallback fonts

fallback_fonts is an ordered list of font names consulted when the primary body / code font lacks a glyph for a codepoint. Mixed-script documents (Latin + CJK, Arabic, Hebrew, math symbols, emoji) render each codepoint in the first configured font that covers it; characters unmatched by every font degrade to ? rather than panicking.

Names resolve the same way as font_family — built-in aliases, system font names, or paths to .ttf / .otf files. The field is only read from [defaults]; per-block tables ignore it.

Programmatic callers can set the same list on FontConfig:

let cfg = FontConfig::new()
    .with_default_font("Helvetica")
    .with_fallback_fonts(["Noto Sans CJK SC", "Symbola"]);

Colors accept hex strings ("#RRGGBB", "#RGB"), structs ({ r = 255, g = 0, b = 0 }), or arrays ([255, 0, 0]).

Padding and margin can be a scalar (applies to all sides), a pair ([vertical, horizontal]), a quad ([top, right, bottom, left]), or a struct ({ top, right, bottom, left }).

Block types

Every block type accepts the same superset of fields plus a few construct-specific ones. Set only what you want to override.

Paragraph

[paragraph]
text_align = "justify"
margin_after_pt = 4.0
small_caps = false

text_align = "justify" distributes inter-word slack on non-last lines via the PDF Tw (word-spacing) operator. The last line of a paragraph always stays left-aligned (typographic convention). When slack exceeds 30% of the column width, the line silently falls back to left-alignment to avoid grotesque stretches.

small_caps = true renders originally-lowercase letters at 78% size in uppercase (faux small caps); digits, punctuation, and originally-uppercase letters stay full-size.

Headings 1–6

Each heading level has its own section. Drop any subsection to inherit from [defaults] (and the active theme).

[headings.h1]
font_size_pt = 22.0
font_weight = "bold"
text_align = "left"
margin_before_pt = 8.0
margin_after_pt = 4.0
small_caps = false

[headings.h2]
font_size_pt = 17.0
font_weight = "bold"

Headings automatically:

  • Register as PDF bookmarks (the viewer's outline panel)
  • Generate a GitHub-style slug anchor for [text](#slug) links

Code blocks (fenced ```)

[code_block]
font_family = "Courier"
background_color = "#F6F8FA"
text_color = "#1F2328"
padding = { top = 8.0, right = 10.0, bottom = 8.0, left = 10.0 }
margin_before_pt = 6.0
margin_after_pt = 6.0

[code_block.border]
all = { width_pt = 0.5, color = "#E1E4E8", style = "solid" }

border accepts per-side (top, right, bottom, left) or all for uniform borders. Styles: solid, dashed, dotted.

Inline code (`)

[code_inline]
font_family = "Courier"
background_color = "#EFF1F3"
text_color = "#1F2328"

Block quotes (>)

[blockquote]
font_style = "italic"
text_color = "#57606A"
indent_pt = 17.0
padding = { top = 2.0, right = 6.0, bottom = 2.0, left = 8.0 }
margin_before_pt = 4.0
margin_after_pt = 4.0

[blockquote.border]
left = { width_pt = 3.0, color = "#D0D7DE", style = "solid" }

Lists

Three flavors: unordered, ordered, task. All inherit from [list.common].

[list.common]
margin_after_pt = 0.5
indent_per_level_pt = 17.0
item_spacing_tight_pt = 0.5  # CommonMark "tight" list (no blank lines)
item_spacing_loose_pt = 2.0  # CommonMark "loose" list (any blank line)
bullet_gap_pt = 5.67         # horizontal gap between the bullet/number and the item text

[list.unordered]
bullet = ""   # any glyph

[list.ordered]
bullet = "1."  # numeric format hint: "1." or "1)"

[list.task]
# Renderer emits [x] / [ ] for task items automatically.

Tables (GFM)

[table]
row_gap_pt = 2.0
cell_padding = { top = 3.0, right = 4.0, bottom = 3.0, left = 4.0 }
margin_before_pt = 4.0
margin_after_pt = 4.0
# alternating_row_background = "#FAFBFC"   # uncomment for zebra stripes

[table.header]
font_weight = "bold"

[table.cell]
# Inherits from defaults; override per-cell font / color here.

[table.border.all]
width_pt = 0.5
color = "#D0D7DE"
style = "solid"

Column alignment (:---, :---:, ---: in markdown) is honored. Header rows repeat at the top of each page the table spans.

Images

[image]
max_width_pct = 100.0  # 1..=100; cap as a fraction of content width
align = "center"       # left | center | right
margin_before_pt = 4.0
margin_after_pt = 4.0

Images support:

  • Local files — PNG and JPEG via the bundled image crate.
  • URL fetching![alt](https://...) works when compiled with --features fetch. Uses rustls (pure-Rust TLS). The fetch has a 5-second timeout and 10 MB cap; failures degrade to italic alt text.
  • SVG — vector images (.svg) rasterize via resvg when compiled with --features svg. Useful for README hero images served by GitHub.
  • Captions![alt](url "Caption text") renders the title as a small italic caption beneath the image, wrap-constrained to the image's width when the image is narrower than the column.

Links

[link]
text_color = "#0969DA"
underline = false

Links support tooltips via the markdown title attribute:

See [the spec](https://example.com/spec "Hover tooltip here").

The tooltip lands in the PDF's /Contents entry on the link annotation; supported PDF viewers display it on hover.

Inline HTML anchors are recognised too, which is handy when content comes from HTML-converted sources:

Visit <a href="https://example.com" title="Hover tooltip here">the site</a>.

The href becomes the link target and the optional title flows through the same tooltip path. Hrefs may use single or double quotes and the tag name / attributes are case-insensitive (<A HREF="…">…</A> works). An <a> without href, a self-closing <a … />, an unclosed opener, or a stray </a> degrades to literal markup rather than producing a broken annotation.

Internal cross-references resolve automatically:

See [the conclusion](#conclusion) for context.

# Conclusion

#conclusion matches the GitHub-style slug of the heading text. If two headings have the same text, the second gets -2, the third -3, etc. Unresolved anchors log a warning and emit no annotation.

WikiLinks resolve through the same anchor machinery:

See [[Conclusion]] or [[conclusion|the wrap-up]].

# Conclusion

[[Target]] links to the heading whose slug matches Target; [[Target|Label]] shows Label instead. There is no [wikilink] config block — a WikiLink renders with the [link] style above and resolves like any #slug, so an unmatched target logs a warning and falls back to styled text rather than breaking the export.

Footnotes ([^id] and ^[…])

Two syntaxes are supported. The GFM reference form defines the note separately:

Tea rewards patience[^steep].

[^steep]: Two to three minutes is a sensible start.

The Pandoc inline form writes the note in place — no separate definition, no label to invent:

Water just off the boil^[around 90–95 °C for black teas] is plenty.

Both share one numbering sequence, assigned in first-reference order as they appear in the document, so inline and reference footnotes interleave correctly. Every marker renders as a superscript number linking to its entry, and all notes are collected into a single Footnotes section appended at the end of the document. A reference definition may span multiple lines (continuation lines indented at least four spaces); a defined-but-unreferenced [^id]: is still listed so it never silently vanishes.

There is no [footnote] config block — markers use the body / [link] style above and the section heading uses Heading 2 typography. Malformed input degrades to literal text rather than breaking the export: an unbalanced ^[, an empty ^[], or a [^id] with no matching definition all render as plain characters.

Highlight (==text==)

[mark]
background_color = "#FFF59D"

==text== paints background_color behind the run (a soft yellow by default). It nests with other inline styles, so ==**bold**== is both bold and highlighted:

Some ==important== text, and a ==**bold mark**==.

== is only a highlight mid-content: a line that is exactly === (or ---) still underlines the paragraph above it as a Setext heading, and an unterminated == renders as literal text.

Admonitions (!!! kind / > [!KIND])

[admonition]
padding = { top = 8.0, right = 12.0, bottom = 8.0, left = 14.0 }
margin_before_pt = 4.0
margin_after_pt = 4.0

[admonition.note]
accent_color = "#448AFF"
background_color = "#E7F2FF"

Two authoring syntaxes are recognised and both produce the same styled box:

!!! warning "Watch out"
    A MkDocs-style admonition. The body is indented at least four
    spaces; blank lines inside the body are preserved.

> [!NOTE]
> A GitHub-flavoured alert. The marker may stand alone on the
> first line, or carry inline content after a space.

First-class kinds are note, info, tip, warning, and danger. The obvious aliases collapse to those so a document authored against either ecosystem renders the same way: caution and error map to danger, important maps to info, warn and attention map to warning, hint maps to tip.

Unknown kinds (!!! bug "Repro", > [!QUESTION]) fall back to a generic palette and surface the raw label uppercased as the header so the author's intent is never erased:

!!! bug "Repro steps"
    Renders in the generic grey box with "Repro steps" as the
    header.

!!! quirk
    No title — the header reads "QUIRK" verbatim.

The [admonition] block holds shared shape (padding, margins, font defaults). Per-kind sub-blocks layer colour and label overrides on top:

[admonition.danger]
accent_color = "#FF1744"
background_color = "#FFEBEE"
label = "STOP"        # overrides the default "DANGER" header

accent_color drives both the left border and the per-kind icon (note ●, info ⓘ, tip 💡, warning ⚠ +!, danger ⊗, generic ≡); icons are drawn as vector glyphs so they don't depend on any font's coverage. Each bundled theme ships its own palette — github matches GitHub's alert colours, academic / minimal stay restrained, modern leans vibrant.

A custom "…" title on the MkDocs form replaces the default header; inline markdown inside the title (emphasis, code) is preserved. Admonition bodies are block sequences — lists, fenced code, tables, even nested admonitions all work — and inline <a href="…"> anchors inside the body still become clickable links.

Math ($…$, $$…$$)

$…$ is inline math and $$…$$ is a centered display block:

The identity $a^2 + b^2 = c^2$ holds, and

$$\int_0^1 x\,dx$$

The content between the delimiters is opaque TeX — no markdown parsing or escape decoding happens inside. A built-in TeX engine typesets it (TeXbook Appendix-G layout over STIX Two Math's OpenType MATH metrics): real fraction bars, radicals with indices, sub/ superscript stacks, big operators with limits, delimiters that grow to their content, matrices, cases, accents, Greek, and \mathbb/\mathbf/\mathcal/… alphabets. Inline math sits on the text baseline and wraps as one indivisible box; display math is its own block. A command the engine doesn't know degrades to literal text rather than failing.

The glyphs are drawn as filled vector outlines, not text — no font is embedded and the equation is not selectable, so it behaves like a figure in every PDF viewer (this matches how LaTeX-, MathJax-, and KaTeX-to-PDF pipelines treat math; selectable math would require tagged-PDF /ActualText).

Delimiter handling follows Pandoc, so prose with dollar signs is not mistaken for math:

  • The opening $ must be followed by a non-space character, and the closing $ must be preceded by one and not directly followed by a digit — $5 and $6 stays literal text.
  • \$ is a literal dollar (\$5.00 renders as $5.00).
  • Inline math is single-line; display math may span lines but a blank line ends it.
  • An unterminated $ or $$ degrades to literal text rather than breaking the export.

Display blocks are configurable via [math]:

[math]
align = "center"   # center (default) | left | right
scale = 1.08        # display size as a multiple of the body size
color = "#1A1A1A"   # math ink (defaults to the paragraph text color)
margin_before_pt = 6
margin_after_pt = 6

scale applies to display blocks; inline math always tracks the surrounding text size. With no [math] table, display math is centered at 1.08× the body size in the paragraph color, with the paragraph's block spacing.

Horizontal rule (---)

[horizontal_rule]
color = "#D0D7DE"
thickness_pt = 0.5
style = "solid"   # solid | dashed | dotted
width_pct = 100.0
margin_before_pt = 6.0
margin_after_pt = 6.0

Document features

Metadata (PDF Info dict)

[metadata]
title = "My Document"
author = "Author Name"
subject = "Subject line"
keywords = ["one", "two"]
creator = "markdown2pdf"
language = "en-US"

language is a BCP-47 tag emitted as the PDF Catalog /Lang entry, used by screen readers to select a pronunciation dictionary. It is omitted entirely when unset (no faked default).

Non-ASCII values are encoded as UTF-16BE with a FEFF BOM (PDF spec compliant).

Headers and footers

Three slots per row (left / center / right) with template variables. Available variables: {page}, {total_pages}, {title}, {date}, {author}.

[header]
left = "{title}"
right = "{page} / {total_pages}"
show_on_first_page = false

[footer]
center = "Page {page}"

Page numbers substitute correctly because the renderer collects raw pages first, then assembles each PdfPage once the total page count is known.

Title page

[title_page]
title = "Document Title"
subtitle = "Optional subtitle"
author = "Author Name"
date = "2026-05-15"
# cover_image_path = "cover.png"   # deferred follow-up

When title is set, the renderer prepends a dedicated first page with the title (2.4× the base font size, bold), subtitle (1.4×), author (1.1×), and date (1.0×), all centered both horizontally and vertically. Headers / footers are suppressed on title pages.

Auto-generated table of contents

[toc]
enabled = true
title = "Contents"
max_depth = 3

When enabled = true, every heading at or above max_depth becomes a TOC entry between the title page (if any) and the body. Each entry is a clickable GoTo link to its target heading. The renderer runs a convergence loop on page count (bounded at 3 iterations) so the displayed page numbers match the final post-TOC offsets.

Hyphenation

The split_long_words pre-pass consults a Knuth-Liang English dictionary (hyphenation crate) to find break points in any word that exceeds the column width. When a dictionary break fits in the remaining space, the renderer emits prefix + "-" and continues with the suffix on the next chunk. Words the dictionary doesn't know (long URLs, identifiers, repeated-char tokens) fall back to UTF-8 char boundaries.

Page breaks

Force a page break with a standalone HTML comment:

First page content.

<!-- pagebreak -->

Second page content.

The marker is case-insensitive and whitespace-tolerant.

Inline HTML

markdown2pdf understands a small, deliberately conservative subset of inline HTML. Anything outside the subset passes through as literal text — no scripting, no arbitrary HTML execution.

Inline styling tags apply to the wrapped text:

Tag Effect
<sup> superscript
<sub> subscript
<u> underline
<s>, <del>, <strike> strikethrough
<small> smaller text
<kbd> monospace (keyboard input)
<br> / <br/> soft line break

Anchors<a href="…" title="…">…</a> becomes a clickable PDF link annotation; see the Links section above.

Structural block wrappers drop out so their children render as normal paragraphs: <div>, <section>, <figure>, <figcaption>, <p>, and <center> (with or without attributes). This is what lets documents converted from HTML keep working without showing literal <div> markup.

<section>

Inner **markdown** still renders, including [links](https://example.com).

</section>

Comments (<!-- … -->) are invisible per CommonMark, and the special marker <!-- pagebreak --> forces a page break (see Page breaks).

Everything else — <span>, <aside>, custom elements, raw <script> / <style> / <pre> / <textarea> blocks — renders verbatim as a monospace HTML block, so the source stays visible rather than being silently dropped or interpreted.

Loading methods

Three ways to feed the renderer a config:

1. CLI flag (-c)

markdown2pdf -p input.md -c path/to/config.toml -o out.pdf

2. CLI theme override (--theme)

markdown2pdf -p input.md --theme github -o out.pdf

The --theme NAME flag overrides any theme = "..." value in the config file itself, useful for quickly switching presets without editing the config.

3. Library API

The Rust API takes a ConfigSource:

use markdown2pdf::config::ConfigSource;
use markdown2pdf::parse_into_bytes;

// Default style.
let bytes = parse_into_bytes(md, ConfigSource::Default, None)?;

// Load from a file path.
let bytes = parse_into_bytes(md, ConfigSource::File("config.toml"), None)?;

// Embed at compile time.
let cfg = include_str!("../my-config.toml");
let bytes = parse_into_bytes(md, ConfigSource::Embedded(cfg), None)?;

Error messages

The schema uses #[serde(deny_unknown_fields)], so typos in field names are caught at parse time. The error message includes the file path, line, column, the unknown field name, and a typo suggestion when a close match exists:

error in config.toml at line 5, column 1: unknown field `text_colr`,
expected one of `font_family`, `font_size_pt`, ..., `text_color`, ...
  hint: did you mean `text_color`?

Unknown theme names get the same treatment:

unknown theme preset `githb`
  did you mean `github`?

See also

  • docs/config.toml — the annotated reference config, drop in any of its sections to start tweaking.