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 barehttps://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 costsis#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,\pagebreakor<!-- pagebreak -->starts a new page. - Front matter: a YAML block between
---lines at the very top is removed from the document. Itstitle,author,subjectandkeywordsbecome the PDF's properties wheresettings.pdf.metadataleaves 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
documenttheme, 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; ajavascript:,vbscript:orfile:address stays the text that was written. - Pictures keep
http,https, relative addresses (the file library) anddata: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.markdown | Values | Default | Meaning |
|---|---|---|---|
theme | document, none | document | document styles the text; none is the print reset alone, for a look of your own. |
html | safe, allow | safe | What raw HTML in the text does (above). |
breaks | true, false | false | A single line break becomes a line break, as in a chat. Off, a paragraph ends only at an empty line. |
css | a stylesheet, ≤ 256 kB | none | Comes 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
h1and 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-h2for##), 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,  or , 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 &: 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, footer and page numbers
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: truewrites bookmarks from the headings, nested by their level.settings.pdf.ua: truemakes the file a PDF/UA-1 document: tagged, repaired and held against veraPDF's machine-checkable rules before it is delivered, with the verdict asaccessibility. 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.pdfamakes it a PDF/A file for archiving.- With the
_invoiceblock indataandpost.einvoice, the PDF is an e-invoice, as forhtml.
Limits
| What | Limit |
|---|---|
markdown | 1 MB of UTF-8, refused with 400 invalid_request; that is several hundred pages of prose |
settings.markdown.css | 256 kB |
| Converting the text | 5 seconds, the time limit of filling a template, else 504 render_timeout |
| Everything after it | as 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.