UNPKG

dom-to-image-more

Version:

Generates an image from a DOM node using HTML5 canvas and SVG

1,071 lines (973 loc) 120 kB
(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 `&quot;` — valid, but surprising and reported as broken // (issue #191). Rewrite `url(&quot;X&quot;)` 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 `&quot;` 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\(&quot;([^]*?)&quot;\)/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