dom-to-image-more
Version:
Generates an image from a DOM node using HTML5 canvas and SVG
1,071 lines (973 loc) • 120 kB
JavaScript
(function (global) {
'use strict';
const util = newUtil();
const inliner = newInliner();
const fontFaces = newFontFaces();
const images = newImages();
const offscreen = {
position: 'fixed',
left: '-9999px',
visibility: 'hidden',
};
const SVG_NS = 'http://www.w3.org/2000/svg';
const XLINK_NS = 'http://www.w3.org/1999/xlink';
const SVG_DATA_URI_PREFIX = 'data:image/svg+xml;charset=utf-8,';
// Default logger: delegates to the global console at call time (so a stubbed
// console is still observed). It's the default value of the `logger` option, so the
// library always routes diagnostics through `options.logger`; a caller can replace
// it to redirect or silence output.
const defaultLogger = {
warn: function (...args) {
console.warn(...args);
},
error: function (...args) {
console.error(...args);
},
};
// Default impl options
const defaultOptions = {
// Default is to copy default styles of elements
copyDefaultStyles: true,
// Default is to fail on error, no placeholder
imagePlaceholder: undefined,
// Default cache bust is false, it will use the cache
cacheBust: false,
// Use (existing) authentication credentials for external URIs (CORS requests)
useCredentials: false,
// Use (existing) authentication credentials for external URIs (CORS requests) on some filtered requests only
useCredentialsFilters: [],
// Default resolve timeout
httpTimeout: 30000,
// Style computation cache tag rules (options are strict, relaxed)
styleCaching: 'strict',
// Default cors config is to request the image address directly
corsImg: undefined,
// Callback for adjustClonedNode eventing (to allow adjusting clone's properties)
adjustClonedNode: undefined,
// Callback to filter style properties to be included in the output
filterStyles: undefined,
// Callback to filter urls to be downloaded and inlined in the output
filterUrls: undefined,
// Callback to drop or adjust a ::before/::after pseudo-element; receives
// (node, pseudo, style) and may return false to drop it, an object of CSS
// property overrides to tweak it, or undefined/true to keep it as-is
adjustPseudoElement: undefined,
// Callback invoked when a resource (image/font) cannot be fetched; receives
// { url, message, status, willUsePlaceholder }. Purely observational — the
// render still degrades gracefully (placeholder or empty string).
onImageError: undefined,
// Opt-in: force the explicitly-captured root to be shown even if it is hidden
// by its own display:none / opacity:0 (visibility:hidden is always handled).
// Root-only; per-element hiding inside the subtree is left intact.
ensureShown: false,
// Device-pixel-ratio multiplier for the rasterized canvas output (png/jpeg/
// blob/canvas). Defaults to 1 (CSS pixels, unchanged). Set to
// window.devicePixelRatio for crisp high-DPI/Retina output. Composes with
// `scale` (effective multiplier = scale * pixelRatio).
pixelRatio: 1,
// Opt-in: reflect each scrollable element's current scroll position in the
// output instead of rendering everything scrolled to the top/left (issue
// #22). Default off so existing output is unchanged.
preserveScroll: false,
// Opt-in: suppress the console.error logged when a (typically cross-origin)
// stylesheet's cssRules cannot be read during font discovery. The failure is
// benign and already handled gracefully; this just quiets the noise.
ignoreCSSRuleErrors: false,
// Optional hook to supply or recover any external resource. Called as
// requestInterceptor(url, { type, status }) where `type` is one of the
// RESOURCE_TYPE values (exposed as domtoimage.ResourceType). status ===
// undefined is the pre-fetch call (a returned data URL string / promise
// short-circuits the network); a numeric status is a failed fetch (0 =
// network error/timeout) where a returned value is the fallback, taking
// precedence over imagePlaceholder. undefined/null falls through.
requestInterceptor: undefined,
// Opt-in: fetch and re-parse a cross-origin stylesheet whose cssRules can't be
// read directly, so its @font-face web fonts can be discovered and embedded.
// false (default) keeps the current behavior of skipping unreadable sheets.
// true fetches every unreadable cross-origin sheet; a function (href) => bool
// scopes which ones. Adds a network fetch per matched sheet, degrades quietly.
loadExternalStyleSheet: false,
// Console-like sink ({ warn?, error? }) the library's own diagnostics are routed
// through. Defaults to a logger that delegates to the global console. Provide
// your own (or a partial one) to redirect or silence: a missing method drops
// that level, so {} silences everything and { error: fn } keeps only errors.
logger: defaultLogger,
};
// Resource kinds passed to requestInterceptor as context.type. Exposed as
// domtoimage.ResourceType so callers can compare against named constants rather
// than magic strings. Values are plain strings (debuggable/serializable, unlike
// Symbols, which would also break === across realms/bundles).
const RESOURCE_TYPE = Object.freeze({
IMAGE: 'image', // <img> and SVG <image> content images
// any image referenced via a CSS property: background, mask, content,
// border-image, list-style-image, cursor, …
CSS_IMAGE: 'css-image',
FONT: 'font', // @font-face src
STYLESHEET: 'stylesheet', // external stylesheets
});
const domtoimage = {
toSvg: toSvg,
toPng: toPng,
toJpeg: toJpeg,
toBlob: toBlob,
toPixelData: toPixelData,
toCanvas: toCanvas,
ResourceType: RESOURCE_TYPE,
impl: {
fontFaces: fontFaces,
images: images,
util: util,
inliner: inliner,
urlCache: [],
options: {},
copyOptions: copyOptions,
resetUrlCache: resetUrlCache,
},
};
// Clear the per-render resource cache. Called at the start/end of a render so
// each capture fetches fresh; exposed for tests/advanced callers that drive the
// impl helpers directly.
function resetUrlCache() {
domtoimage.impl.urlCache = [];
}
if (typeof exports === 'object' && typeof module === 'object') {
module.exports = domtoimage; // eslint-disable-line no-undef
} else {
global.domtoimage = domtoimage;
}
// support node and browsers
const ELEMENT_NODE =
(typeof Node !== 'undefined' ? Node.ELEMENT_NODE : undefined) || 1;
const getComputedStyle = resolveGlobal('getComputedStyle');
const atob = resolveGlobal('atob');
// Resolve a global by name across node/browser/worker contexts.
function resolveGlobal(name) {
return (
(typeof global !== 'undefined' ? global[name] : undefined) ||
(typeof window !== 'undefined' ? window[name] : undefined) ||
globalThis[name]
);
}
// Route the library's diagnostics through the configured `logger` (a console-like
// object; defaults to one that delegates to the global console). A caller can
// redirect output to their own sink or silence it: a method missing on a supplied
// logger is dropped, so `logger: {}` silences everything and `logger: { error: fn }`
// keeps only errors.
function logWarn(...args) {
emitLog('warn', args);
}
function logError(...args) {
emitLog('error', args);
}
function emitLog(level, args) {
// Fall back to defaultLogger for the brief window before copyOptions runs.
const logger = domtoimage.impl.options.logger || defaultLogger;
const method = logger[level];
if (typeof method === 'function') {
method.apply(logger, args);
}
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options
* @param {Function} options.filter - Should return true if passed node should be included in the output
* (excluding node means excluding it's children as well). Not called on the root node.
* @param {Function} options.onclone - Callback function which is called when the Document has been cloned for
* rendering, can be used to modify the contents that will be rendered without affecting the original
* source document.
* @param {String} options.bgcolor - color for the background, any valid CSS color value.
* @param {Number} options.width - width to be applied to node before rendering.
* @param {Number} options.height - height to be applied to node before rendering.
* @param {Object} options.style - an object whose properties to be copied to node's style before rendering.
* @param {Number} options.quality - a Number between 0 and 1 indicating image quality (applicable to JPEG only),
defaults to 1.0.
* @param {Number} options.scale - a Number multiplier to scale up the canvas before rendering to reduce fuzzy images, defaults to 1.0.
* @param {Number} options.pixelRatio - device-pixel-ratio multiplier for the rasterized canvas (png/jpeg/blob/canvas); set to window.devicePixelRatio for crisp high-DPI output. Composes with scale. Defaults to 1.0.
* @param {String} options.imagePlaceholder - dataURL to use as a placeholder for failed images, default behaviour is to fail fast on images we can't fetch
* @param {Boolean} options.cacheBust - set to true to cache bust by appending the time to the request url
* @param {String} options.styleCaching - set to 'strict', 'relaxed' to select style caching rules
* @param {Boolean} options.copyDefaultStyles - set to false to disable use of default styles of elements
* @param {Boolean} options.disableEmbedFonts - set to true to disable font embedding into the SVG output.
* @param {Boolean} options.disableInlineImages - set to true to disable inlining images into the SVG output.
* @param {Object} options.corsImg - When the image is restricted by the server from cross-domain requests, the proxy address is passed in to get the image
* - @param {String} url - eg: https://cors-anywhere.herokuapp.com/
* - @param {Enumerator} method - get, post
* - @param {Object} headers - eg: { "Content-Type", "application/json;charset=UTF-8" }
* - @param {Object} data - post payload
* @param {Function} options.adjustClonedNode - callback for adjustClonedNode eventing (to allow adjusting clone's properties)
* @param {Function} options.filterStyles - Should return true if passed propertyName should be included in the output
* @param {Function} options.onImageError - called when a resource fails to fetch with { url, message, status, willUsePlaceholder }; observational only
* @return {Promise} - A promise that is fulfilled with a SVG image data URL
* */
function toSvg(node, options) {
const ownerWindow = domtoimage.impl.util.getWindow(node);
options = options || {};
domtoimage.impl.copyOptions(options);
const restorations = [];
svgRefsToInline = [];
// Rendering needs a live DOM. Under SSR (Angular Universal, Next.js, …)
// there is no document, so fail with a short, catchable error instead of a
// raw ReferenceError deep in the pipeline (issue #83; see the README "Things
// to watch out for" note on SSR). A real node — incl. jsdom — carries its
// own document via ownerWindow, so this only trips when genuinely DOM-less.
if (!ownerWindow || !ownerWindow.document) {
return Promise.reject(
new Error('dom-to-image-more: a browser DOM is required (SSR)')
);
}
return waitForDocumentFonts()
.then(function () {
return ensureElement(node);
})
.then(function (clonee) {
return cloneNode(clonee, options, null, ownerWindow);
})
.then(injectSvgRefs)
.then(options.disableEmbedFonts ? Promise.resolve(node) : embedFonts)
.then(options.disableInlineImages ? Promise.resolve(node) : inlineImages)
.then(applyOptions)
.then(makeSvgDataUri)
.finally(cleanup);
// Wait for any web fonts already loading in the source document to settle
// before we read computed styles, clone, and rasterize — a capture taken
// while the page's own fonts are mid-load would otherwise measure/snapshot
// fallback glyphs (wrong metrics, or missing icon glyphs). The CSS Font
// Loading API is not universal (older browsers, SSR/jsdom), so feature-detect;
// race a timeout (httpTimeout) so a perpetually-pending font can't hang. If
// the timeout wins we render anyway but warn, since the result may be missing
// glyphs or laid out with fallback metrics.
function waitForDocumentFonts() {
const doc = ownerWindow.document;
if (!doc.fonts || !doc.fonts.ready) {
return Promise.resolve();
}
const cap = domtoimage.impl.options.httpTimeout || 30000;
let timer;
const ready = Promise.resolve(doc.fonts.ready).then(
function () {
return false;
},
function () {
return false;
}
);
const timeout = new Promise(function (resolve) {
timer = ownerWindow.setTimeout(function () {
resolve(true);
}, cap);
});
return Promise.race([ready, timeout]).then(function (timedOut) {
ownerWindow.clearTimeout(timer);
if (timedOut) {
logWarn(
'dom-to-image-more: timed out after ' +
cap +
'ms waiting for document fonts to finish loading ' +
'(document.fonts.ready); rendering anyway — the output ' +
'may have missing glyphs or fallback-font metrics.'
);
}
});
}
function ensureElement(node) {
if (node.nodeType === ELEMENT_NODE) return node;
const originalChild = node;
const originalParent = node.parentNode;
if (!originalParent) {
throw new Error(
'Cannot render a non-element node that is not attached to a parent; ' +
'wrap it in an element or attach it to the document first.'
);
}
const wrappingSpan = document.createElement('span');
originalParent.replaceChild(wrappingSpan, originalChild);
wrappingSpan.append(node);
restorations.push({
parent: originalParent,
child: originalChild,
wrapper: wrappingSpan,
});
return wrappingSpan;
}
// Runs on both success and failure (via .finally) so a render that rejects
// partway can't leak the wrapper spans or the sandbox iframe into the
// document, or leave the per-render url cache populated.
function cleanup() {
restoreWrappers();
resetUrlCache();
svgRefsToInline = [];
removeSandbox();
}
// Prepend a hidden <svg><defs> holding any out-of-subtree elements that
// <use> nodes referenced (issue #215), so the standalone clone is
// self-contained. Ids already present in the clone are skipped to avoid
// duplicates. Returns the clone unchanged so the chain flows through.
function injectSvgRefs(clone) {
if (svgRefsToInline.length === 0) {
return clone;
}
const holder = document.createElementNS(SVG_NS, 'svg');
holder.setAttribute('xmlns', SVG_NS);
holder.setAttribute('width', '0');
holder.setAttribute('height', '0');
holder.style.setProperty('position', 'absolute');
holder.style.setProperty('width', '0');
holder.style.setProperty('height', '0');
holder.style.setProperty('overflow', 'hidden');
const defs = document.createElementNS(SVG_NS, 'defs');
holder.appendChild(defs);
const existingIds = new Set();
if (clone.getAttribute('id')) {
existingIds.add(clone.getAttribute('id'));
}
clone.querySelectorAll('[id]').forEach(function (el) {
existingIds.add(el.getAttribute('id'));
});
let injected = 0;
svgRefsToInline.forEach(function (ref) {
if (existingIds.has(ref.id)) {
return; // already in the clone
}
defs.appendChild(ref.node);
injected += 1;
});
if (injected > 0) {
clone.insertBefore(holder, clone.firstChild);
}
return clone;
}
function restoreWrappers() {
// put the original children back where the wrappers were inserted
while (restorations.length > 0) {
const restoration = restorations.pop();
try {
restoration.parent.replaceChild(
restoration.child,
restoration.wrapper
);
} catch (e) {
// The DOM may have been mutated mid-render; restore
// best-effort and never let cleanup throw (it would mask the
// real success value or error).
logError('domtoimage: failed to restore wrapped node', e);
}
}
}
function applyOptions(clone) {
// The captured root's own margin would offset it inside the fixed-size
// <foreignObject> and clip it out of the canvas (issue #38). Neutralize
// it on the root only (descendant margins drive internal layout and are
// left intact). A user can still set one explicitly via options.style.
if (clone.style) {
clone.style.margin = '0';
}
if (options.bgcolor) {
clone.style.backgroundColor = options.bgcolor;
}
if (options.width) {
clone.style.width = `${options.width}px`;
}
if (options.height) {
clone.style.height = `${options.height}px`;
}
if (options.style) {
Object.assign(clone.style, options.style);
}
let onCloneResult = null;
if (typeof options.onclone === 'function') {
onCloneResult = options.onclone(clone);
}
return Promise.resolve(onCloneResult).then(function () {
return clone;
});
}
function makeSvgDataUri(clone) {
// A non-root SVG element (`<g>`, `<path>`, `<circle>`, …) is meaningless
// inside an XHTML `<foreignObject>` and fails to rasterize (issue #205).
// Wrap it in a real synthesized `<svg>` framed by its bounding box instead.
if (util.isSVGElement(node) && !util.isSVGSVGElement(node)) {
return makeNonRootSvgDataUri(clone);
}
const finalizeEnsureShown = revealRootIfHidden(clone);
let width;
let height;
try {
width = options.width || util.width(node);
height = options.height || util.height(node);
} finally {
finalizeEnsureShown();
}
return Promise.resolve(clone)
.then(function (svg) {
svg.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
return new XMLSerializer().serializeToString(svg);
})
.then(normalizeCssUrlQuotes)
.then(util.escapeXhtml)
.then(function (xhtml) {
const foreignObjectSizing =
(util.isDimensionMissing(width)
? ' width="100%"'
: ` width="${width}"`) +
(util.isDimensionMissing(height)
? ' height="100%"'
: ` height="${height}"`);
const svgSizing =
(util.isDimensionMissing(width) ? '' : ` width="${width}"`) +
(util.isDimensionMissing(height) ? '' : ` height="${height}"`);
return `<svg xmlns="${SVG_NS}"${svgSizing}><foreignObject${foreignObjectSizing}>${xhtml}</foreignObject></svg>`;
})
.then(function (svg) {
return `${SVG_DATA_URI_PREFIX}${svg}`;
});
}
// Render a non-root SVG element (`<g>`, `<path>`, …) by wrapping its clone in a
// freshly synthesized `<svg>` framed by the original's `getBBox()` (issue #205).
// No `<foreignObject>` and no XHTML namespace — the element is real SVG content.
// The element's own positioning transform placed it within its *original* svg
// and is meaningless once extracted, so it is dropped and the bbox `x/y` drives
// the `viewBox` so the geometry frames exactly. Falls back to a 0 box if getBBox
// is unavailable (detached / `display:none`). `ensureShown` is honored here too,
// so a hidden `<g>`/`<path>` root is revealed before measuring (otherwise getBBox
// throws on a `display:none` element and the capture comes out blank).
function makeNonRootSvgDataUri(clone) {
const finalizeEnsureShown = revealRootIfHidden(clone);
let box;
try {
box = node.getBBox();
} catch (_e) {
box = { x: 0, y: 0, width: 0, height: 0 };
} finally {
finalizeEnsureShown();
}
clone.removeAttribute('transform');
clone.style.removeProperty('transform');
const width = options.width || box.width;
const height = options.height || box.height;
return Promise.resolve(clone)
.then(function (svgEl) {
svgEl.setAttribute('xmlns', SVG_NS);
return new XMLSerializer().serializeToString(svgEl);
})
.then(normalizeCssUrlQuotes)
.then(util.escapeXhtml)
.then(function (inner) {
const sizing =
(util.isDimensionMissing(width) ? '' : ` width="${width}"`) +
(util.isDimensionMissing(height) ? '' : ` height="${height}"`);
const viewBox = `${box.x} ${box.y} ${box.width} ${box.height}`;
return `<svg xmlns="${SVG_NS}"${sizing} viewBox="${viewBox}">${inner}</svg>`;
})
.then(function (svg) {
return `${SVG_DATA_URI_PREFIX}${svg}`;
});
}
// Browsers normalize CSS `url()` values to double quotes, so setting one via
// the live style (options.style, inlined images, copied computed styles) and
// then serializing it inside the double-quoted `style="…"` attribute escapes
// those quotes to `"` — valid, but surprising and reported as broken
// (issue #191). Rewrite `url("X")` to single-quoted `url('X')` for
// clean, conventional output. Single quotes don't collide with the attribute
// delimiter, so they survive serialization unescaped. Left as-is when X itself
// contains a single quote (the `"` form is still correct there). Runs on
// the serialized string because the live style object always re-normalizes
// back to double quotes, so it can't be fixed before serializing.
function normalizeCssUrlQuotes(serialized) {
return serialized.replace(
/url\("([^]*?)"\)/g,
function (match, inner) {
return inner.indexOf("'") >= 0 ? match : `url('${inner}')`;
}
);
}
// `ensureShown` opt-in: make the explicitly-captured ROOT appear even if it
// is hidden by its own `display:none` / `opacity:0` (`visibility:hidden` is
// already handled during cloning). Root-only and never on by default — these
// values are often deliberate, and per-element hiding inside the subtree is
// left intact. `opacity:0` just needs the clone overridden. `display:none` has
// no layout box, so the original is briefly revealed *in place* to measure
// (synchronous — no paint between set and restore, so no visible flash, though
// it does force a reflow); the measured size feeds the SVG and the clone root
// takes the element's real revealed display. Returns a finalize() the caller
// runs immediately after measuring (guarded with try/finally above).
function revealRootIfHidden(clone) {
const noop = function () {};
if (!options.ensureShown) {
return noop;
}
const computed = getComputedStyle(node);
if (computed.getPropertyValue('opacity') === '0') {
clone.style.setProperty('opacity', '1');
}
if (computed.getPropertyValue('display') !== 'none') {
return noop;
}
const previousDisplay = node.style.getPropertyValue('display');
const previousPriority = node.style.getPropertyPriority('display');
// Reveal without clobbering the element's intended display. The common
// case is an inline `style="display:none"`: just dropping the inline
// declaration lets the cascade restore the *real* shown display — e.g. a
// class's `display:flex`/`grid` — which a blanket `revert` would have
// discarded. If a rule still hides it, force it shown, preferring the
// element's own inline display when it had a meaningful one (e.g. inline
// `display:flex` defeated by a stylesheet `display:none !important`) and
// falling back to `revert` (the UA tag default) only when the intended
// display is genuinely unknowable.
node.style.removeProperty('display');
if (getComputedStyle(node).getPropertyValue('display') === 'none') {
const fallback =
previousDisplay && previousDisplay !== 'none'
? previousDisplay
: 'revert';
node.style.setProperty('display', fallback, 'important');
}
return function finalize() {
const shown = getComputedStyle(node).getPropertyValue('display');
clone.style.setProperty('display', shown === 'none' ? 'block' : shown);
if (previousDisplay) {
node.style.setProperty('display', previousDisplay, previousPriority);
} else {
node.style.removeProperty('display');
}
};
}
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options, @see {@link toSvg}
* @return {Promise} - A promise that is fulfilled with a Uint8Array containing RGBA pixel data.
* */
function toPixelData(node, options) {
return draw(node, options).then(function (canvas) {
return canvas
.getContext('2d')
.getImageData(0, 0, util.width(node), util.height(node)).data;
});
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options, @see {@link toSvg}
* @return {Promise} - A promise that is fulfilled with a PNG image data URL
* */
function toPng(node, options) {
return draw(node, options).then(function (canvas) {
return canvas.toDataURL();
});
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options, @see {@link toSvg}
* @return {Promise} - A promise that is fulfilled with a JPEG image data URL
* */
function toJpeg(node, options) {
return draw(node, options).then(function (canvas) {
return canvas.toDataURL(
'image/jpeg',
(options ? options.quality : undefined) || 1.0
);
});
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options, @see {@link toSvg}
* @return {Promise} - A promise that is fulfilled with a PNG image blob
* */
function toBlob(node, options) {
return draw(node, options).then(util.canvasToBlob);
}
/**
* @param {Node} node - The DOM Node object to render
* @param {Object} options - Rendering options, @see {@link toSvg}
* @return {Promise} - A promise that is fulfilled with a canvas object
* */
function toCanvas(node, options) {
return draw(node, options);
}
function copyOptions(options) {
// Copy options to impl options for use in impl, falling back to the
// default for any option the caller did not supply.
Object.keys(defaultOptions).forEach(function (name) {
domtoimage.impl.options[name] =
typeof options[name] === 'undefined'
? defaultOptions[name]
: options[name];
});
}
function draw(domNode, options) {
options = options || {};
return toSvg(domNode, options)
.then(util.makeImage)
.then(function (image) {
const result = newCanvas(domNode);
const canvas = result.canvas;
const scale = result.scale;
const ctx = canvas.getContext('2d');
ctx.msImageSmoothingEnabled = false;
ctx.imageSmoothingEnabled = false;
if (image) {
ctx.scale(scale, scale);
// Draw into an explicit width×height box rather than relying on
// the image's intrinsic size. Chrome derives that intrinsic size
// from the SVG's width/height attributes (so this is a no-op
// there), but Firefox computes it unreliably for an
// `<foreignObject>` image and otherwise crops the result to a
// default/intrinsic box (issue #160). Forcing the destination
// rectangle makes both engines fill the same canvas.
ctx.drawImage(image, 0, 0, result.width, result.height);
}
return canvas;
});
function newCanvas(node) {
let width = options.width || util.width(node);
let height = options.height || util.height(node);
// per https://www.w3.org/TR/CSS2/visudet.html#inline-replaced-width the default width should be 300px if height
// not set, otherwise should be 2:1 aspect ratio for whatever height is specified
if (util.isDimensionMissing(width)) {
width = util.isDimensionMissing(height) ? 300 : height * 2.0;
}
if (util.isDimensionMissing(height)) {
height = width / 2.0;
}
// Effective resolution multiplier. Both default to 1, so the default
// output is unchanged; `pixelRatio: window.devicePixelRatio` opts into
// crisp high-DPI output, and `scale` remains an explicit upscale.
const requestedScale =
(typeof options.scale === 'number' ? options.scale : 1) *
(typeof options.pixelRatio === 'number' ? options.pixelRatio : 1);
const scale = clampScaleToCanvasLimit(width, height, requestedScale);
const canvas = document.createElement('canvas');
canvas.width = width * scale;
canvas.height = height * scale;
if (options.bgcolor) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = options.bgcolor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return { canvas: canvas, scale: scale, width: width, height: height };
}
}
// Browsers cap canvas size; beyond the cap a canvas silently produces a blank or
// partial bitmap (issue #182 "incomplete on Retina", and the #159/#160 crash/crop
// family). When the requested `width * height * scale` canvas would exceed a
// conservative limit, clamp the scale to fit and warn — degrading predictably
// instead of truncating silently. Returns the original scale when it already fits.
function clampScaleToCanvasLimit(width, height, scale) {
// Conservative cross-browser bounds: max single dimension and max area.
const MAX_DIMENSION = 16384;
const MAX_AREA = MAX_DIMENSION * MAX_DIMENSION;
// All three must be positive finite numbers; written this way (rather than
// `<= 0`) so NaN is also rejected, since `NaN <= 0` is false.
const allPositive = width > 0 && height > 0 && scale > 0;
if (!allPositive) {
return scale;
}
const limit = Math.min(
MAX_DIMENSION / width,
MAX_DIMENSION / height,
Math.sqrt(MAX_AREA / (width * height))
);
if (scale <= limit) {
return scale;
}
logWarn(
'dom-to-image-more: the requested ' +
Math.round(width * scale) +
'×' +
Math.round(height * scale) +
' canvas exceeds the browser limit; clamping the effective scale from ' +
scale +
' to ' +
limit +
'. Capture detail may be reduced — render a smaller region or lower scale/pixelRatio.'
);
return limit;
}
let sandbox = null;
// Referenced SVG defs (e.g. a <symbol> a <use> points at) that live OUTSIDE the
// rendered subtree. Collected during cloning and injected into the root clone so
// the standalone output SVG is self-contained and `<use href="#id">` resolves
// (issue #215). Keyed by id; reset per render.
let svgRefsToInline = [];
function cloneNode(node, options, parentComputedStyles, ownerWindow) {
const filter = options.filter;
if (
node === sandbox ||
util.isHTMLScriptElement(node) ||
util.isHTMLStyleElement(node) ||
util.isHTMLLinkElement(node) ||
(parentComputedStyles !== null && filter && !filter(node))
) {
return Promise.resolve();
}
return Promise.resolve(node)
.then(makeNodeCopy)
.then(adjustCloneBefore)
.then(function (clone) {
return cloneChildren(clone, getParentOfChildren(node));
})
.then(adjustCloneAfter)
.then(function (clone) {
return processClone(clone, node);
});
function makeNodeCopy(original) {
if (util.isHTMLCanvasElement(original)) {
return util.makeImage(original.toDataURL());
}
return original.cloneNode(false);
}
function adjustCloneBefore(clone) {
if (options.adjustClonedNode) {
options.adjustClonedNode(node, clone, false);
}
return Promise.resolve(clone);
}
function adjustCloneAfter(clone) {
if (options.adjustClonedNode) {
options.adjustClonedNode(node, clone, true);
}
return Promise.resolve(clone);
}
function getParentOfChildren(original) {
if (util.isElementHostForOpenShadowRoot(original)) {
return original.shadowRoot; // jump "down" to #shadow-root
}
return original;
}
function cloneChildren(clone, original) {
const originalChildren = getRenderedChildren(original);
let done = Promise.resolve();
if (originalChildren.length !== 0) {
const originalComputedStyles = getComputedStyle(
getRenderedParent(original)
);
util.asArray(originalChildren).forEach(function (originalChild) {
done = done.then(function () {
return cloneNode(
originalChild,
options,
originalComputedStyles,
ownerWindow
).then(function (clonedChild) {
if (clonedChild) {
clone.appendChild(clonedChild);
}
});
});
});
}
return done.then(function () {
return clone;
});
function getRenderedParent(original) {
if (util.isShadowRoot(original)) {
return original.host; // jump up from #shadow-root to its parent <element>
}
return original;
}
function getRenderedChildren(original) {
if (util.isShadowSlotElement(original)) {
const assignedNodes = original.assignedNodes();
if (assignedNodes && assignedNodes.length > 0) return assignedNodes; // shadow DOM <slot> has "assigned nodes" as rendered children
}
return original.childNodes;
}
}
function processClone(clone, original) {
if (!util.isElement(clone) || util.isShadowSlotElement(original)) {
return Promise.resolve(clone);
}
return Promise.resolve()
.then(decodeSourceImage)
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(sanitizeAttributes)
.then(preserveScroll)
.then(fixSvg)
.then(fixTableCaption)
.then(fixResponsiveImages)
.then(function () {
return clone;
});
// Opt-in (issue #22): a scrolled element re-lays-out at scroll 0 inside the
// <foreignObject>, so the output shows the top/left instead of what's
// actually scrolled into view. Scroll position is a runtime property that
// doesn't serialize, so instead clip the clone and shift its children up/
// left by the original's scroll offset — a post-layout visual translate that
// works for normal flow and flex/grid alike (no fragile content wrapping).
// Composes with any transform the child already carries.
function preserveScroll() {
if (!options.preserveScroll || !clone.style) {
return;
}
const scrollLeft = original.scrollLeft || 0;
const scrollTop = original.scrollTop || 0;
if (scrollLeft === 0 && scrollTop === 0) {
return;
}
// Clip to the element's box without painting interactive scrollbars.
clone.style.overflow = 'hidden';
const shift = `translate(${-scrollLeft}px, ${-scrollTop}px)`;
util.asArray(clone.children).forEach(function (child) {
if (!child.style) {
return;
}
const existing =
child.style.transform && child.style.transform !== 'none'
? ` ${child.style.transform}`
: '';
child.style.transform = `${shift}${existing}`;
});
}
// Malformed source HTML (e.g. `<div id="x"">`) makes the parser create
// attributes whose names are illegal in XML — a lone `"` here. Serialized
// into the XHTML <foreignObject> they produce invalid markup that fails to
// rasterize (issue #152). No valid attribute name can contain a quote,
// `=`, `<`, `>`, `/`, or whitespace, so drop any such attribute and let the
// capture succeed instead of erroring.
function sanitizeAttributes() {
if (!clone.attributes || !clone.removeAttribute) {
return;
}
const illegal = [];
for (let i = 0; i < clone.attributes.length; i += 1) {
const name = clone.attributes[i].name;
if (/["'=<>/\s]/.test(name)) {
illegal.push(name);
}
}
illegal.forEach(function (name) {
clone.removeAttribute(name);
});
}
// An <img> contributes its height from the loaded bitmap's aspect ratio.
// If the source image hasn't decoded yet (e.g. loading="lazy", or simply
// not yet loaded), its computed height is ~0, so cloneStyle would copy a
// collapsed box and the picture drops out of the capture. Forcing the
// decode first makes the dimensions real and resolves currentSrc for
// srcset/sizes images, so the capture is deterministic regardless of when
// the source happened to load.
function decodeSourceImage() {
if (
!util.isHTMLImageElement(original) ||
typeof original.decode !== 'function'
) {
return undefined;
}
if (original.complete && original.naturalWidth > 0) {
return undefined;
}
return original.decode().catch(function () {
// Broken or blocked image: nothing to wait for, proceed with
// whatever dimensions/source we can read below.
});
}
function fixResponsiveImages() {
if (!util.isHTMLImageElement(clone)) {
return;
}
// Remove lazy-loading and responsive attributes
clone.removeAttribute('loading');
// Collapse srcset/sizes down to the single source the browser chose
// (now resolved, thanks to decodeSourceImage), so the clone renders
// exactly that candidate.
if (original.srcset || original.sizes) {
clone.removeAttribute('srcset');
clone.removeAttribute('sizes');
clone.src = original.currentSrc || original.src;
}
}
function cloneStyle() {
// Some exotic elements are real Elements but expose no `.style`
// object — e.g. an element in a non-HTML/SVG/MathML namespace created
// via `createElementNS`. There's nothing to copy styles onto, and
// touching `.style` would throw "Cannot read properties of undefined"
// (issue #151). Skip styling such a node; its structure still clones.
if (!clone.style) {
return;
}
copyStyle(original, clone);
fixInheritedVisibility();
// `visibility` is inherited, but the style copy pins each element's
// *computed* value. So an element that is hidden only because an
// ancestor is `visibility:hidden` ends up with an explicit
// `visibility:hidden` of its own — and so does the captured root.
// Rendering a node from inside a hidden subtree then comes out blank
// (issue #167). Fix: on the captured root (no parent), force it visible
// — the caller explicitly asked to render it; on descendants, drop
// visibility when it merely equals the parent's (i.e. inherited) so it
// follows the now-visible root, while keeping genuine per-element
// overrides (visible-inside-hidden, or hidden-inside-visible). Covers
// both the fast-path and the cssText path.
function fixInheritedVisibility() {
const sourceVisibility =
getComputedStyle(original).getPropertyValue('visibility');
if (parentComputedStyles === null) {
// 'hidden' or 'collapse' (collapse renders like hidden off
// tables) — both blank the explicitly-captured root.
if (sourceVisibility !== 'visible') {
clone.style.setProperty('visibility', 'visible');
}
return;
}
const parentVisibility =
parentComputedStyles.getPropertyValue('visibility');
if (sourceVisibility === parentVisibility) {
clone.style.removeProperty('visibility');
}
}
function copyFont(source, target) {
target.font = source.font;
target.fontFamily = source.fontFamily;
target.fontFeatureSettings = source.fontFeatureSettings;
target.fontKerning = source.fontKerning;
target.fontSize = source.fontSize;
target.fontStretch = source.fontStretch;
target.fontStyle = source.fontStyle;
target.fontVariant = source.fontVariant;
target.fontVariantCaps = source.fontVariantCaps;
target.fontVariantEastAsian = source.fontVariantEastAsian;
target.fontVariantLigatures = source.fontVariantLigatures;
target.fontVariantNumeric = source.fontVariantNumeric;
target.fontVariationSettings = source.fontVariationSettings;
target.fontWeight = source.fontWeight;
}
function copyStyle(sourceElement, targetElement) {
const sourceComputedStyles = getComputedStyle(sourceElement);
if (sourceComputedStyles.cssText) {
targetElement.style.cssText = sourceComputedStyles.cssText;
copyFont(sourceComputedStyles, targetElement.style); // here we re-assign the font props.
} else {
copyUserComputedStyleFast(
options,
sourceElement,
sourceComputedStyles,
parentComputedStyles,
targetElement
);
// Remove positioning of initial element, which stops them from being captured correctly
if (parentComputedStyles === null) {
[
'inset-block',
'inset-block-start',
'inset-block-end',
].forEach((prop) => targetElement.style.removeProperty(prop));
['left', 'right', 'top', 'bottom'].forEach((prop) => {
if (targetElement.style.getPropertyValue(prop)) {
targetElement.style.setProperty(prop, '0px');
}
});
}
}
}
}
function clonePseudoElements() {
const cloneClassName = util.uid();
return Promise.all([':before', ':after'].map(clonePseudoElement));
function clonePseudoElement(element) {
const style = getComputedStyle(original, element);
const content = style.getPropertyValue('content');
if (content === '' || content === 'none') {
return undefined;
}
// Let