render() & Options Reference
render() is the primary function for converting HTML or raw images into the target format.
Signatures
// wasm-html-to-image/single
export function render(
options: HtmlToImageOptions,
): Promise<Uint8Array | string>;
// wasm-html-to-image
export function htmlToImage(
module: HtmlToImageModule,
options: HtmlToImageOptions,
): Promise<Uint8Array | string>;
:::note Return Type
Returns a string for svg format, and Uint8Array for all other formats (png, jpeg, webp, apng, gif, tiff, avif, jxl, pdf, raw, thumbhash).
:::
Options (HtmlToImageOptions)
Core Options
| Option | Type | Default | Description |
|---|---|---|---|
value | string | string[] | Uint8Array | ArrayBuffer | - | Input data (HTML string, HTML array for multi-page PDF, or image buffer) |
url | string | - | URL to fetch and render when value is omitted |
baseUrl | string | - | Base URL for resolving relative assets |
width | number | 800 | Viewport width (HTML) or target output width (image) |
height | number | auto | Viewport height (HTML) or target output height (image) |
format | OutputFormat | "png" | Target output format (png, jpeg, webp, apng, gif, tiff, avif, jxl, raw, thumbhash, svg, pdf) |
scale | number | 1 | Pixel density of raster output from HTML input (0-16). width stays in CSS px and the page is drawn scale times larger (scale: 3.125 turns an 800px layout into a 2500px image, with text and shapes re-rasterized rather than stretched). Ignored for svg / pdf and for image input. |
dpi | number | 96 × scale | Resolution written to tiff, in pixels per inch. A print shop reads the physical size from it. |
colorSpace | "srgb" | "cmyk" | "srgb" | Output color space of tiff and pdf. "cmyk" is for print: TIFF becomes a separated CMYK image with transparency flattened onto white, and PDF has every fill, stroke and image rewritten to DeviceCMYK. The conversion is the plain device formula (K = 1 − max(R,G,B), black stays K only) with no ICC profile, so saturated screen colors are not gamut-mapped. Ignored for other formats. |
Encoding & Resizing
| Option | Type | Default | Description |
|---|---|---|---|
quality | number | 85 | Compression quality 0–100 (JPEG, WebP, AVIF) |
speed | number | AVIF: 8 / other: 6 | Encoding speed 0–10 (AVIF, JXL, PNG and animated lossy WebP). PNG uses zlib level 12 - speed (the default 6 is level 6, output unchanged; 9 is level 3: about 20% faster on a large page, ~15% larger files). Animated WebP uses libwebp method 8 - speed: the default 6 is method 2, 5 is method 3 (~4% smaller), 9 is method 0 (fastest, ~30% larger). AVIF at 8 is several times faster than 6 for ~15–20% larger files; pass speed: 6 for the smallest output |
fit | "contain" | "cover" | "fill" | - | Resize fit strategy |
crop | { x: number, y: number, width: number, height: number } | - | Cropping bounding rectangle |
animation | boolean | { duration: number; fps?: number; loop?: number } | false | Preserve animation frames where supported. For HTML input, { duration, fps, loop } samples opacity, transform (translate / rotate / scale), and color / background-color @keyframes into an animated WebP / APNG / GIF (`format: "webp" |
Fonts & Styling
| Option | Type | Default | Description |
|---|---|---|---|
fontMap | Record<string, string> | DEFAULT_FONT_MAP | Map of generic family names to Google Fonts URLs |
fallbackFonts | (Uint8Array | ArrayBuffer | string)[] | [] | User-supplied fallback fonts |
fonts | { name: string, data: Uint8Array }[] | [] | Named fonts pre-loaded on the instance |
css | string | - | Additional custom CSS string injected before discovery |
mediaType | "screen" | "print" | "screen" | Target media type for CSS @media rules |
textToPaths | boolean | true | Outline text as SVG paths |
userAgent | string | Chrome's UA | User-Agent for resource fetches (Web font CSS, ...) |
Running scripts (javascript) and Mermaid (mermaid)
| Option | Type | Description |
|---|---|---|
javascript | boolean | { timeout?: number, settleMs?: number, waitFor?: string, engine?: "quickjs", limits?, skip? } | Run the page's <script>s in QuickJS (WebAssembly) on a DOM written in C before rendering (the Node.js single entry and the CLI's --js do it for you; on edge entries, call applyJavaScript() first). true waits 1 s after load. timeout bounds the whole phase (default 10000 ms; 0 / Infinity disables the limit), settleMs is the wait after load (default 1000 ms), waitFor is a CSS selector to wait for (replaces settleMs). engine accepts only "quickjs" (kept for one release; "jsdom" throws at runtime) — there is no other engine. limits / skip bound what the page may fetch. There is no layout and no sandbox: use it with trusted pages only |
mermaid | { source, theme?, padding?, background?, accent?, mode?, directives?, onWarning?, maxLines?, maxSourceBytes? } | Draw a Mermaid diagram (instead of value), as an image sized to the diagram. See Mermaid conversion |
Running scripts on edge runtimes (Cloudflare Workers and others)
The QuickJS (WebAssembly) engine runs on every runtime. Where eval is not allowed, such as Workers, applyJavaScript() from wasm-html-to-image/javascript runs the page's scripts in QuickJS on a DOM written in C. A React / Next.js client has been checked to run through hydration and its useEffects.
import { applyJavaScript } from "wasm-html-to-image/javascript";
import { render } from "wasm-html-to-image/workerd";
const options = await applyJavaScript({ url: "https://example.com/", javascript: true, width: 1200, format: "png" });
const { data } = await render(options);
- The package picks the QuickJS wasm for the runtime (
#quickjs-wasmin theimportsofpackage.json): a pre-compiled module on Workers and on Vercel / Next.js Edge, read from disk on Node.js and Bun, fetched on Deno and in browsers. To use a module of your own, pass it asapplyJavaScript(options, { quickjsWasm }). The Node.jssingleentry and the CLI's--jsrun the same phase for you. - The engine itself is the separate package
wasm-quickjs-dom(a small DOM implementation that knows nothing about images), installed as a dependency ofwasm-html-to-image.createDOMcan also be used straight fromwasm-quickjs-dom. - The Node.js
singleentry'srunPageScriptsis now an alias ofwasm-quickjs-dom'srunPageScriptsQuickJS. Its third argumentviewportWidthdefaults toundefined(the options'widthis used, then 800) instead of 800, somatchMediacan answer differently than before. - Limits: no layout of its own (
getBoundingClientRectreturns 0 untilmeasureLayout()orenableLiveLayout()supplies boxes; see below), nocanvas,type="module"scripts run, but onlyimports with literal specifiers (fetched up front) resolve. The DOM is a small one written in C, and QuickJS is an interpreter, so heavy pages are slower. - More limits:
attachShadow()works, butserialize()and the rendered HTML leave shadow trees out (a warning is logged when a root is attached).document.fontsis a stub (readyresolves at once,check()is alwaystrue,load()resolves to an empty array) andFontFacedoes not exist. Interaction pseudo-classes (:hover,:focus,:active,:visited,:target,:focus-within,:focus-visible) and pseudo-elements never match, in selectors or ingetComputedStyle()(matchMedia("(hover: hover)")still reportstrue).document.elementFromPoint()returnsnullandelementsFromPoint()returns[];scroll(),scrollTo(),scrollBy(), andscrollIntoView()do nothing (scrollTop/scrollLeftread0). An<iframe>'scontentWindow/contentDocumentarenull, andsrcdocis kept as an attribute only, never loaded. XPath cannot resolve namespace prefixes (namespace-uri()is"",createNSResolver()returnsnull).crypto.getRandomValues()/crypto.randomUUID()are built onMath.random, so they are not cryptographically secure. CanvasgetContext()returnsnull(andtoDataURL()"data:,"), so calling a method on the context throws aTypeError. SVGgetBBox()/getTotalLength()/getPointAtLength()are approximate: a path'sAcommand is its chord,transforms are ignored, and<text>is0 × 0. - Use it with trusted pages only (the scripts can
fetch). For pages you do not control, always add the fetch limits below, orjavascript: { limits, skip }. IntersectionObserverandResizeObserverreport once when youobserve()(the page is meant to be seen whole, without scrolling, so everything counts as intersecting and lazy content gets to load).matchMediaanswers for the viewportwidth(min-width/max-width/orientation..., with a light-mode, no-motion user).getComputedStylecomputes the cascade of<style>and the element's ownstyle, inheritance and initial values, but does not lay out: awidth/heightofautoor%comes back as written (auto,50%, ...), so take real sizes from the boxesmeasureLayout()returns.
Keeping the DOM open (createDOM)
Without rendering anything, the page can be kept open and driven, like a browser page. The DOM lives inside QuickJS, so you talk to it through eval() and JSON values rather than a live dom.window.
import { createDOM } from "wasm-html-to-image/javascript";
import { render } from "wasm-html-to-image/workerd";
const dom = await createDOM({ url: "https://example.com/" }, { width: 1200 });
try {
const title = await dom.eval("document.title"); // the value comes back as JSON; promises are awaited
await dom.eval("document.querySelector('h1').textContent = 'Hello'");
const { data } = await render(dom.toOptions({ width: 1200, format: "png" })); // draw the DOM as it is now
} finally {
dom.close(); // frees QuickJS
}
eval(fn, ...args)also takes a function and JSON arguments (dom.eval((sel) => document.querySelectorAll(sel).length, "a")). The function travels as source text, so it cannot use variables from its surroundings.click(selector),type(selector, text)(setsvalue, firesinputandchange) andwaitForSelector(selector)drive the page and wait for it. They returnfalsewhen nothing matched.expose(name, fn)makes a host function callable from the page asawait window[name](...args). Arguments and result travel as JSON; a throw becomes a rejection with the same message.eval(code)runs code in the page's global scope and returns the result as JSON. A promise is awaited, and what the code throws becomes anError. A DOM node cannot be returned: return what you read from it. Calls run one after another.settle(ms?)lets the page's timers and fetches run. The page's clock runs only duringeval()/settle(), not in between.serialize()is the current HTML;toOptions(options)gives render options you can pass torender()as they are.- The second argument of
createDOMis thejavascriptoption (timeout/settleMs/waitFor) pluswidthandquickjsWasm.timeoutcounts per call. bridge()gives the host synchronous access to the page's DOM (each property read, write or method call is one call into QuickJS; the same node is always the same wrapper).
Moving from jsdom: the DOM lives inside QuickJS, so there is no window to hand around. Values cross the boundary as JSON, so dom.eval(fn, ...args) returns only what JSON can carry (no DOM nodes), and a function travels as source text, so it cannot close over outer variables. Give the page host functions with dom.expose(name, fn) (await window[name](...args)), and use dom.bridge() when the host wants to touch the DOM itself. There is no beforeParse hook: prepend a <script> to the HTML, or eval() your setup into the page after createDOM() and let settle() carry it into the page's pending work.
Command line: npx wasm-quickjs-dom <URL | file | -> prints the HTML after the scripts ran (-e prints a value, --json adds the logs, --wait / --settle / --timeout, and the limits --block-private / --allow-host / --skip ...). --help lists everything.
Speed: with the QuickJS engine settleMs is an upper bound. With no timer pending that falls due within it and no request in flight the page can no longer change, so it finishes without using up the time. External <script src>s are requested all at once and only run in document order. skip: (url) => boolean fails the URLs a render does not need (analytics, ads, ...) without going to the network (a <script> gets its error event, a fetch rejects). On real sites these three take most of the time off.
Fetch limits (limits): for pages you do not control, restrict where the page's scripts may fetch from. It covers the page's own fetch / XMLHttpRequest, the <script>s and modules it loads, and the page itself given as url.
const dom = await createDOM({ url }, {
limits: {
allowedProtocols: ["https:"],
allowedHosts: ["example.com", "cdn.example.com"], // exact match
blockPrivateHosts: true, // refuse localhost and private, loopback and link-local addresses
maxResourceCount: 50,
maxResourceBytes: 5_000_000,
maxTotalResourceBytes: 20_000_000,
},
});
- The names are those of the
limitsofrender(). WithapplyJavaScript()thelimitsof the same call are the defaults, andjavascript: { limits }overrides them. - With host or protocol limits, redirects are not followed automatically: each one is checked before it is followed. A runtime that hides the redirect target (a browser's
opaqueredirect) cannot be checked, so the request is refused. blockPrivateHostslooks at hostnames and IP literals only. A hostname that resolves to an internal address through DNS cannot be told apart here: pair it withallowedHostswhen the target is not trusted.
What happened in the page (logs): dom.logs keeps the console output, script errors, unhandled promise rejections (unhandledrejection) and the page's requests with their status or the reason they failed (the last 1000). The onLog option delivers them as they happen. Use them to find out why a page rendered empty.
const dom = await createDOM({ url }, { onLog: (e) => e.type === "error" && console.error(e.source, e.message) });
console.log(dom.logs.filter((e) => e.type === "request" && (e.error || (e.status ?? 0) >= 400)));
Giving the page a layout (measureLayout): there is no layout inside QuickJS, so getBoundingClientRect() returns 0. measureLayout() lays the page out with the image engine (nothing is drawn or encoded) and hands the numbers to the page. Afterwards getBoundingClientRect(), getClientRects(), offsetWidth / offsetHeight / offsetLeft / offsetTop, clientWidth / clientHeight and scrollWidth / scrollHeight answer with the measured values.
import { createDOM, measureLayout } from "wasm-html-to-image/javascript";
import { render } from "wasm-html-to-image/workerd";
const dom = await createDOM({ url: "https://example.com/" }, { width: 1200 });
await measureLayout(dom, render, { width: 1200 }); // the render of the entry you use
const h = await dom.eval("document.querySelector('main').getBoundingClientRect().height");
- The values are a snapshot: call
measureLayout()again after the page changed. - An inline element in a line of text (
<span>,<a>, ...) gets the one box that encloses all the line fragments it is broken into. The offsets are measured from the top left of the page, not from anoffsetParent. - The page itself is not touched: the marks for the layout are only on a copy.
Following the page's changes (enableLiveLayout): when the page's scripts change the DOM and read getBoundingClientRect() or getComputedStyle() as they go, use enableLiveLayout() instead of the measureLayout() snapshot: every read is answered from the image engine's layout of the page as it is at that moment. The engine keeps the laid-out document and applies only what the page changed (attributes, style, text, inserted / removed / moved nodes).
import { createDOM, enableLiveLayout } from "wasm-html-to-image/javascript";
import { getDefaultModule } from "wasm-html-to-image/workerd"; // Node.js: loadHtmlToImageModule()
const dom = await createDOM({ url: "https://example.com/" }, { width: 1200 });
const layout = await enableLiveLayout(dom, await getDefaultModule(), { width: 1200, maxElements: 10000 });
await dom.eval("document.querySelector('main').style.height = '400px'");
await dom.eval("document.querySelector('footer').getBoundingClientRect().y"); // already moved
await layout.refreshResources(); // fetch the images, style sheets and fonts a script added
layout.dispose();
dom.close();
- Reads are synchronous and cannot fetch. Style sheets, fonts and images are fetched once, from the page as it is when you call
enableLiveLayout(); what a script adds later is fetched byawait layout.refreshResources()(until then an added image lays out as empty). - Cost of one change plus one read (Node.js, 6,000 elements): a paint-only change (
color, ...) about 13 ms,appendabout 90 ms, a change that moves boxes (height) about 170 ms; it grows with the number of elements. Tables are slower: about 0.6 s per change at 1,500 rows. - On Cloudflare Workers (128 MB for both wasm modules) about 13,000 elements fit; keep
maxElementsat 10,000 or less. A page over it, or an engine failure, leaves the boxes at 0 with one warning and the page keeps running. It needs the compatibility flagenable_weak_ref, and the gzipped upload (about 5.5 MB) needs a paid plan. - Design and measurements:
doc/design/live-layout-incremental.md; benchmark:packages/html-to-image/scripts/bench-live-layout.mjs.
Rendering untrusted HTML (limits)
When rendering user-supplied HTML, always restrict where it may fetch from and how much. By default any host is fetched, and on Node local files under baseUrl are read.
limits key | Type | Description |
|---|---|---|
timeoutMs | number | Timeout for the whole render. Each individual fetch is aborted by the remaining time |
maxResourceCount | number | Maximum number of resources to load |
strictFonts | boolean | Throw LIMIT_RESOURCE_COUNT instead of rendering missing glyphs when fonts were skipped by maxResourceCount (default false) |
maxResourceBytes | number | Maximum bytes for a single resource |
maxTotalResourceBytes | number | Maximum total bytes for all resources |
allowedProtocols | string[] | Allowed URL protocols (e.g. ["https:"]) |
allowedHosts | string[] | Allowed hostnames |
blockedHosts | string[] | Blocked hostnames |
confineFilesToBaseUrl | boolean | Opt-in (Node only). Restrict local file reads to files inside baseUrl (symlinks resolved). ../ escapes are refused, and with no baseUrl no local file is read at all |
await render({
value: untrustedHtml,
width: 1200,
baseUrl: "/srv/assets",
limits: {
timeoutMs: 10_000,
maxResourceCount: 20,
maxTotalResourceBytes: 10_000_000,
allowedProtocols: ["https:"],
confineFilesToBaseUrl: true,
},
});
Fetched resources are cached in-process (up to 256 entries / 64 MiB, least-recently-used eviction, remote entries keyed per User-Agent). Call clearResourceCache() if the source files changed.
PDF Metadata & Page Layout
| Option | Type | Default | Description |
|---|---|---|---|
pdfTitle | string | - | PDF document title |
pdfAuthor | string | - | Author name |
pdfSubject | string | - | Document subject |
pdfKeywords | string | - | Keywords |
pdfCreator | string | - | Generating application |
pdfProducer | string | - | PDF conversion engine |
pdfOutline | boolean | true | Generate PDF bookmarks (outline) from h1–h6. Pass false to disable |
pdfMargin | { top?: number, right?: number, bottom?: number, left?: number } | - | Page margin settings (in px or pt) |
pdfHeader | string | - | HTML header template for each page ({{pageNumber}}, {{totalPages}} supported) |
pdfFooter | string | - | HTML footer template for each page ({{pageNumber}}, {{totalPages}} supported) |
pdfBackground | string | - | Background of the whole PDF page, margins included (#rrggbb or #rrggbbaa). Without it the document's background covers only the content area, so a dark page shows white margins |
pdfSkipRunning | number | 0 | How many leading pages are printed without header and footer (a cover, a contents page) |