Skip to content

LolipopJ/mdimg

Repository files navigation

lts-version download-counts language

mdimg

A tool that can be used to convert Markdown or HTML format text to an image.

How does it work?

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.

Preview

Rendering results:

Linux MacOS Windows HTML Template CSS Template Notes
default preview default preview default preview default default
empty preview empty preview empty preview default empty Only use base stylesheets
github preview github preview github preview default github
github dark preview github dark preview github dark preview default githubDark Should be used with theme: "dark"
words preview words preview words preview words words It is recommended to use with plain text only

Requirements

This tool requires a LTS Node version (v20.0.0+).

Installation

CLI:

npm install -g mdimg

In Node.js project:

npm install mdimg

Usage

CLI

Example:

mdimg -i path/to/input.md -o path/to/output.png -w 600 --css github

mdimg 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.

In Node.js project

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",
  theme: "light",
  // or with dark theme
  // cssTemplate: "githubDark",
  // theme: "dark",
});

console.log(
  `Convert to image successfully!\nImage has been saved as \`${convertRes.path}\``,
);

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) or inputText (directly input text).

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
outputFilename String See notes Output file path. For the default image processor: auto-generates ./mdimg_output/mdimg_${new Date()}.${type} when omitted. For a custom outputProcessor: written to disk only when explicitly provided (no default is generated)
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
cssText String undefined CSS rendering text
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, the mdimg will try to read local file. This option has no effect if cssText is specified
theme light | dark light Rendering color theme, affects the default highlight.js theme and other dark/light-aware 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

Custom template

😍 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 custom

Or 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.

HTML Template

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".

CSS Template

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 rollup:sass

CSS templates with the corresponding name will be generated in template/css directory.

Local Template

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:

  1. Using local template file. Pass a local filepath with the file extension .html and .css through options --html and --css with CLI (htmlTemplate and cssTemplate with Node.js).
  2. Using template text. Pass template text through --htmlText and --cssText with CLI (htmlText and cssText with Node.js).

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: `@import "https://unpkg.com/normalize.css/normalize.css";
.markdown-body {
  padding: 6rem 4rem;
}`,
});

Extensions

Extensions are default enabled. You can easily configuration them in Node.js:

await mdimg({
  extensions: false, // disable all extensions
});

await mdimg({
  extensions: {
    highlightJs: false, // disable highlight.js
    mathJax: {
      // further configuration for MathJax
      // ...
    },
    mermaid: true, // enable mermaid (by default)
  },
});

In CLI, you can only enable or disable extensions globally:

mdimg --extensions false # disable all extensions

Extended Syntaxes

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.

⚠️ The single dollar sign $ 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: [
          ["$", "$"],
          ["\\(", "\\)"],
        ],
      },
    },
  },
});

CLI doesn't support to configuration extensions, so you need to override MathJax options in HTML template directly:

<!-- path/to/template.html -->
<div id="mdimg-body">
  <div class="markdown-body"></div>
</div>

<script>
  MathJax = {
    tex: {
      inlineMath: [
        ["$", "$"],
        ["\\(", "\\)"],
      ],
    },
  };
</script>
mdimg --html path/to/template.html

⚠️ Due to the parse behaviors between marked and MathJax: "\" before any ASCII punctuation character is backslash escape, so "\\" (or "\,") should be written as "\\\\" (or "\\,"). You need to manually replace characters or wrap the LaTeX code in a <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
```

Other Extensions

Highlight.js is a syntax highlighter.

By default the theme is chosen automatically based on the global theme option (atom-one-light for light, atom-one-dark for dark). You can override it independently by setting extensions.highlightJs.theme to any of the bundled theme names:

await mdimg({
  theme: "light",           // global page theme
  extensions: {
    highlightJs: {
      theme: "github-dark", // highlight.js theme, independent of global theme
    },
  },
});

Tip: All bundled theme names are exported as the IHighlightJsTheme union type — your IDE will autocomplete the full list, including base16/ variants.

Plugins

The plugin system lets you hook into every stage of the conversion pipeline and register custom extensions — all without modifying mdimg's source code.

Lifecycle Hooks

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],
});

Custom Extensions

A plugin can also contribute one or more extensions — each extension injects HTML fragments into the <head> or <body> of the rendered page.

import type { IPlugin, IExtension } from "mdimg";

const fontExtension: IExtension = {
  name: "googleFont",
  inject({ theme }) {
    const family = theme === "dark" ? "JetBrains+Mono" : "Inter";
    return {
      head: `<link rel="stylesheet" href="/proxy/aHR0cHM6Ly9mb250cy5nb29nbGVhcGlzLmNvbS9jc3MyP2ZhbWlseT0lM0NzcGFuJTIwY2xhc3M9"pl-s1">${family}&display=swap">`,
    };
  },
};

const fontPlugin: IPlugin = {
  name: "fontPlugin",
  extensions: [fontExtension],
};

await mdimg({
  inputText: "# Hello",
  encoding: "base64",
  plugins: [fontPlugin],
});

Custom Markdown Syntax

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).

Example: custom inline syntax

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],
});

Example: custom renderer override

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="/proxy/aHR0cHM6Ly9naXRodWIuY29tL0xvbGlwb3BKLyUzQ3NwYW4lMjBjbGFzcz0"pl-s1">${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],
});

Overriding Built-in Extensions

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 type { IPlugin } from "mdimg";

// Replace built-in highlight.js with a custom CDN version
const customHljsPlugin: IPlugin = {
  name: "customHighlightJs",
  extensions: [
    {
      name: "highlightJs", // same name → replaces the built-in
      inject({ theme }) {
        const cssHref =
          theme === "dark"
            ? "https://cdn.example.com/hljs/atom-one-dark.min.css"
            : "https://cdn.example.com/hljs/atom-one-light.min.css";
        return {
          head: `<link rel="stylesheet" href="/proxy/aHR0cHM6Ly9naXRodWIuY29tL0xvbGlwb3BKLyUzQ3NwYW4lMjBjbGFzcz0"pl-s1">${cssHref}">`,
          body: `<script src="/proxy/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vaGxqcy9oaWdobGlnaHQubWluLmpz"></script>
<script>hljs.highlightAll();</script>`,
        };
      },
    },
  ],
};

await mdimg({
  inputText: "# Hello",
  encoding: "base64",
  plugins: [customHljsPlugin],
});

Suppressing Extensions by Name

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.

Output Processors

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?) PDF Yes Uses Puppeteer's page.pdf()
createHtmlOutputProcessor() HTML No Returns the rendered HTML string; Puppeteer is never launched

Export to HTML

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(),
});

Export to PDF

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).

Disk-write semantics

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.

Custom processor

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 }.

Development

git clone https://github.com/LolipopJ/mdimg.git
cd mdimg
pnpm install
npx puppeteer browsers install chrome

Lint

# Check lint rules
pnpm run lint
# Check lint rules and fix resolvable errors
pnpm run lint:fix

Build

# Build .js, .scss and .sass files
pnpm run build
# Generate preview images in `docs` directory
pnpm run preview

Test

# Build productions before testing
pnpm run build
# Run test cases
pnpm run test

Inspired by

  • md2img. Provided me the idea and a complete feasible solution.