· Tutorials · By Simon Chiu

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:

  1. 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 a javascript: link. Without sanitize-html all three reached the page. With it, the script and the handler were gone and the link became plain text.
  2. input is 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. Allowing type, checked and disabled keeps the boxes.
  3. 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-wrap with overflow-wrap: anywhere wraps it inside the box.
  4. body has a fixed width and fullPage: true. The width you pick is the width of the image; the height follows the content. Without fullPage, a long changelog is cut at the 600 px viewport height.
  5. The script waits for document.fonts.ready. The load event 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 html to 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