A tool that can be used to convert Markdown or HTML format text to an image.
First, the script calls marked to parse Markdown into a HTML document. Next, use Puppeteer to start a headless browser and render the document with HTML and CSS templates. Finally, export our image through Puppeteer's screenshot API.
Note
Rendering results may differ from the preview images below depending on the actual host environment.
| MacOS | Windows | Linux | HTML Template | CSS Template |
|---|---|---|---|---|
![]() |
![]() |
![]() |
default |
default |
![]() |
![]() |
![]() |
default |
github |
![]() |
![]() |
![]() |
default |
githubDark |
Build-in CSS Templates:
blueprint |
botanical |
midnight |
paper |
pop |
swiss |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Brand-inspired CSS Templates:
apple |
binance |
bmw-m |
claude |
nike |
nvidia |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
playstation |
replicate |
sentry |
slack |
spacex |
vercel |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
More previews can be found in docs/darwin.
This tool requires a LTS Node version (v22.12.0+).
CLI:
npm install -g mdimgIn Node.js project:
npm install mdimgExample:
mdimg -i path/to/input.md -o path/to/output.png -w 600 --css githubmdimg will read text from path/to/input.md and convert it to an image file path/to/output.png.
When using the command, you must specify either -i (input file, recommended) or -t (directly input text).
When using -t to input Markdown text directly, escape characters will not be available. To fix this, for example, you should replace \n with <br>.
You can always call mdimg -h to get complete help.
ES modules and CommonJS are supported, Import mdimg to your project:
import { mdimg } from "mdimg";Convert markdown file to an image:
const convertRes = await mdimg({
inputFilename: "path/to/input.md",
outputFilename: "path/to/output.png",
width: 600,
cssTemplate: "github",
// with dark theme
// cssTemplate: "githubDark",
// with appended styles
// cssText: ".markdown-body { font-size: 24px; line-height: 1.6; color: #333; }",
});
console.log(
`Convert to image successfully!\nImage has been saved as \`${convertRes.path}\``,
);cssText accept strings, value is appended after template and extension styles. Normal CSS specificity and !important rules still apply. Empty CSS text keeps the template styles; use cssTemplate: "empty" to start without preset styles.
Get image data in-memory without saving to disk:
// encoding: "blob" returns a Uint8Array in memory and does NOT auto-save to disk.
// Use this when you want to handle the image bytes yourself.
const convertRes = await mdimg({
inputText: "# Hello world",
encoding: "blob",
});
// import { writeFileSync } from "fs";
// writeFileSync("path/to/output.png", convertRes.data);When using mdimg() method, you must specify either inputFilename (input file, alias input) or inputText (directly input text).
Library loading, typesetting, diagram rendering, fonts and images share a readiness promise. Image/PDF output waits for it; exported HTML runs the same initialization and reports failures in the browser console, including a 30-second timeout.
Here are all available options:
| Argument | Type | Default | Notes |
|---|---|---|---|
| inputText | String |
undefined |
Input Markdown or HTML text directly. This option has no effect if inputFilename is specified |
| inputFilename | String |
undefined |
Read Markdown or HTML text from a file |
| input | String |
undefined |
Alias for inputFilename; inputFilename takes priority |
| outputFilename | String |
See notes | Output file path. For the default image processor: auto-generates ./mdimg_output/mdimg_<timestamp>_<UUID>.${type} when omitted. For a custom outputProcessor: written to disk only when explicitly provided (no default is generated) |
| output | String |
See notes | Alias for outputFilename; outputFilename takes priority |
| type | "jpeg" | "png" | "webp" |
png |
File type of the image. Type will be inferred from outputFilename if specified. Ignored when outputProcessor is set |
| width | Number |
800 |
Width in pixel of output image |
| height | Number |
100 |
Min-height in pixel of output image. No less than 100 |
| encoding | "base64" | "binary" | "blob" |
binary |
Encode type of output image. Ignored when outputProcessor is set |
| quality | Number |
100 |
Quality of the image, between 0-100. Not applicable to png image. Ignored when outputProcessor is set |
| htmlText | String |
undefined |
HTML rendering text |
| html | String |
undefined |
Alias for htmlText; htmlText takes priority |
| cssText | String |
undefined |
CSS text appended last, after template and extension styles |
| css | String |
undefined |
Alias for cssText; cssText takes priority |
| htmlTemplate | String |
default |
HTML rendering template. Available presets can be found in template/html. If ends with .html, the mdimg will try to read local file. This option has no effect if htmlText is specified |
| cssTemplate | String |
default |
CSS rendering template. Available presets can be found in template/css. If ends with .css, mdimg will try to read a local file. cssText are appended to these styles |
| extensions | boolean | IExtensionOptions |
true |
Configurations for extensions |
| plugins | IPlugin[] |
[] |
List of plugins to apply during the conversion pipeline |
| outputProcessor | IOutputProcessor |
image processor | Custom output processor. Overrides the built-in screenshot logic. See Output Processors |
| log | Boolean |
false |
Print execution logs via stderr |
| debug | Boolean |
false |
Whether to keep temporary HTML file after rendering |
| puppeteerProps | LaunchOptions |
undefined |
Launch options of Puppeteer. Ignored when outputProcessor sets requiresPage: false |
Returns: Promise<object>
| Key | Value Type | Notes |
|---|---|---|
| data | string | Uint8Array |
Image bytes as Uint8Array (encoding: "binary" or "blob") or BASE64 string (encoding: "base64"). When outputProcessor is set: Uint8Array for binary formats (image, PDF) or string for text formats (HTML, SVG) |
| path | string | undefined |
Path of output file. For image output: set when encoding is "binary". For custom outputProcessor: set when outputFilename is explicitly provided; undefined otherwise |
| html | string |
Rendered HTML document |
π Contribute to template presets via pull requests is welcomed!
Template presets are stored in the template directory.
If you execute the following command:
mdimg --html custom --css customOr in Node.js project:
await mdimg({
htmlTemplate: "custom",
cssTemplate: "custom",
});The mdimg will read template/html/custom.html as HTML template and template/css/custom.css as CSS template in the mdimg directory to render the image.
Create a new .html file in template/html directory.
There is only one rule you need to follow: an element with id mdimg-body wrapping an element with class markdown-body.
The simplest example:
<div id="mdimg-body">
<div class="markdown-body"></div>
</div>The mdimg will put the parsed HTML content in the element which class="markdown-body" (elements inside will be replaced), and finally generate the image for the whole element which id="mdimg-body".
Nothing to note, create a new .css file in template/css directory and then make your style!
For further development, it is recommended that write .scss or .sass files in the template/scss directory, and use the following command to generate CSS templates:
# Build .scss and .sass files
pnpm run build:sassCSS templates with the corresponding name will be generated in template/css directory.
Template presets may not often meet your needs. If you already know the specifications of HTML template and CSS template, you can pass the template directly. There are two methods:
- Using local template file. Pass a local filepath with the file extension
.htmland.cssthrough options--htmland--csswith CLI (htmlTemplateandcssTemplatewith Node.js). - Using template text. Pass HTML template text through
--htmlTextwith CLI (htmlTextwith Node.js). Append CSS overrides through--cssText(cssTextwith Node.js); the selected CSS template is still included.
CLI:
# use local file
mdimg --html path/to/custom.html --css path/to/custom.css
# use text directly
mdimg --htmlText '<div id="mdimg-body"><div class="markdown-body"></div></div>' --cssText '@import "https://unpkg.com/normalize.css/normalize.css"; .markdown-body { padding: 6rem 4rem; }'Or in Node.js project:
// use local file
await mdimg({
htmlTemplate: "path/to/custom.html",
cssTemplate: "path/to/custom.css",
});
// use text directly
await mdimg({
htmlText: `<div id="mdimg-body">
<div class="markdown-body"></div>
</div>`,
cssText: `.markdown-body {
padding: 6rem 4rem;
}`,
});The mdimg has build-in extensions, and they are default enabled. You can easily configuration them in Node.js:
await mdimg({
extensions: false, // disable all extensions, use marked to parse document only
});
await mdimg({
extensions: {
highlightJs: false, // disable certain extension, e.g. highlight.js
mathJax: {
// further configuration for MathJax
// ...
},
mermaid: true, // as all extensions are default enabled, nothing will change
},
});In CLI, you can also configuration extensions by passing a JSON object:
mdimg --extensions false
mdimg --extensions '{"highlightJs": false}'Some extended syntaxes, such as LaTeX, can't be parsed by pure marked correctly. To solve this problem, the mdimg introduces some third-party libraries to enhance rendering capabilities. Below are introduced libraries:
MathJax is an open-source JavaScript display engine for LaTeX, MathML, and AsciiMath notation.
MathJax uses SVG with formula-local glyph references and the bundled NewCM
font. CHTML settings, custom font/loading paths, unpackaged fonts and the
optional speech engine are rejected. Mermaid uses native MathML by default;
legacyMathML and forceLegacyMathML use embedded KaTeX CSS/fonts. Unknown
layouts, external icon packs and remote diagram images/styles fail explicitly.
$ is not enabled by default to render inline LaTeX. Because It is used too frequently in normal text, so if you want to use it for math delimiters, you must specify it explicitly. In Node.js project:
await mdimg({
extensions: {
mathJax: {
tex: {
inlineMath: [
["$", "$"],
["\\(", "\\)"],
],
},
},
},
});Pass MathJax configuration through extensions.mathJax. The CLI accepts the
same extension options as JSON:
mdimg -i input.md --extensions '{"mathJax":{"tex":{"inlineMath":[["$","$"]]}}}'<div> block. Example:
<div>$$
A_{m,n} =
\begin{pmatrix}
a_{1,1} & a_{1,2} & \cdots & a_{1,n} \\
a_{2,1} & a_{2,2} & \cdots & a_{2,n} \\
\vdots & \vdots & \ddots & \vdots \\
a_{m,1} & a_{m,2} & \cdots & a_{m,n}
\end{pmatrix}
$$</div>Mermaid is a JavaScript-based diagramming and charting tool that uses Markdown-inspired text definitions and a renderer to create and modify complex diagrams.
Sequence diagram example:
```mermaid
sequenceDiagram
Alice->>Bob: Hello Bob, how are you ?
Bob->>Alice: Fine, thank you. And you?
create participant Carl
Alice->>Carl: Hi Carl!
create actor D as Donald
Carl->>D: Hi!
destroy Carl
Alice-xCarl: We are too many
destroy Bob
Bob->>Alice: I agree
```Highlight.js is a syntax highlighter.
By default, highlight.js and Mermaid themes follow the CSS template. Dark templates (binance, bmw-m, claude, githubDark, midnight, playstation, replicate, sentry, spacex, vercel, and x.ai) use atom-one-dark and Mermaid's dark theme. Other templates use atom-one-light and Mermaid's default theme. Template filenames use the same mapping.
You can override either theme independently with extensions.highlightJs.theme or extensions.mermaid.theme. Highlight.js accepts any of the bundled theme names:
await mdimg({
cssTemplate: "github",
extensions: {
highlightJs: {
theme: "github-dark", // overrides the CSS template's default
},
},
});Tip: All bundled theme names are exported as the
IHighlightJsThemeunion type β your IDE will autocomplete the full list, includingbase16/variants.
The plugin system lets you hook into every stage of the conversion pipeline and register custom extensions β all without modifying mdimg's source code.
A plugin can define any combination of the four lifecycle hooks:
| Hook | When it runs | Signature |
|---|---|---|
beforeParse |
Before Markdown is parsed | (text: string) => string | Promise<string> |
afterParse |
After Markdown β HTML fragment, before template splicing | (html: string) => string | Promise<string> |
afterSplice |
After the full HTML document is assembled | (html: string) => string | Promise<string> |
afterRender |
After Puppeteer renders, before the image is written to disk | (result: IConvertResponse) => IConvertResponse | Promise<IConvertResponse> |
Multiple plugins run in registration order.
import { mdimg } from "mdimg";
import type { IPlugin } from "mdimg";
const myPlugin: IPlugin = {
name: "myPlugin",
hooks: {
// Transform raw Markdown before parsing
beforeParse: (text) => text.replace(/\[\[(\w+)\]\]/g, "**$1**"),
// Modify the rendered HTML fragment
afterParse: (html) => `<div class="wrapper">${html}</div>`,
// Post-process the final HTML document
afterSplice: (html) => html.replace("</title>", " β my brand</title>"),
// Transform or inspect the output image bytes before they are saved
afterRender: async (result) => {
console.log(`Image size: ${result.data.length} bytes`);
return result;
},
},
};
await mdimg({
inputText: "# Hello [[World]]",
outputFilename: "output.png",
plugins: [myPlugin],
});A plugin can also contribute one or more extensions β each extension injects HTML fragments into the <head> or <body> of the rendered page. The injection context provides the selected cssTemplate name or filename.
import type { IPlugin, IExtension } from "mdimg";
const fontExtension: IExtension = {
name: "googleFont",
inject({ cssTemplate }) {
const family = cssTemplate === "githubDark" ? "JetBrains+Mono" : "Inter";
return {
head: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${family}&display=swap">`,
};
},
};
const fontPlugin: IPlugin = {
name: "fontPlugin",
extensions: [fontExtension],
};
await mdimg({
inputText: "# Hello",
encoding: "base64",
plugins: [fontPlugin],
});A plugin can expose the full marked extensions API through the markedExtensions field. This lets you add custom block/inline syntax, override renderers, or attach walkTokens logic β all scoped to a single mdimg call with no global side-effects.
Each item in markedExtensions is passed to marked.use() in registration order on a fresh Marked instance. Plugin-provided extensions are applied after mdimg's built-in code renderer, so they take precedence (custom code renderers will override the default mermaid handling).
The following plugin renders :wave: as an emoji span:
import type { IPlugin } from "mdimg";
import type { MarkedExtension } from "marked";
const emojiExtension: MarkedExtension = {
extensions: [
{
name: "emoji",
level: "inline",
start(src) {
return src.indexOf(":");
},
tokenizer(src) {
const match = src.match(/^:([a-z_]+):/);
if (match) {
return { type: "emoji", raw: match[0], name: match[1] };
}
},
renderer(token) {
const map: Record<string, string> = {
wave: "π",
fire: "π₯",
star: "β",
};
return `<span class="emoji">${map[token.name] ?? token.raw}</span>`;
},
},
],
};
const emojiPlugin: IPlugin = {
name: "emojiPlugin",
markedExtensions: [emojiExtension],
};
await mdimg({
inputText: "# Hello :wave:",
encoding: "base64",
plugins: [emojiPlugin],
});Override the default link renderer to open all links in a new tab:
import type { IPlugin } from "mdimg";
import type { MarkedExtension } from "marked";
const externalLinksExtension: MarkedExtension = {
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : "";
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
},
};
const externalLinksPlugin: IPlugin = {
name: "externalLinksPlugin",
markedExtensions: [externalLinksExtension],
};
await mdimg({
inputText: "[GitHub](https://github.com)",
encoding: "base64",
plugins: [externalLinksPlugin],
});If a plugin extension has the same name as a built-in extension (highlightJs, mathJax, or mermaid), it replaces the built-in. This lets you fully control how a dependency is configured or injected.
import { readFileSync } from "node:fs";
import type { IPlugin } from "mdimg";
// Supply a self-contained local bundle and theme.
const customScript = readFileSync("./custom-highlight.js", "utf8").replace(
/<\/script/gi,
"<\\/script",
);
const customHljsPlugin: IPlugin = {
name: "customHighlightJs",
extensions: [
{
name: "highlightJs", // same name β replaces the built-in
inject({ cssTemplate }) {
const cssFile =
cssTemplate === "githubDark"
? "./custom-dark.css"
: "./custom-light.css";
return {
head: `<style>${readFileSync(cssFile, "utf8")}</style>`,
body: `<script>${customScript}</script>
<script>hljs.highlightAll();</script>`,
};
},
},
],
};
await mdimg({
inputText: "# Hello",
encoding: "base64",
plugins: [customHljsPlugin],
});Any extension β built-in or plugin-contributed β can be suppressed via the extensions option using its name as the key. Suppression is complete: it disables both HTML injection and the markedExtensions of the plugin that owns the extension, so no half-enabled state (parsing active, resources absent) is possible.
await mdimg({
inputText: "# Hello",
encoding: "base64",
// Disable mermaid and a plugin-contributed extension named "googleFont"
extensions: { mermaid: false, googleFont: false },
plugins: [fontPlugin],
});You can also suppress by plugin name to disable every IExtension the plugin registered together with its markedExtensions in one key β useful when the plugin name differs from the individual extension names:
await mdimg({
inputText: "# Hello",
encoding: "base64",
extensions: { myPlugin: false }, // disables all of myPlugin's IExtensions + markedExtensions
plugins: [myPlugin],
});extensions: false still disables every extension globally.
By default mdimg captures the rendered #mdimg-body element as a PNG / JPEG / WebP screenshot via Puppeteer. You can replace this with any output processor by passing outputProcessor β a custom object that transforms the rendered HTML (and optionally a live Puppeteer page) into whatever format you need.
Three built-in processors are exported directly from mdimg:
| Factory | Format | Requires browser | Description |
|---|---|---|---|
createImageOutputProcessor(type, quality, encoding) |
PNG / JPEG / WebP | Yes | Default when outputProcessor is not set. Typically not called directly β use type, quality, encoding options |
createPdfOutputProcessor(pdfOptions?) |
Yes | Uses Puppeteer's page.pdf() |
|
createHtmlOutputProcessor() |
HTML | No | Returns the rendered HTML string; Puppeteer is never launched |
createHtmlOutputProcessor sets requiresPage: false, so Puppeteer is never launched β making it the fastest option when you only need the rendered HTML document.
import { mdimg, createHtmlOutputProcessor } from "mdimg";
// In-memory only (no outputFilename)
const { data } = await mdimg({
inputText: "# Hello",
outputProcessor: createHtmlOutputProcessor(),
});
// data is the full HTML string
// Write to disk
await mdimg({
inputText: "# Hello",
outputFilename: "output.html",
outputProcessor: createHtmlOutputProcessor(),
});import { mdimg, createPdfOutputProcessor } from "mdimg";
await mdimg({
inputFilename: "path/to/input.md",
outputFilename: "output.pdf",
outputProcessor: createPdfOutputProcessor({
printBackground: true,
format: "A4",
}),
});pdfOptions accepts any Puppeteer PDFOptions except path (managed by mdimg).
Unlike the default image processor (which auto-generates a path when outputFilename is omitted), custom processors write to disk only when outputFilename is explicitly provided. When omitted, the result is returned in result.data in-memory only.
Implement the IOutputProcessor interface to support any format:
import { mdimg } from "mdimg";
import type { IOutputProcessor } from "mdimg";
const svgProcessor: IOutputProcessor = {
format: "svg",
requiresPage: true,
async process({ page }) {
const svg = await page!.evaluate(
() => document.querySelector("svg")?.outerHTML ?? "",
);
return { data: svg };
},
};
await mdimg({
inputText: "# Hello",
outputFilename: "chart.svg",
outputProcessor: svgProcessor,
});Setting requiresPage: false skips browser launch entirely; the process context then receives only { html, outputPath }.
git clone https://github.com/LolipopJ/mdimg.git
cd mdimg
pnpm install
npx puppeteer browsers install chromeFor TypeScript and other support files:
# Check lint rules
pnpm run lint
# Check lint rules and fix resolvable errors
pnpm run lint:fixFor Stylesheets:
pnpm run stylelint
pnpm run stylelint:fix# Build .js, .scss and .sass files
pnpm run build
# Generate preview images in `docs/<platform>` with a standalone script
pnpm run previewThe preview script discovers styles from template/css/*.css and defaults to
three concurrent conversions. Style names are filenames without .css.
--options accepts a JSON object of mdimg rendering options and overrides the
common defaults and style-specific presets. The selected style, output filename,
and binary encoding are managed by the script. For custom output directories,
use --output <directory> (or -o). You can also import generatePreviews from
the script and call it with { styles, concurrency, outputDir, debug, options }.
# Generate only selected styles, with at most two conversions at once
pnpm run preview airbnb apple blueprint --concurrency 2
# Override rendering options and the output directory, keep the temporary HTML file
pnpm run preview airbnb --output ./preview-output --debug --options '{"width":1200,"extensions":false}'# Run test cases after building
pnpm run test
# Run consumer check before publishing
pnpm run test:consumerConsumer tests run separately from the default suite. They build and install the
npm tarball in an isolated production-only project, then check its API, CLI,
public types, and offline rendering with the current Node.js and Node.js 22.12.0.
You can set MDIMG_NODE22 to a preinstalled Node.js 22.12.0 executable when needed.
Tests use a fresh temporary directory for each case and clean it up afterward. They decode images to check their format, dimensions and rendered pixels, parse PDF text and page sizes, and check converted HTML content.
- md2img. Provided me the idea and a complete feasible solution.


























