DEV Community

Cover image for How to Convert Markdown to an Image (Without Screenshotting Your Editor)
Accreditly
Accreditly

Posted on • Originally published at html2img.com

How to Convert Markdown to an Image (Without Screenshotting Your Editor)

You write release notes, changelogs and comparison tables in Markdown, and then you need to post them somewhere that refuses to render it. Slack collapses your table into a line of pipes. X and LinkedIn strip every heading and bullet to flat text. Email clients show your readers literal asterisks.

So you open the preview pane and take a screenshot. It works, in the way that holding a door shut works instead of fixing the lock. We've just published a fuller guide on converting Markdown to an image over on HTML to Image, and this post covers the working version for JavaScript developers.

What's wrong with a screenshot

A screenshot carries everything you didn't want along with the content. Your editor theme comes with it, so a dark-mode preview lands in a light-mode inbox looking like a ransom note. The resolution is whatever your display happens to be, so text that was crisp on your laptop turns soft on a retina screen. The crop is hand-drawn, so there's a sliver of scrollbar down one edge.

And it's manual. If the notes change, you screenshot again. Fine once; wrong as a workflow.

The better approach: convert the Markdown to HTML, wrap it in a clean stylesheet, and render it in a real browser at a fixed width. The formatting survives because it's no longer formatting. It's pixels.

Prerequisites

  • Node 18 or newer (for built-in fetch)
  • The marked package: npm install marked
  • An API key for a rendering service (HTML to Image has a free tier; any HTML-to-PNG renderer with a full-page option works the same way)

Step 1: Parse the Markdown

marked handles GitHub-flavoured Markdown out of the box, so tables, strikethrough and fenced code blocks all convert properly:

import { readFile } from "node:fs/promises";
import { marked } from "marked";

const markdown = await readFile("release-notes.md", "utf8");
const fragment = marked.parse(markdown, { gfm: true });
Enter fullscreen mode Exit fullscreen mode

What you get back is a bare HTML fragment with no opinion about its own appearance. That's the next step's job.

Step 2: Wrap it in a document stylesheet

The parser gives you semantics; this gives you the look. It's deliberately close to how GitHub renders Markdown, because that's what people expect a rendered document to look like:

const stylesheet = `
  body { margin: 0; padding: 48px 56px;
    font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 17px; line-height: 1.6; color: #1f2328; }
  h2 { font-size: 30px; margin: 0 0 16px; padding-bottom: 10px;
    border-bottom: 1px solid #d1d9e0; }
  table { border-collapse: collapse; width: 100%; margin: 0 0 18px; }
  th, td { border: 1px solid #d1d9e0; padding: 9px 14px; text-align: left; }
  th { background: #f6f8fa; }
  blockquote { margin: 0 0 18px; padding: 4px 18px;
    border-left: 4px solid #d1d9e0; color: #59636e; }
  code { font-family: ui-monospace, Menlo, Consolas, monospace;
    font-size: 0.9em; background: #f0f1f3; padding: 2px 6px; border-radius: 5px; }
  pre { background: #f6f8fa; border: 1px solid #d1d9e0;
    border-radius: 8px; padding: 16px 18px; }
  pre code { background: none; padding: 0; font-size: 15px; }
`;

const html = `<!doctype html>
<html><head><meta charset="utf-8"><style>${stylesheet}</style></head>
<body>${fragment}</body></html>`;
Enter fullscreen mode Exit fullscreen mode

Swap the font stack for your brand face and tint the borders if you want it on-brand. The structure doesn't change.

Step 3: Render it

One POST. The two parameters that matter are width, which sets the line length (1,000px reads well for a document), and fullpage: true, which makes the capture height follow the content:

const response = await fetch("https://app.html2img.com/api/html", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": process.env.HTML2IMG_KEY,
  },
  body: JSON.stringify({ html, width: 1000, fullpage: true }),
});

const { url } = await response.json();
console.log(url);
Enter fullscreen mode Exit fullscreen mode

The full-page part is what separates this from screenshotting. A three-line note produces a small image and a forty-line changelog produces a tall one, from the same code, with nothing cropped at the bottom.

Here's a release note that started life as thirty lines of Markdown and came out of exactly this script:

A rendered release note as a PNG: heading, bold bullets, a table with inline code, a blockquote and a code block all intact

The bold lead-ins, the inline code, the table columns, the blockquote and the fenced block all survived. Post that image anywhere and it looks the same everywhere.

Where this pays off

Release notes as social posts are the obvious one: the structure survives on platforms that would flatten it to a paragraph. Tables in Slack are the daily one. Email is the sneaky one, because clients disagree about nearly every layout feature, and a PNG looks identical in Gmail, Outlook and Apple Mail since there's nothing left to interpret.

And because it's one HTTP call, it automates: run it in CI when a release tags, or in a cron that turns the week's merged PRs into a Friday summary card. The Markdown was already being written. Now it travels.

For the browser-based version of this (paste Markdown, get a PNG, no code), plus Python and PHP implementations and the full stylesheet, the complete guide is here.

Do you post rendered Markdown anywhere regularly, or are you still screenshotting the preview pane? Share your setup in the comments.

Top comments (0)