Markdown to Image: Node, Python and One API Call
Convert Markdown to PNG or WebP with marked + Puppeteer, Python-Markdown + Playwright, or one API request. Tested scripts, measured timings and file sizes.
To turn Markdown into an image, convert it to HTML, style the HTML, and take a screenshot of it in a headless browser. In Node.js that is three libraries and about 60 lines:
npm install [email protected] [email protected] [email protected] [email protected] [email protected] node md2img.mjs release-notes.md release-notes.png --theme dark
The full md2img.mjs is below, with a Python version and a third route: one request to a Markdown to image API that does the parsing, styling and capture for you. Each route has a section on what breaks, and the numbers come from runs on our own machine, not from vendor pages.
Tested on 2026-10-18 with Node.js 22.18.0, Puppeteer 25.14.0 (Chrome 155.0.8059.79), marked 18.1.1, marked-highlight 2.2.4, highlight.js 11.12.0 and sanitize-html 2.18.1; Python 3.12.15 with Playwright 1.64.0, Markdown 3.11.1, pymdown-extensions 12.3 and nh3 0.3.7. macOS 27.0.1 on an Apple M1 Pro.
Three routes, one decision
| Route | You control | You maintain | Best for |
|---|---|---|---|
| marked + Puppeteer (Node) | Every pixel: fonts, colors, highlighting, task lists | Chrome, a sanitizer, a stylesheet, font packages | Branded images, an app that already runs Chrome |
| Python-Markdown + Playwright | Same as above | Same as above, plus Markdown extensions | Python services and notebooks |
API with markdown_theme |
One of three themes, size and format | A JSON request | Release notes, chat posts, untrusted text, no browser to run |
The sample we render throughout is a short release note: a heading, inline code, a bullet list with a strikethrough, a three-column table, a fenced JavaScript block and a block quote that ends in an emoji. It is small, but it touches every part of a Markdown renderer that tends to go wrong.
Route 1: Node.js with marked and Puppeteer
The script has two exported functions. toHtml builds a complete page from Markdown, and render takes a screenshot of it in a browser you pass in. Keeping them apart lets you reuse one browser for many images, which matters more than any other setting (see the timings below).
// md2img.mjs - Markdown -> sanitized HTML -> PNG/WebP with Puppeteer. // Usage: node md2img.mjs notes.md notes.png [--theme dark] [--width 800] [--scale 2] import { readFile, writeFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; import { Marked } from 'marked'; import { markedHighlight } from 'marked-highlight'; import hljs from 'highlight.js'; import sanitizeHtml from 'sanitize-html'; import puppeteer from 'puppeteer'; const THEMES = { light: { bg: '#ffffff', fg: '#1f2328', muted: '#59636e', border: '#d1d9e0', code: '#f6f8fa' }, dark: { bg: '#0d1117', fg: '#e6edf3', muted: '#9198a1', border: '#3d444d', code: '#151b23' }, }; const marked = new Marked( markedHighlight({ emptyLangClass: 'hljs', langPrefix: 'hljs language-', highlight: (code, lang) => hljs.highlight(code, { language: hljs.getLanguage(lang) ? lang : 'plaintext' }).value, }), ); export function toHtml(markdown, theme = 'light', width = 800) { const t = THEMES[theme]; if (!t) throw new Error(`unknown theme: ${theme}`); // marked passes raw HTML through. Keep plain document markup, drop scripts and handlers. const body = sanitizeHtml(marked.parse(markdown), { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'del', 'input']), allowedAttributes: { a: ['href'], img: ['src', 'alt'], span: ['class'], code: ['class'], th: ['align'], td: ['align'], input: ['type', 'checked', 'disabled'], }, allowedSchemes: ['https', 'data'], }); return `<!doctype html><html><head><meta charset="utf-8"><style> html { background: ${t.bg}; } body { margin: 0; padding: 32px 40px; width: ${width}px; box-sizing: border-box; color: ${t.fg}; font: 16px/1.6 -apple-system, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Noto Color Emoji"; } h1, h2 { border-bottom: 1px solid ${t.border}; padding-bottom: .3em; } code { font: 85% ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; background: ${t.code}; padding: .2em .4em; border-radius: 6px; } pre { background: ${t.code}; padding: 16px; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; } pre code { padding: 0; background: none; } table { border-collapse: collapse; } th { text-align: left; } th, td { border: 1px solid ${t.border}; padding: 6px 13px; } blockquote { margin: 0; padding: 0 1em; color: ${t.muted}; border-left: .25em solid ${t.border}; } .hljs-keyword, .hljs-built_in { color: ${theme === 'dark' ? '#ff7b72' : '#cf222e'}; } .hljs-string { color: ${theme === 'dark' ? '#a5d6ff' : '#0a3069'}; } .hljs-literal, .hljs-number { color: ${theme === 'dark' ? '#79c0ff' : '#0550ae'}; } </style></head><body>${body}</body></html>`; } export async function render(browser, html, { width = 800, scale = 2, type = 'png' } = {}) { const page = await browser.newPage(); try { await page.setViewport({ width, height: 600, deviceScaleFactor: scale }); // setContent has no URL, so relative image paths cannot load: use absolute https URLs. await page.setContent(html, { waitUntil: 'load' }); await page.evaluate(() => document.fonts.ready); return await page.screenshot({ type, fullPage: true }); } finally { await page.close(); } } if (import.meta.url === `file://${process.argv[1]}`) { const { values, positionals } = parseArgs({ allowPositionals: true, options: { theme: { type: 'string', default: 'light' }, width: { type: 'string', default: '800' }, scale: { type: 'string', default: '2' }, }, }); const [input, output = 'out.png'] = positionals; if (!input) { console.error('usage: node md2img.mjs <input.md> [output.png|.webp] [--theme light|dark]'); process.exit(2); } const width = Number(values.width); const html = toHtml(await readFile(input, 'utf8'), values.theme, width); const browser = await puppeteer.launch(); try { const type = output.endsWith('.webp') ? 'webp' : output.endsWith('.jpg') ? 'jpeg' : 'png'; await writeFile(output, await render(browser, html, { width, scale: Number(values.scale), type })); } catch (err) { console.error(err.message); process.exitCode = 1; } finally { await browser.close(); } }
Five decisions in that file are worth explaining, because each one fixes something we saw go wrong:
- The sanitizer runs after marked, not before. marked does not escape raw HTML; it copies it into the output. We fed it a line with
<script>, an<img onerror=...>and ajavascript:link. Withoutsanitize-htmlall three reached the page. With it, the script and the handler were gone and the link became plain text. inputis on the allow list. marked renders GitHub task lists (- [x] done) as disabled checkboxes. The default sanitizer settings strip them, and the list items then lose their state. Allowingtype,checkedanddisabledkeeps the boxes.- Code wraps. With the usual
overflow: auto, a long shell command in a fenced block runs past the right edge and the screenshot cuts it off, because a screenshot has no scrollbar to drag.white-space: pre-wrapwithoverflow-wrap: anywherewraps it inside the box. bodyhas a fixed width andfullPage: true. The width you pick is the width of the image; the height follows the content. WithoutfullPage, a long changelog is cut at the 600 px viewport height.- The script waits for
document.fonts.ready. Theloadevent can fire before a web font has finished decoding. For the system fonts in this stylesheet the wait costs nothing; it starts to matter as soon as you add a@font-face.
Route 2: Python with Python-Markdown and Playwright
The Python version has the same shape. It uses nh3 as the sanitizer (a binding to the Rust ammonia library) and Playwright's sync API.
pip install playwright==1.64.0 markdown==3.11.1 pymdown-extensions==12.3 nh3==0.3.7 python -m playwright install chromium
"""md2img.py - Markdown -> sanitized HTML -> PNG with Playwright. Usage: python md2img.py notes.md notes.png """ import html import sys from pathlib import Path import markdown import nh3 from playwright.sync_api import sync_playwright CSS = """ body { margin: 0; padding: 32px 40px; width: 800px; box-sizing: border-box; color: #1f2328; font: 16px/1.6 -apple-system, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Noto Color Emoji"; } h1, h2 { border-bottom: 1px solid #d1d9e0; padding-bottom: .3em; } code { font: 85% ui-monospace, Menlo, Consolas, monospace; background: #f6f8fa; padding: .2em .4em; border-radius: 6px; } pre { background: #f6f8fa; padding: 16px; border-radius: 6px; } pre code { padding: 0; } table { border-collapse: collapse; } th { text-align: left; } th, td { border: 1px solid #d1d9e0; padding: 6px 13px; } blockquote { margin: 0; padding: 0 1em; color: #59636e; border-left: .25em solid #d1d9e0; } """ def to_html(text: str, title: str) -> str: body = markdown.markdown(text, extensions=['tables', 'fenced_code', 'pymdownx.tilde']) body = nh3.clean(body) # drops <script>, event handlers and javascript: links return (f'<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>' f'<style>{CSS}</style></head><body>{body}</body></html>') def main(src: str, out: str) -> None: doc = to_html(Path(src).read_text(encoding='utf-8'), Path(src).stem) with sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page(viewport={'width': 800, 'height': 600}, device_scale_factor=2) page.set_content(doc, wait_until='load') page.evaluate('document.fonts.ready') page.screenshot(path=out, full_page=True) finally: browser.close() if __name__ == '__main__': if len(sys.argv) != 3: sys.exit('usage: python md2img.py <input.md> <output.png>') main(sys.argv[1], sys.argv[2])
Our first Python run printed ~~Node 18 support~~ with the tildes in the image. Python-Markdown implements the original Markdown syntax, which has no strikethrough, so the pymdownx.tilde extension in the listing is not optional if your text comes from GitHub or a chat tool. Task lists need another extension again (pymdownx.tasklist), which we did not test here. The general rule: list the Markdown features your input actually uses, then check each one in the output, because the Python and JavaScript parsers do not agree on defaults.
On the sanitizer: nh3 with its default settings kept the table, code and <del> elements, removed the <script> element and the onerror attribute, and dropped the href of a javascript: link. In Python, deviceScaleFactor is device_scale_factor on new_page, and full_page=True does the same job as in Node.
What it costs: measured timings and file sizes
We rendered the release-note sample at 800 px wide, 7 times per row, and report the median. "Cold" launches Chrome for each image; "warm" reuses one browser and opens a new page per image, as render() does.
| Run | Format and scale | Median time | Median size |
|---|---|---|---|
| Cold (launch + render + close) | PNG, 2x | 880 to 905 ms | 132 KB |
| Warm | PNG, 1x | 149 to 156 ms | 61 KB |
| Warm | PNG, 2x | 157 to 167 ms | 132 KB |
| Warm | JPEG, 2x | 147 to 185 ms | 109 KB |
| Warm | WebP, 2x | 252 to 266 ms | 53 KB |
| Warm, dark theme | PNG, 2x | 191 to 192 ms | 130 KB |
| Warm, dark theme | WebP, 2x | 244 to 283 ms | 53 KB |
Method: Node 22.18, Puppeteer 25.14.0, Chrome 155, macOS on an Apple M1 Pro, two runs of 7 renders each on an idle machine; the ranges show both runs. The output was 1600 x 1434 px at 2x. The numbers are local browser time only, with no network.
Three things stand out:
- Reuse the browser. A cold render took five to six times as long as a warm one.
- WebP costs time and saves bytes. It took about 100 ms more to encode than PNG at 2x and produced a file 60% smaller. For chat posts and web pages that is the right trade. Keep PNG for archives and for tools that cannot read WebP.
- Scale 2 doubles the bytes, not the time. Retina output cost roughly 10 ms more than 1x here. If people read the image on phones, use 2x.
Fonts and emoji on a Linux server
The renders above came from a Mac, which ships with emoji and CJK fonts. A slim Debian or Ubuntu container usually does not, and the same Markdown then shows empty boxes where the emoji and any Chinese, Japanese or Korean text should be. Install the fonts in the image that runs Chrome:
RUN apt-get update \ && apt-get install -y --no-install-recommends fonts-noto-color-emoji fonts-noto-cjk fonts-liberation \ && rm -rf /var/lib/apt/lists/*
Then name the emoji font in your font-family list, as both stylesheets above do with "Noto Color Emoji". We did not run this Dockerfile for this article; our emoji rendering guide covers the font side in more depth.
Route 3: one request to an API
If the three built-in looks are enough, you can skip Chrome, the parser and the sanitizer. RenderScreenshot accepts Markdown directly in a POST body:
curl -sS --fail-with-body -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --rawfile md release-notes.md '{ markdown: $md, markdown_theme: "dark", viewport: { width: 800, height: 600, scale: 2 }, capture: { mode: "full_page" }, output: { format: "webp" } }')" \ -o release-notes.webp
jq --rawfile reads the file into a JSON string, so quotes, backslashes and newlines in the Markdown are escaped correctly. Building that JSON by hand with string concatenation is the most common way to break this request.
What the API does differently from the scripts above, from the content input reference and the Markdown rendering guide:
| Feature | Your own script | API markdown input |
|---|---|---|
| Looks | Any CSS you write | light, github or dark |
| Syntax highlighting | Yes, with highlight.js | No, monospaced only |
| Raw HTML in the Markdown | Passed through unless you sanitize | Always stripped |
| Footnotes | Not in marked's core (needs an extension) | Yes |
| Task-list checkboxes | Yes, if the sanitizer allows input |
Not in the documented feature list; test first |
| Images | Absolute URLs | Absolute URLs |
| Chrome, fonts, updates | Yours | Ours |
| Output | PNG, JPEG, WebP | PNG, JPEG, WebP, PDF |
The trade is plain. The script gives you full control and costs you a browser to run. The API gives you three themes and nothing to run, and it is the safer choice for Markdown that users wrote, because raw HTML never reaches the browser. If you need both your brand and no browser, convert the Markdown with marked as above and send the HTML to the API's html field instead; the Puppeteer HTML to image tutorial and the Python version show the template side.
We did not time the API route for this article, so the table above has no latency row for it. API time depends on region, cache state and queue, and a fair comparison needs a method box of its own.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Image is cut off at the bottom | Screenshot of the viewport only | fullPage: true (Node), full_page=True (Python), capture.mode: "full_page" (API) |
~~text~~ shows tildes |
Python-Markdown has no strikethrough | Add the pymdownx.tilde extension |
| Task-list boxes disappear | Sanitizer strips <input> |
Allow input with type, checked, disabled |
| A long command is cut at the right edge | pre does not wrap |
white-space: pre-wrap; overflow-wrap: anywhere |
| Images in the Markdown are blank | Relative paths; setContent has no base URL |
Use absolute https:// URLs, or a <base> element |
| Empty boxes instead of emoji or CJK | Fonts missing on the server | Install Noto Color Emoji and Noto CJK; list them in font-family |
| A web font shows as the fallback | Capture before the font decoded | Wait for document.fonts.ready; use font-display: block |
| Text is blurry on phones | Rendered at 1x | deviceScaleFactor: 2 / viewport.scale: 2 |
| Each image takes about a second | Chrome launched per image | Launch once, open a new page per image |
invalid_parameter from the API |
Theme not light, github or dark, or two inputs in one body |
Fix the theme name; send only markdown |
Literal \n in the API image |
Newlines escaped twice | Build the body with a JSON encoder (jq, JSON.stringify, json.dumps) |
Which route to pick
- You post release notes, digests or bot answers to chat, and the three themes look fine: use the API. There is no browser to patch, and user-written text is safe by default. Start with the Markdown to image API page.
- You need your fonts, colors or highlighted code, and you already run Node or Python with Chrome: use the scripts above. Keep one browser alive and sanitize every input.
- You need your design but do not want to run Chrome: use marked to make the HTML yourself and send it as
htmlto the API.
The Node and Python scripts ran with the versions pinned above; the Dockerfile and the API curl command did not run for this article. To try the API route on your own notes, create a free account and run the curl command with your file.
Related in this topic
- Markdown to Image API: Render Markdown as PNG Send Markdown, get a PNG, JPEG, WebP or PDF back. Three themes (light, github, dark), raw HTML stripped, full-page sizing and a public CDN link per image.
- Convert a Website to Markdown: Tested on 20 Pages Turn any URL into clean Markdown for LLMs with Readability and Turndown in Node, or Python. Scored on 20 real pages, with four fixes that raised the score.