Skip to main content

Markdown to PDF

A report, release notes or an answer a model wrote are often Markdown already. Send the text as markdown and the API returns a PDF with a look of its own, without a template and without a line of CSS:

{
"markdown": "# Quarterly report\n\nRevenue rose by **12 %**.\n\n| Region | Revenue |\n| --- | --: |\n| North | 1,200 |\n",
"settings": { "pdf": { "outline": true } }
}

markdown is the fourth source of POST /v1/renders, beside template, html and url, and of the items of a batch: send exactly one of them. Everything else of a render applies as for html: paper and margins, header and footer, filename, mode, webhooks, post-processing, PDF/UA-1, PDF/A and an e-invoice. Every plan renders Markdown, at the units of any PDF: one per started five pages, nothing with a test key.

What it reads​

  • GitHub Flavored Markdown: headings, emphasis, lists, links, pictures, code, quotes, tables with their alignment, task lists (- [x] done), strikethrough (~~old~~) and bare https:// addresses as links.
  • Footnotes: [^1] in the text and [^1]: The source. anywhere below; they are numbered and printed at the end.
  • Links between headings: every heading gets an id by GitHub's rule (## Setup and costs is #setup-and-costs, a second heading of the same text #setup-and-costs-1), so [see the costs](#setup-and-costs) is a link inside the PDF.
  • Page breaks: a line that is \newpage, \pagebreak or <!-- pagebreak --> starts a new page.
  • Front matter: a YAML block between --- lines at the very top is removed from the document. Its title, author, subject and keywords become the PDF's properties where settings.pdf.metadata leaves them empty.
---
title: Quarterly report Q3
author: Fennlor Studio GmbH
keywords: report, 2026
---

# Quarterly report

Revenue rose by **12 %**[^1].

\newpage

## Outlook \{#outlook}

[^1]: Compared with Q2.

The title of the PDF, which a reader shows in its title bar and a screen reader announces first, is settings.pdf.metadata.title, else the front matter's title, else the text of the first heading. The request's locale (or settings.locale) sets the document's language, <html lang>, with or without an engine.

Raw HTML is shown as text​

Markdown allows HTML in its text, and HTML can run scripts and load things. By default (settings.markdown.html: "safe") the API writes none of it:

  • Raw HTML is shown as text (a block of it in a small grey monospace face in the document theme, a tag inside a sentence in the sentence's own type), so what the text held stays visible instead of disappearing without a word. <script>alert(1)</script> prints as those characters, and so does a <div style>.
  • Kept as tags, without any attribute: <br>, and <sup>, <sub>, <kbd>, <mark>, <u>, <ins>, <del>, <s> and <small> with their closing tags. A table cell needs <br> for a second line, and models write it.
  • HTML comments are removed, but for the page break <!-- pagebreak -->.
  • Links keep http, https, mailto, tel, relative addresses and #anchors. A link with another scheme keeps its text and loses the link; a javascript:, vbscript: or file: address stays the text that was written.
  • Pictures keep http, https, relative addresses (the file library) and data:image/ in PNG, JPEG, GIF, WebP and SVG.

"html": "allow" writes raw HTML as it stands, scripts and styles included, as an html render does. The rules for links and pictures still hold for Markdown's own syntax. Use it only for Markdown you wrote yourself, such as your own release notes with a styled box: never for text from your users or from a model, which is what the default is for.

The look​

settings.markdownValuesDefaultMeaning
themedocument, nonedocumentdocument styles the text; none is the print reset alone, for a look of your own.
htmlsafe, allowsafeWhat raw HTML in the text does (above).
breakstrue, falsefalseA single line break becomes a line break, as in a chat. Off, a paragraph ends only at an empty line.
cssa stylesheet, ≤ 256 kBnoneComes after the theme. The converter writes the document into <div class="ff-markdown">.

The block is strict: an unknown key, a theme or mode that does not exist, or the block beside any other source answers 400 invalid_request with the field it is about, so a typo never prints a document in another look. A stored template's settings.markdown is never read.

document sets the text in Noto Sans and code in a monospace face the renderer has, so a render downloads no font, with headings that keep to what follows them, tables with lines, quotes with a bar, and no element wider than the page. Where the organisation has a brand kit, headings take its heading font, text its body font and size, and links, rules and quotes its primary colour. none reads nothing of the kit.

{
"markdown": "# Release notes\n\n…",
"settings": {
"markdown": {
"theme": "none",
"css": ".ff-markdown { font-family: Georgia, serif; } .ff-markdown h1 { color: #0f766e; }"
}
}
}

Headings and tables​

  • Heading levels are closed up in the document's structure: the first heading is an h1 and no heading is more than one level below the one it stands under, which is what PDF/UA-1 and a screen reader expect. A heading keeps the size you wrote, through a class (ff-h2 for ##), so a letter that starts with ## still looks like one.
  • An empty table cell holds a no-break space, because the browser writes no cell into the PDF's structure for an empty one, and a table with a missing cell fails PDF/UA-1.

Pictures​

A picture named by a relative name, ![Logo](logo.png) or ![Chart](charts/q3.png), is a file of the workspace's file library by that name, as in a template. Upload it once and every document finds it. A picture can also be a public https:// address or a data:image/ address.

formfeed pdf convert notes.md and the local MCP server's markdown_to_pdf with a path send the pictures inside the file's folder along as data:image/ addresses, so a README.md with its screenshots converts as it is. A path that leads outside the folder is refused, a name with no file behind it stays a name of the library, and the text with its pictures may not grow past 1 MB.

A template over data​

With an engine, the text is a template over data first and then read as Markdown, with the helpers, the brand kit and the shared partials:

{
"markdown": "# Report for {{ customer.name }}\n\n{% for item in items %}- {{ item.name }}: {{ item.total | money }}\n{% endfor %}",
"engine": "jinja2",
"data": {
"customer": { "name": "Olvarest GmbH" },
"items": [{ "name": "Consulting", "total": 960 }]
}
}

Values are HTML-escaped as in every template, so a value cannot bring HTML in; it can bring Markdown, which is what a notes field is for. Markdown decodes no entities in code, so an escaped value inside a code block shows &amp;: write {{ value | safe }} there, since a code block prints its text as text anyway. The markdown helper inside such a text follows the document's mode, so a safe document stays safe through {{ notes | markdown }}.

validate_data does not apply: there is no stored schema to check the data against, and the request answers 400 invalid_request.

Header and footer are HTML, as for every PDF: settings.header.html and settings.footer.html, rendered with the engine where there is one. Page numbers are Chromium's classes:

{
"markdown": "# Handbook\n\n…",
"settings": {
"margin": { "bottom": "20mm" },
"footer": {
"html": "<div style=\"font-size: 8pt; text-align: center; width: 100%\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
}
}
}

See Headers, footers and page numbers for what works there.

Bookmarks, PDF/UA-1 and PDF/A​

  • settings.pdf.outline: true writes bookmarks from the headings, nested by their level.
  • settings.pdf.ua: true makes the file a PDF/UA-1 document: tagged, repaired and held against veraPDF's machine-checkable rules before it is delivered, with the verdict as accessibility. A Markdown document brings what those rules ask for: a title, closed-up headings, table headers, filled cells and the document's language. Alternative texts are yours: write them in ![…]. A passed check names which rules the file passed, by which validator; it does not make a document accessible.
  • settings.pdf.pdfa makes it a PDF/A file for archiving.
  • With the _invoice block in data and post.einvoice, the PDF is an e-invoice, as for html.

Limits​

WhatLimit
markdown1 MB of UTF-8, refused with 400 invalid_request; that is several hundred pages of prose
settings.markdown.css256 kB
Converting the text5 seconds, the time limit of filling a template, else 504 render_timeout
Everything after itas for any PDF: page and size limits of the plan, the time limits of the browser

A Markdown render is deduplicated like html: the same text, settings and data within 24 hours answer with the earlier file at no units, as long as the converter and the theme are the same release. engine_version names both, such as chromium 153.0.8010.12; engine none; markdown-it 15.0.3, rules 1, theme document 1.

From the CLI, an agent or the SDKs​

npx formfeed pdf convert notes.md --theme document

formfeed pdf convert reads a .md file as Markdown on every plan, with --css <file>, --settings <file> (a JSON file of render settings), --raw-html (html: "allow"), --landscape and --page-ranges, and saves notes.pdf (command reference). An agent has the MCP tool markdown_to_pdf. The SDKs take markdown in renders.create:

const render = await client.renders.create({
markdown: '# Notes\n\n- one\n- two',
settings: { markdown: { breaks: true } },
});

Markdown inside a template​

A stored template that shows a Markdown field among other content uses the markdown helper: {{ notes | markdown({ html: 'safe' }) }}. The helper converts a fragment, so the document's rules (front matter, the title, closed-up headings, the wrapper) stay with the markdown source, and raw HTML passes unless you give it html: 'safe', because a template's data is its author's.