UNPKG

modern-screenshot

Version:

Quickly generate image from DOM node using HTML5 canvas and SVG

1,544 lines (1,515 loc) 53.5 kB
function changeJpegDpi(uint8Array, dpi) { uint8Array[13] = 1; uint8Array[14] = dpi >> 8; uint8Array[15] = dpi & 255; uint8Array[16] = dpi >> 8; uint8Array[17] = dpi & 255; return uint8Array; } const _P = "p".charCodeAt(0); const _H = "H".charCodeAt(0); const _Y = "Y".charCodeAt(0); const _S = "s".charCodeAt(0); let pngDataTable; function createPngDataTable() { const crcTable = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1; } crcTable[n] = c; } return crcTable; } function calcCrc(uint8Array) { let c = -1; if (!pngDataTable) pngDataTable = createPngDataTable(); for (let n = 0; n < uint8Array.length; n++) { c = pngDataTable[(c ^ uint8Array[n]) & 255] ^ c >>> 8; } return c ^ -1; } function searchStartOfPhys(uint8Array) { const length = uint8Array.length - 1; for (let i = length; i >= 4; i--) { if (uint8Array[i - 4] === 9 && uint8Array[i - 3] === _P && uint8Array[i - 2] === _H && uint8Array[i - 1] === _Y && uint8Array[i] === _S) { return i - 3; } } return 0; } function changePngDpi(uint8Array, dpi, overwritepHYs = false) { const physChunk = new Uint8Array(13); dpi *= 39.3701; physChunk[0] = _P; physChunk[1] = _H; physChunk[2] = _Y; physChunk[3] = _S; physChunk[4] = dpi >>> 24; physChunk[5] = dpi >>> 16; physChunk[6] = dpi >>> 8; physChunk[7] = dpi & 255; physChunk[8] = physChunk[4]; physChunk[9] = physChunk[5]; physChunk[10] = physChunk[6]; physChunk[11] = physChunk[7]; physChunk[12] = 1; const crc = calcCrc(physChunk); const crcChunk = new Uint8Array(4); crcChunk[0] = crc >>> 24; crcChunk[1] = crc >>> 16; crcChunk[2] = crc >>> 8; crcChunk[3] = crc & 255; if (overwritepHYs) { const startingIndex = searchStartOfPhys(uint8Array); uint8Array.set(physChunk, startingIndex); uint8Array.set(crcChunk, startingIndex + 13); return uint8Array; } else { const chunkLength = new Uint8Array(4); chunkLength[0] = 0; chunkLength[1] = 0; chunkLength[2] = 0; chunkLength[3] = 9; const finalHeader = new Uint8Array(54); finalHeader.set(uint8Array, 0); finalHeader.set(chunkLength, 33); finalHeader.set(physChunk, 37); finalHeader.set(crcChunk, 50); return finalHeader; } } const b64PhysSignature1 = "AAlwSFlz"; const b64PhysSignature2 = "AAAJcEhZ"; const b64PhysSignature3 = "AAAACXBI"; function detectPhysChunkFromDataUrl(dataUrl) { let b64index = dataUrl.indexOf(b64PhysSignature1); if (b64index === -1) { b64index = dataUrl.indexOf(b64PhysSignature2); } if (b64index === -1) { b64index = dataUrl.indexOf(b64PhysSignature3); } return b64index; } const PREFIX = "[modern-screenshot]"; const IN_BROWSER = typeof window !== "undefined"; const SUPPORT_WEB_WORKER = IN_BROWSER && "Worker" in window; const SUPPORT_ATOB = IN_BROWSER && "atob" in window; const SUPPORT_BTOA = IN_BROWSER && "btoa" in window; const USER_AGENT = IN_BROWSER ? window.navigator?.userAgent : ""; const IN_CHROME = USER_AGENT.includes("Chrome"); const IN_SAFARI = USER_AGENT.includes("AppleWebKit") && !IN_CHROME; const IN_FIREFOX = USER_AGENT.includes("Firefox"); const isContext = (value) => value && "__CONTEXT__" in value; const isCssFontFaceRule = (rule) => rule.constructor.name === "CSSFontFaceRule"; const isCSSImportRule = (rule) => rule.constructor.name === "CSSImportRule"; const isElementNode = (node) => node.nodeType === 1; const isSVGElementNode = (node) => typeof node.className === "object"; const isSVGImageElementNode = (node) => node.tagName === "image"; const isSVGUseElementNode = (node) => node.tagName === "use"; const isHTMLElementNode = (node) => isElementNode(node) && typeof node.style !== "undefined" && !isSVGElementNode(node); const isCommentNode = (node) => node.nodeType === 8; const isTextNode = (node) => node.nodeType === 3; const isImageElement = (node) => node.tagName === "IMG"; const isVideoElement = (node) => node.tagName === "VIDEO"; const isCanvasElement = (node) => node.tagName === "CANVAS"; const isTextareaElement = (node) => node.tagName === "TEXTAREA"; const isInputElement = (node) => node.tagName === "INPUT"; const isStyleElement = (node) => node.tagName === "STYLE"; const isScriptElement = (node) => node.tagName === "SCRIPT"; const isSelectElement = (node) => node.tagName === "SELECT"; const isSlotElement = (node) => node.tagName === "SLOT"; const isIFrameElement = (node) => node.tagName === "IFRAME"; const consoleWarn = (...args) => console.warn(PREFIX, ...args); function supportWebp(ownerDocument) { const canvas = ownerDocument?.createElement?.("canvas"); if (canvas) { canvas.height = canvas.width = 1; } return Boolean(canvas) && "toDataURL" in canvas && Boolean(canvas.toDataURL("image/webp").includes("image/webp")); } const isDataUrl = (url) => url.startsWith("data:"); function resolveUrl(url, baseUrl) { if (url.match(/^[a-z]+:\/\//i)) return url; if (IN_BROWSER && url.match(/^\/\//)) return window.location.protocol + url; if (url.match(/^[a-z]+:/i)) return url; if (!IN_BROWSER) return url; const doc = getDocument().implementation.createHTMLDocument(); const base = doc.createElement("base"); const a = doc.createElement("a"); doc.head.appendChild(base); doc.body.appendChild(a); if (baseUrl) base.href = baseUrl; a.href = url; return a.href; } function getDocument(target) { return (target && isElementNode(target) ? target?.ownerDocument : target) ?? window.document; } const XMLNS = "http://www.w3.org/2000/svg"; function createSvg(width, height, ownerDocument) { const svg = getDocument(ownerDocument).createElementNS(XMLNS, "svg"); svg.setAttributeNS(null, "width", width.toString()); svg.setAttributeNS(null, "height", height.toString()); svg.setAttributeNS(null, "viewBox", `0 0 ${width} ${height}`); return svg; } function svgToDataUrl(svg, removeControlCharacter) { let xhtml = new XMLSerializer().serializeToString(svg); if (removeControlCharacter) { xhtml = xhtml.replace(/[\u0000-\u0008\v\f\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/gu, ""); } return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xhtml)}`; } async function canvasToBlob(canvas, type = "image/png", quality = 1) { try { return await new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error("Blob is null")); } }, type, quality); }); } catch (error) { if (SUPPORT_ATOB) { return dataUrlToBlob(canvas.toDataURL(type, quality)); } throw error; } } function dataUrlToBlob(dataUrl) { const [header, base64] = dataUrl.split(","); const type = header.match(/data:(.+);/)?.[1] ?? void 0; const decoded = window.atob(base64); const length = decoded.length; const buffer = new Uint8Array(length); for (let i = 0; i < length; i += 1) { buffer[i] = decoded.charCodeAt(i); } return new Blob([buffer], { type }); } function readBlob(blob, type) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.onabort = () => reject(new Error(`Failed read blob to ${type}`)); if (type === "dataUrl") { reader.readAsDataURL(blob); } else if (type === "arrayBuffer") { reader.readAsArrayBuffer(blob); } }); } const blobToDataUrl = (blob) => readBlob(blob, "dataUrl"); const blobToArrayBuffer = (blob) => readBlob(blob, "arrayBuffer"); function createImage(url, ownerDocument) { const img = getDocument(ownerDocument).createElement("img"); img.decoding = "sync"; img.loading = "eager"; img.src = url; return img; } function loadMedia(media, options) { return new Promise((resolve) => { const { timeout, ownerDocument, onError: userOnError, onWarn } = options ?? {}; const node = typeof media === "string" ? createImage(media, getDocument(ownerDocument)) : media; let timer = null; let removeEventListeners = null; function onResolve() { resolve(node); timer && clearTimeout(timer); removeEventListeners?.(); } if (timeout) { timer = setTimeout(onResolve, timeout); } if (isVideoElement(node)) { const currentSrc = node.currentSrc || node.src; if (!currentSrc) { if (node.poster) { return loadMedia(node.poster, options).then(resolve); } return onResolve(); } if (node.readyState >= 2) { return onResolve(); } const onLoadeddata = onResolve; const onError = (error) => { onWarn?.( "Failed video load", currentSrc, error ); userOnError?.(error); onResolve(); }; removeEventListeners = () => { node.removeEventListener("loadeddata", onLoadeddata); node.removeEventListener("error", onError); }; node.addEventListener("loadeddata", onLoadeddata, { once: true }); node.addEventListener("error", onError, { once: true }); } else { const currentSrc = isSVGImageElementNode(node) ? node.href.baseVal : node.currentSrc || node.src; if (!currentSrc) { return onResolve(); } const onLoad = async () => { if (isImageElement(node) && "decode" in node) { try { await node.decode(); } catch (error) { onWarn?.( "Failed to decode image, trying to render anyway", node.dataset.originalSrc || currentSrc, error ); } } onResolve(); }; const onError = (error) => { onWarn?.( "Failed image load", node.dataset.originalSrc || currentSrc, error ); onResolve(); }; if (isImageElement(node) && node.complete) { return onLoad(); } removeEventListeners = () => { node.removeEventListener("load", onLoad); node.removeEventListener("error", onError); }; node.addEventListener("load", onLoad, { once: true }); node.addEventListener("error", onError, { once: true }); } }); } async function waitUntilLoad(node, options) { if (isHTMLElementNode(node)) { if (isImageElement(node) || isVideoElement(node)) { await loadMedia(node, options); } else { await Promise.all( ["img", "video"].flatMap((selectors) => { return Array.from(node.querySelectorAll(selectors)).map((el) => loadMedia(el, options)); }) ); } } } const uuid = /* @__PURE__ */ function uuid2() { let counter = 0; const random = () => `0000${(Math.random() * 36 ** 4 << 0).toString(36)}`.slice(-4); return () => { counter += 1; return `u${random()}${counter}`; }; }(); function splitFontFamily(fontFamily) { return fontFamily?.split(",").map((val) => val.trim().replace(/"|'/g, "").toLowerCase()).filter(Boolean); } let uid = 0; function createLogger(debug) { const prefix = `${PREFIX}[#${uid}]`; uid++; return { // eslint-disable-next-line no-console time: (label) => debug && console.time(`${prefix} ${label}`), // eslint-disable-next-line no-console timeEnd: (label) => debug && console.timeEnd(`${prefix} ${label}`), warn: (...args) => debug && consoleWarn(...args) }; } function getDefaultRequestInit(bypassingCache) { return { cache: bypassingCache ? "no-cache" : "force-cache" }; } async function orCreateContext(node, options) { return isContext(node) ? node : createContext(node, { ...options, autoDestruct: true }); } async function createContext(node, options) { const { scale = 1, workerUrl, workerNumber = 1 } = options || {}; const debug = Boolean(options?.debug); const features = options?.features ?? true; const ownerDocument = node.ownerDocument ?? (IN_BROWSER ? window.document : void 0); const ownerWindow = node.ownerDocument?.defaultView ?? (IN_BROWSER ? window : void 0); const requests = /* @__PURE__ */ new Map(); const context = { // Options width: 0, height: 0, quality: 1, type: "image/png", scale, backgroundColor: null, style: null, filter: null, maximumCanvasSize: 0, timeout: 3e4, progress: null, debug, fetch: { requestInit: getDefaultRequestInit(options?.fetch?.bypassingCache), placeholderImage: "data:image/png;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", bypassingCache: false, ...options?.fetch }, fetchFn: null, font: {}, drawImageInterval: 100, workerUrl: null, workerNumber, onCloneNode: null, onEmbedNode: null, onCreateForeignObjectSvg: null, includeStyleProperties: null, autoDestruct: false, ...options, // InternalContext __CONTEXT__: true, log: createLogger(debug), node, ownerDocument, ownerWindow, dpi: scale === 1 ? null : 96 * scale, svgStyleElement: createStyleElement(ownerDocument), svgDefsElement: ownerDocument?.createElementNS(XMLNS, "defs"), svgStyles: /* @__PURE__ */ new Map(), defaultComputedStyles: /* @__PURE__ */ new Map(), workers: [ ...Array.from({ length: SUPPORT_WEB_WORKER && workerUrl && workerNumber ? workerNumber : 0 }) ].map(() => { try { const worker = new Worker(workerUrl); worker.onmessage = async (event) => { const { url, result } = event.data; if (result) { requests.get(url)?.resolve?.(result); } else { requests.get(url)?.reject?.(new Error(`Error receiving message from worker: ${url}`)); } }; worker.onmessageerror = (event) => { const { url } = event.data; requests.get(url)?.reject?.(new Error(`Error receiving message from worker: ${url}`)); }; return worker; } catch (error) { context.log.warn("Failed to new Worker", error); return null; } }).filter(Boolean), fontFamilies: /* @__PURE__ */ new Map(), fontCssTexts: /* @__PURE__ */ new Map(), acceptOfImage: `${[ supportWebp(ownerDocument) && "image/webp", "image/svg+xml", "image/*", "*/*" ].filter(Boolean).join(",")};q=0.8`, requests, drawImageCount: 0, tasks: [], features, isEnable: (key) => { if (key === "restoreScrollPosition") { return typeof features === "boolean" ? false : features[key] ?? false; } if (typeof features === "boolean") { return features; } return features[key] ?? true; } }; context.log.time("wait until load"); await waitUntilLoad(node, { timeout: context.timeout, onWarn: context.log.warn }); context.log.timeEnd("wait until load"); const { width, height } = resolveBoundingBox(node, context); context.width = width; context.height = height; return context; } function createStyleElement(ownerDocument) { if (!ownerDocument) return void 0; const style = ownerDocument.createElement("style"); const cssText = style.ownerDocument.createTextNode(` .______background-clip--text { background-clip: text; -webkit-background-clip: text; } `); style.appendChild(cssText); return style; } function resolveBoundingBox(node, context) { let { width, height } = context; if (isElementNode(node) && (!width || !height)) { const box = node.getBoundingClientRect(); width = width || box.width || Number(node.getAttribute("width")) || 0; height = height || box.height || Number(node.getAttribute("height")) || 0; } return { width, height }; } async function imageToCanvas(image, context) { const { log, timeout, drawImageCount, drawImageInterval } = context; log.time("image to canvas"); const loaded = await loadMedia(image, { timeout, onWarn: context.log.warn }); const { canvas, context2d } = createCanvas(image.ownerDocument, context); const drawImage = () => { try { context2d?.drawImage(loaded, 0, 0, canvas.width, canvas.height); } catch (error) { context.log.warn("Failed to drawImage", error); } }; drawImage(); if (context.isEnable("fixSvgXmlDecode")) { for (let i = 0; i < drawImageCount; i++) { await new Promise((resolve) => { setTimeout(() => { drawImage(); resolve(); }, i + drawImageInterval); }); } } context.drawImageCount = 0; log.timeEnd("image to canvas"); return canvas; } function createCanvas(ownerDocument, context) { const { width, height, scale, backgroundColor, maximumCanvasSize: max } = context; const canvas = ownerDocument.createElement("canvas"); canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; if (max) { if (canvas.width > max || canvas.height > max) { if (canvas.width > max && canvas.height > max) { if (canvas.width > canvas.height) { canvas.height *= max / canvas.width; canvas.width = max; } else { canvas.width *= max / canvas.height; canvas.height = max; } } else if (canvas.width > max) { canvas.height *= max / canvas.width; canvas.width = max; } else { canvas.width *= max / canvas.height; canvas.height = max; } } } const context2d = canvas.getContext("2d"); if (context2d && backgroundColor) { context2d.fillStyle = backgroundColor; context2d.fillRect(0, 0, canvas.width, canvas.height); } return { canvas, context2d }; } function cloneCanvas(canvas, context) { if (canvas.ownerDocument) { try { const dataURL = canvas.toDataURL(); if (dataURL !== "data:,") { return createImage(dataURL, canvas.ownerDocument); } } catch (error) { context.log.warn("Failed to clone canvas", error); } } const cloned = canvas.cloneNode(false); const ctx = canvas.getContext("2d"); const clonedCtx = cloned.getContext("2d"); try { if (ctx && clonedCtx) { clonedCtx.putImageData( ctx.getImageData(0, 0, canvas.width, canvas.height), 0, 0 ); } return cloned; } catch (error) { context.log.warn("Failed to clone canvas", error); } return cloned; } function cloneIframe(iframe, context) { try { if (iframe?.contentDocument?.body) { return cloneNode(iframe.contentDocument.body, context); } } catch (error) { context.log.warn("Failed to clone iframe", error); } return iframe.cloneNode(false); } function cloneImage(image) { const cloned = image.cloneNode(false); if (image.currentSrc && image.currentSrc !== image.src) { cloned.src = image.currentSrc; cloned.srcset = ""; } if (cloned.loading === "lazy") { cloned.loading = "eager"; } return cloned; } async function cloneVideo(video, context) { if (video.ownerDocument && !video.currentSrc && video.poster) { return createImage(video.poster, video.ownerDocument); } const cloned = video.cloneNode(false); cloned.crossOrigin = "anonymous"; if (video.currentSrc && video.currentSrc !== video.src) { cloned.src = video.currentSrc; } const ownerDocument = cloned.ownerDocument; if (ownerDocument) { let canPlay = true; await loadMedia(cloned, { onError: () => canPlay = false, onWarn: context.log.warn }); if (!canPlay) { if (video.poster) { return createImage(video.poster, video.ownerDocument); } return cloned; } cloned.currentTime = video.currentTime; await new Promise((resolve) => { cloned.addEventListener("seeked", resolve, { once: true }); }); const canvas = ownerDocument.createElement("canvas"); canvas.width = video.offsetWidth; canvas.height = video.offsetHeight; try { const ctx = canvas.getContext("2d"); if (ctx) ctx.drawImage(cloned, 0, 0, canvas.width, canvas.height); } catch (error) { context.log.warn("Failed to clone video", error); if (video.poster) { return createImage(video.poster, video.ownerDocument); } return cloned; } return cloneCanvas(canvas, context); } return cloned; } function cloneElement(node, context) { if (isCanvasElement(node)) { return cloneCanvas(node, context); } if (isIFrameElement(node)) { return cloneIframe(node, context); } if (isImageElement(node)) { return cloneImage(node); } if (isVideoElement(node)) { return cloneVideo(node, context); } return node.cloneNode(false); } function getSandBox(context) { let sandbox = context.sandbox; if (!sandbox) { const { ownerDocument } = context; try { if (ownerDocument) { sandbox = ownerDocument.createElement("iframe"); sandbox.id = `__SANDBOX__-${uuid()}`; sandbox.width = "0"; sandbox.height = "0"; sandbox.style.visibility = "hidden"; sandbox.style.position = "fixed"; ownerDocument.body.appendChild(sandbox); sandbox.contentWindow?.document.write('<!DOCTYPE html><meta charset="UTF-8"><title></title><body>'); context.sandbox = sandbox; } } catch (error) { context.log.warn("Failed to getSandBox", error); } } return sandbox; } const ignoredStyles = [ "width", "height", "-webkit-text-fill-color" ]; const includedAttributes = [ "stroke", "fill" ]; function getDefaultStyle(node, pseudoElement, context) { const { defaultComputedStyles } = context; const nodeName = node.nodeName.toLowerCase(); const isSvgNode = isSVGElementNode(node) && nodeName !== "svg"; const attributes = isSvgNode ? includedAttributes.map((name) => [name, node.getAttribute(name)]).filter(([, value]) => value !== null) : []; const key = [ isSvgNode && "svg", nodeName, attributes.map((name, value) => `${name}=${value}`).join(","), pseudoElement ].filter(Boolean).join(":"); if (defaultComputedStyles.has(key)) return defaultComputedStyles.get(key); const sandbox = getSandBox(context); const sandboxWindow = sandbox?.contentWindow; if (!sandboxWindow) return /* @__PURE__ */ new Map(); const sandboxDocument = sandboxWindow?.document; let root; let el; if (isSvgNode) { root = sandboxDocument.createElementNS(XMLNS, "svg"); el = root.ownerDocument.createElementNS(root.namespaceURI, nodeName); attributes.forEach(([name, value]) => { el.setAttributeNS(null, name, value); }); root.appendChild(el); } else { root = el = sandboxDocument.createElement(nodeName); } el.textContent = " "; sandboxDocument.body.appendChild(root); const computedStyle = sandboxWindow.getComputedStyle(el, pseudoElement); const styles = /* @__PURE__ */ new Map(); for (let len = computedStyle.length, i = 0; i < len; i++) { const name = computedStyle.item(i); if (ignoredStyles.includes(name)) continue; styles.set(name, computedStyle.getPropertyValue(name)); } sandboxDocument.body.removeChild(root); defaultComputedStyles.set(key, styles); return styles; } function getDiffStyle(style, defaultStyle, includeStyleProperties) { const diffStyle = /* @__PURE__ */ new Map(); const prefixs = []; const prefixTree = /* @__PURE__ */ new Map(); if (includeStyleProperties) { for (const name of includeStyleProperties) { applyTo(name); } } else { for (let len = style.length, i = 0; i < len; i++) { const name = style.item(i); applyTo(name); } } for (let len = prefixs.length, i = 0; i < len; i++) { prefixTree.get(prefixs[i])?.forEach((value, name) => diffStyle.set(name, value)); } function applyTo(name) { const value = style.getPropertyValue(name); const priority = style.getPropertyPriority(name); const subIndex = name.lastIndexOf("-"); const prefix = subIndex > -1 ? name.substring(0, subIndex) : void 0; if (prefix) { let map = prefixTree.get(prefix); if (!map) { map = /* @__PURE__ */ new Map(); prefixTree.set(prefix, map); } map.set(name, [value, priority]); } if (defaultStyle.get(name) === value && !priority) return; if (prefix) { prefixs.push(prefix); } else { diffStyle.set(name, [value, priority]); } } return diffStyle; } function copyCssStyles(node, cloned, isRoot, context) { const { ownerWindow, includeStyleProperties, currentParentNodeStyle } = context; const clonedStyle = cloned.style; const computedStyle = ownerWindow.getComputedStyle(node); const defaultStyle = getDefaultStyle(node, null, context); currentParentNodeStyle?.forEach((_, key) => { defaultStyle.delete(key); }); const style = getDiffStyle(computedStyle, defaultStyle, includeStyleProperties); style.delete("transition-property"); style.delete("all"); style.delete("d"); style.delete("content"); if (isRoot) { style.delete("margin-top"); style.delete("margin-right"); style.delete("margin-bottom"); style.delete("margin-left"); style.delete("margin-block-start"); style.delete("margin-block-end"); style.delete("margin-inline-start"); style.delete("margin-inline-end"); style.set("box-sizing", ["border-box", ""]); } if (style.get("background-clip")?.[0] === "text") { cloned.classList.add("______background-clip--text"); } if (IN_CHROME) { if (!style.has("font-kerning")) style.set("font-kerning", ["normal", ""]); if ((style.get("overflow-x")?.[0] === "hidden" || style.get("overflow-y")?.[0] === "hidden") && style.get("text-overflow")?.[0] === "ellipsis" && node.scrollWidth === node.clientWidth) { style.set("text-overflow", ["clip", ""]); } } for (let len = clonedStyle.length, i = 0; i < len; i++) { clonedStyle.removeProperty(clonedStyle.item(i)); } style.forEach(([value, priority], name) => { clonedStyle.setProperty(name, value, priority); }); return style; } function copyInputValue(node, cloned) { if (isTextareaElement(node) || isInputElement(node) || isSelectElement(node)) { cloned.setAttribute("value", node.value); } } const pseudoClasses = [ ":before", ":after" // ':placeholder', TODO ]; const scrollbarPseudoClasses = [ ":-webkit-scrollbar", ":-webkit-scrollbar-button", // ':-webkit-scrollbar:horizontal', TODO ":-webkit-scrollbar-thumb", ":-webkit-scrollbar-track", ":-webkit-scrollbar-track-piece", // ':-webkit-scrollbar:vertical', TODO ":-webkit-scrollbar-corner", ":-webkit-resizer" ]; function copyPseudoClass(node, cloned, copyScrollbar, context, addWordToFontFamilies) { const { ownerWindow, svgStyleElement, svgStyles, currentNodeStyle } = context; if (!svgStyleElement || !ownerWindow) return; function copyBy(pseudoClass) { const computedStyle = ownerWindow.getComputedStyle(node, pseudoClass); let content = computedStyle.getPropertyValue("content"); if (!content || content === "none") return; addWordToFontFamilies?.(content); content = content.replace(/(')|(")|(counter\(.+\))/g, ""); const klasses = [uuid()]; const defaultStyle = getDefaultStyle(node, pseudoClass, context); currentNodeStyle?.forEach((_, key) => { defaultStyle.delete(key); }); const style = getDiffStyle(computedStyle, defaultStyle, context.includeStyleProperties); style.delete("content"); style.delete("-webkit-locale"); if (style.get("background-clip")?.[0] === "text") { cloned.classList.add("______background-clip--text"); } const cloneStyle = [ `content: '${content}';` ]; style.forEach(([value, priority], name) => { cloneStyle.push(`${name}: ${value}${priority ? " !important" : ""};`); }); if (cloneStyle.length === 1) return; try { cloned.className = [cloned.className, ...klasses].join(" "); } catch (err) { context.log.warn("Failed to copyPseudoClass", err); return; } const cssText = cloneStyle.join("\n "); let allClasses = svgStyles.get(cssText); if (!allClasses) { allClasses = []; svgStyles.set(cssText, allClasses); } allClasses.push(`.${klasses[0]}:${pseudoClass}`); } pseudoClasses.forEach(copyBy); if (copyScrollbar) scrollbarPseudoClasses.forEach(copyBy); } const excludeParentNodes = /* @__PURE__ */ new Set([ "symbol" // test/fixtures/svg.symbol.html ]); async function appendChildNode(node, cloned, child, context, addWordToFontFamilies) { if (isElementNode(child) && (isStyleElement(child) || isScriptElement(child))) return; if (context.filter && !context.filter(child)) return; if (excludeParentNodes.has(cloned.nodeName) || excludeParentNodes.has(child.nodeName)) { context.currentParentNodeStyle = void 0; } else { context.currentParentNodeStyle = context.currentNodeStyle; } const childCloned = await cloneNode(child, context, false, addWordToFontFamilies); if (context.isEnable("restoreScrollPosition")) { restoreScrollPosition(node, childCloned); } cloned.appendChild(childCloned); } async function cloneChildNodes(node, cloned, context, addWordToFontFamilies) { const firstChild = (isElementNode(node) ? node.shadowRoot?.firstChild : void 0) ?? node.firstChild; for (let child = firstChild; child; child = child.nextSibling) { if (isCommentNode(child)) continue; if (isElementNode(child) && isSlotElement(child) && typeof child.assignedNodes === "function") { const nodes = child.assignedNodes(); for (let i = 0; i < nodes.length; i++) { await appendChildNode(node, cloned, nodes[i], context, addWordToFontFamilies); } } else { await appendChildNode(node, cloned, child, context, addWordToFontFamilies); } } } function restoreScrollPosition(node, chlidCloned) { if (!isHTMLElementNode(node) || !isHTMLElementNode(chlidCloned)) return; const { scrollTop, scrollLeft } = node; if (!scrollTop && !scrollLeft) { return; } const { transform } = chlidCloned.style; const matrix = new DOMMatrix(transform); const { a, b, c, d } = matrix; matrix.a = 1; matrix.b = 0; matrix.c = 0; matrix.d = 1; matrix.translateSelf(-scrollLeft, -scrollTop); matrix.a = a; matrix.b = b; matrix.c = c; matrix.d = d; chlidCloned.style.transform = matrix.toString(); } function applyCssStyleWithOptions(cloned, context) { const { backgroundColor, width, height, style: styles } = context; const clonedStyle = cloned.style; if (backgroundColor) clonedStyle.setProperty("background-color", backgroundColor, "important"); if (width) clonedStyle.setProperty("width", `${width}px`, "important"); if (height) clonedStyle.setProperty("height", `${height}px`, "important"); if (styles) { for (const name in styles) clonedStyle[name] = styles[name]; } } const NORMAL_ATTRIBUTE_RE = /^[\w-:]+$/; async function cloneNode(node, context, isRoot = false, addWordToFontFamilies) { const { ownerDocument, ownerWindow, fontFamilies } = context; if (ownerDocument && isTextNode(node)) { if (addWordToFontFamilies && /\S/.test(node.data)) { addWordToFontFamilies(node.data); } return ownerDocument.createTextNode(node.data); } if (ownerDocument && ownerWindow && isElementNode(node) && (isHTMLElementNode(node) || isSVGElementNode(node))) { const cloned2 = await cloneElement(node, context); if (context.isEnable("removeAbnormalAttributes")) { const names = cloned2.getAttributeNames(); for (let len = names.length, i = 0; i < len; i++) { const name = names[i]; if (!NORMAL_ATTRIBUTE_RE.test(name)) { cloned2.removeAttribute(name); } } } const style = context.currentNodeStyle = copyCssStyles(node, cloned2, isRoot, context); if (isRoot) applyCssStyleWithOptions(cloned2, context); let copyScrollbar = false; if (context.isEnable("copyScrollbar")) { const overflow = [ style.get("overflow-x")?.[0], style.get("overflow-y")?.[0] ]; copyScrollbar = overflow.includes("scroll") || (overflow.includes("auto") || overflow.includes("overlay")) && (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth); } const textTransform = style.get("text-transform")?.[0]; const families = splitFontFamily(style.get("font-family")?.[0]); const addWordToFontFamilies2 = families ? (word) => { if (textTransform === "uppercase") { word = word.toUpperCase(); } else if (textTransform === "lowercase") { word = word.toLowerCase(); } else if (textTransform === "capitalize") { word = word[0].toUpperCase() + word.substring(1); } families.forEach((family) => { let fontFamily = fontFamilies.get(family); if (!fontFamily) { fontFamilies.set(family, fontFamily = /* @__PURE__ */ new Set()); } word.split("").forEach((text) => fontFamily.add(text)); }); } : void 0; copyPseudoClass( node, cloned2, copyScrollbar, context, addWordToFontFamilies2 ); copyInputValue(node, cloned2); if (!isVideoElement(node)) { await cloneChildNodes( node, cloned2, context, addWordToFontFamilies2 ); } return cloned2; } const cloned = node.cloneNode(false); await cloneChildNodes(node, cloned, context); return cloned; } function destroyContext(context) { context.ownerDocument = void 0; context.ownerWindow = void 0; context.svgStyleElement = void 0; context.svgDefsElement = void 0; context.svgStyles.clear(); context.defaultComputedStyles.clear(); if (context.sandbox) { try { context.sandbox.remove(); } catch (err) { context.log.warn("Failed to destroyContext", err); } context.sandbox = void 0; } context.workers = []; context.fontFamilies.clear(); context.fontCssTexts.clear(); context.requests.clear(); context.tasks = []; } function baseFetch(options) { const { url, timeout, responseType, ...requestInit } = options; const controller = new AbortController(); const timer = timeout ? setTimeout(() => controller.abort(), timeout) : void 0; return fetch(url, { signal: controller.signal, ...requestInit }).then((response) => { if (!response.ok) { throw new Error("Failed fetch, not 2xx response", { cause: response }); } switch (responseType) { case "arrayBuffer": return response.arrayBuffer(); case "dataUrl": return response.blob().then(blobToDataUrl); case "text": default: return response.text(); } }).finally(() => clearTimeout(timer)); } function contextFetch(context, options) { const { url: rawUrl, requestType = "text", responseType = "text", imageDom } = options; let url = rawUrl; const { timeout, acceptOfImage, requests, fetchFn, fetch: { requestInit, bypassingCache, placeholderImage }, font, workers, fontFamilies } = context; if (requestType === "image" && (IN_SAFARI || IN_FIREFOX)) { context.drawImageCount++; } let request = requests.get(rawUrl); if (!request) { if (bypassingCache) { if (bypassingCache instanceof RegExp && bypassingCache.test(url)) { url += (/\?/.test(url) ? "&" : "?") + (/* @__PURE__ */ new Date()).getTime(); } } const canFontMinify = requestType.startsWith("font") && font && font.minify; const fontTexts = /* @__PURE__ */ new Set(); if (canFontMinify) { const families = requestType.split(";")[1].split(","); families.forEach((family) => { if (!fontFamilies.has(family)) return; fontFamilies.get(family).forEach((text) => fontTexts.add(text)); }); } const needFontMinify = canFontMinify && fontTexts.size; const baseFetchOptions = { url, timeout, responseType: needFontMinify ? "arrayBuffer" : responseType, headers: requestType === "image" ? { accept: acceptOfImage } : void 0, ...requestInit }; request = { type: requestType, resolve: void 0, reject: void 0, response: null }; request.response = (async () => { if (fetchFn && requestType === "image") { const result = await fetchFn(rawUrl); if (result) return result; } if (!IN_SAFARI && rawUrl.startsWith("http") && workers.length) { return new Promise((resolve, reject) => { const worker = workers[requests.size & workers.length - 1]; worker.postMessage({ rawUrl, ...baseFetchOptions }); request.resolve = resolve; request.reject = reject; }); } return baseFetch(baseFetchOptions); })().catch((error) => { requests.delete(rawUrl); if (requestType === "image" && placeholderImage) { context.log.warn("Failed to fetch image base64, trying to use placeholder image", url); return typeof placeholderImage === "string" ? placeholderImage : placeholderImage(imageDom); } throw error; }); requests.set(rawUrl, request); } return request.response; } async function replaceCssUrlToDataUrl(cssText, baseUrl, context, isImage) { if (!hasCssUrl(cssText)) return cssText; for (const [rawUrl, url] of parseCssUrls(cssText, baseUrl)) { try { const dataUrl = await contextFetch( context, { url, requestType: isImage ? "image" : "text", responseType: "dataUrl" } ); cssText = cssText.replace(toRE(rawUrl), `$1${dataUrl}$3`); } catch (error) { context.log.warn("Failed to fetch css data url", rawUrl, error); } } return cssText; } function hasCssUrl(cssText) { return /url\((['"]?)([^'"]+?)\1\)/.test(cssText); } const URL_RE = /url\((['"]?)([^'"]+?)\1\)/g; function parseCssUrls(cssText, baseUrl) { const result = []; cssText.replace(URL_RE, (raw, quotation, url) => { result.push([url, resolveUrl(url, baseUrl)]); return raw; }); return result.filter(([url]) => !isDataUrl(url)); } function toRE(url) { const escaped = url.replace(/([.*+?^${}()|\[\]\/\\])/g, "\\$1"); return new RegExp(`(url\\(['"]?)(${escaped})(['"]?\\))`, "g"); } const properties = [ "background-image", "border-image-source", "-webkit-border-image", "-webkit-mask-image", "list-style-image" ]; function embedCssStyleImage(style, context) { return properties.map((property) => { const value = style.getPropertyValue(property); if (!value || value === "none") { return null; } if (IN_SAFARI || IN_FIREFOX) { context.drawImageCount++; } return replaceCssUrlToDataUrl(value, null, context, true).then((newValue) => { if (!newValue || value === newValue) return; style.setProperty( property, newValue, style.getPropertyPriority(property) ); }); }).filter(Boolean); } function embedImageElement(cloned, context) { if (isImageElement(cloned)) { const originalSrc = cloned.currentSrc || cloned.src; if (!isDataUrl(originalSrc)) { return [ contextFetch(context, { url: originalSrc, imageDom: cloned, requestType: "image", responseType: "dataUrl" }).then((url) => { if (!url) return; cloned.srcset = ""; cloned.dataset.originalSrc = originalSrc; cloned.src = url || ""; }) ]; } if (IN_SAFARI || IN_FIREFOX) { context.drawImageCount++; } } else if (isSVGElementNode(cloned) && !isDataUrl(cloned.href.baseVal)) { const originalSrc = cloned.href.baseVal; return [ contextFetch(context, { url: originalSrc, imageDom: cloned, requestType: "image", responseType: "dataUrl" }).then((url) => { if (!url) return; cloned.dataset.originalSrc = originalSrc; cloned.href.baseVal = url || ""; }) ]; } return []; } function embedSvgUse(cloned, context) { const { ownerDocument, svgDefsElement } = context; const href = cloned.getAttribute("href") ?? cloned.getAttribute("xlink:href"); if (!href) return []; const [svgUrl, id] = href.split("#"); if (id) { const query = `#${id}`; const definition = ownerDocument?.querySelector(`svg ${query}`); if (svgUrl) { cloned.setAttribute("href", query); } if (svgDefsElement?.querySelector(query)) return []; if (definition) { svgDefsElement?.appendChild(definition.cloneNode(true)); return []; } else if (svgUrl) { return [ contextFetch(context, { url: svgUrl, responseType: "text" }).then((svgData) => { svgDefsElement?.insertAdjacentHTML("beforeend", svgData); }) ]; } } return []; } function embedNode(cloned, context) { const { tasks } = context; if (isElementNode(cloned)) { if (isImageElement(cloned) || isSVGImageElementNode(cloned)) { tasks.push(...embedImageElement(cloned, context)); } if (isSVGUseElementNode(cloned)) { tasks.push(...embedSvgUse(cloned, context)); } } if (isHTMLElementNode(cloned)) { tasks.push(...embedCssStyleImage(cloned.style, context)); } cloned.childNodes.forEach((child) => { embedNode(child, context); }); } async function embedWebFont(clone, context) { const { ownerDocument, svgStyleElement, fontFamilies, fontCssTexts, tasks, font } = context; if (!ownerDocument || !svgStyleElement || !fontFamilies.size) { return; } if (font && font.cssText) { const cssText = filterPreferredFormat(font.cssText, context); svgStyleElement.appendChild(ownerDocument.createTextNode(`${cssText} `)); } else { const styleSheets = Array.from(ownerDocument.styleSheets).filter((styleSheet) => { try { return "cssRules" in styleSheet && Boolean(styleSheet.cssRules.length); } catch (error) { context.log.warn(`Error while reading CSS rules from ${styleSheet.href}`, error); return false; } }); await Promise.all( styleSheets.flatMap((styleSheet) => { return Array.from(styleSheet.cssRules).map(async (cssRule, index) => { if (isCSSImportRule(cssRule)) { let importIndex = index + 1; const baseUrl = cssRule.href; let cssText = ""; try { cssText = await contextFetch(context, { url: baseUrl, requestType: "text", responseType: "text" }); } catch (error) { context.log.warn(`Error fetch remote css import from ${baseUrl}`, error); } const replacedCssText = cssText.replace( URL_RE, (raw, quotation, url) => raw.replace(url, resolveUrl(url, baseUrl)) ); for (const rule of parseCss(replacedCssText)) { try { styleSheet.insertRule( rule, rule.startsWith("@import") ? importIndex += 1 : styleSheet.cssRules.length ); } catch (error) { context.log.warn("Error inserting rule from remote css import", { rule, error }); } } } }); }) ); const cssRules = styleSheets.flatMap((styleSheet) => Array.from(styleSheet.cssRules)); cssRules.filter((cssRule) => isCssFontFaceRule(cssRule) && hasCssUrl(cssRule.style.getPropertyValue("src")) && splitFontFamily(cssRule.style.getPropertyValue("font-family"))?.some((val) => fontFamilies.has(val))).forEach((value) => { const rule = value; const cssText = fontCssTexts.get(rule.cssText); if (cssText) { svgStyleElement.appendChild(ownerDocument.createTextNode(`${cssText} `)); } else { tasks.push( replaceCssUrlToDataUrl( rule.cssText, rule.parentStyleSheet ? rule.parentStyleSheet.href : null, context ).then((cssText2) => { cssText2 = filterPreferredFormat(cssText2, context); fontCssTexts.set(rule.cssText, cssText2); svgStyleElement.appendChild(ownerDocument.createTextNode(`${cssText2} `)); }) ); } }); } } const COMMENTS_RE = /(\/\*[\s\S]*?\*\/)/g; const KEYFRAMES_RE = /((@.*?keyframes [\s\S]*?){([\s\S]*?}\s*?)})/gi; function parseCss(source) { if (source == null) return []; const result = []; let cssText = source.replace(COMMENTS_RE, ""); while (true) { const matches = KEYFRAMES_RE.exec(cssText); if (!matches) break; result.push(matches[0]); } cssText = cssText.replace(KEYFRAMES_RE, ""); const IMPORT_RE = /@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi; const UNIFIED_RE = new RegExp( // eslint-disable-next-line "((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})", "gi" ); while (true) { let matches = IMPORT_RE.exec(cssText); if (!matches) { matches = UNIFIED_RE.exec(cssText); if (!matches) { break; } else { IMPORT_RE.lastIndex = UNIFIED_RE.lastIndex; } } else { UNIFIED_RE.lastIndex = IMPORT_RE.lastIndex; } result.push(matches[0]); } return result; } const URL_WITH_FORMAT_RE = /url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g; const FONT_SRC_RE = /src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g; function filterPreferredFormat(str, context) { const { font } = context; const preferredFormat = font ? font?.preferredFormat : void 0; return preferredFormat ? str.replace(FONT_SRC_RE, (match) => { while (true) { const [src, , format] = URL_WITH_FORMAT_RE.exec(match) || []; if (!format) return ""; if (format === preferredFormat) return `src: ${src};`; } }) : str; } async function domToForeignObjectSvg(node, options) { const context = await orCreateContext(node, options); if (isElementNode(context.node) && isSVGElementNode(context.node)) return context.node; const { ownerDocument, log, tasks, svgStyleElement, svgDefsElement, svgStyles, font, progress, autoDestruct, onCloneNode, onEmbedNode, onCreateForeignObjectSvg } = context; log.time("clone node"); const clone = await cloneNode(context.node, context, true); if (svgStyleElement && ownerDocument) { let allCssText = ""; svgStyles.forEach((klasses, cssText) => { allCssText += `${klasses.join(",\n")} { ${cssText} } `; }); svgStyleElement.appendChild(ownerDocument.createTextNode(allCssText)); } log.timeEnd("clone node"); await onCloneNode?.(clone); if (font !== false && isElementNode(clone)) { log.time("embed web font"); await embedWebFont(clone, context); log.timeEnd("embed web font"); } log.time("embed node"); embedNode(clone, context); const count = tasks.length; let current = 0; const runTask = async () => { while (true) { const task = tasks.pop(); if (!task) break; try { await task; } catch (error) { context.log.warn("Failed to run task", error); } progress?.(++current, count); } }; progress?.(current, count); await Promise.all([...Array.from({ length: 4 })].map(runTask)); log.timeEnd("embed node"); await onEmbedNode?.(clone); const svg = createForeignObjectSvg(clone, context); svgDefsElement && svg.insertBefore(svgDefsElement, svg.children[0]); svgStyleElement && svg.insertBefore(svgStyleElement, svg.children[0]); autoDestruct && destroyContext(context); await onCreateForeignObjectSvg?.(svg); return svg; } function createForeignObjectSvg(clone, context) { const { width, height } = context; const svg = createSvg(width, height, clone.ownerDocument); const foreignObject = svg.ownerDocument.createElementNS(svg.namespaceURI, "foreignObject"); foreignObject.setAttributeNS(null, "x", "0%"); foreignObject.setAttributeNS(null, "y", "0%"); foreignObject.setAttributeNS(null, "width", "100%"); foreignObject.setAttributeNS(null, "height", "100%"); foreignObject.append(clone); svg.appendChild(foreignObject); return svg; } async function domToCanvas(node, options) { const context = await orCreateContext(node, options); const svg = await domToForeignObjectSvg(context); const dataUrl = svgToDataUrl(svg, context.isEnable("removeControlCharacter")); if (!context.autoDestruct) { context.svgStyleElement = createStyleElement(context.ownerDocument); context.svgDefsElement = context.ownerDocument?.createElementNS(XMLNS, "defs"); context.svgStyles.clear(); } const image = createImage(dataUrl, svg.ownerDocument); return await imageToCanvas(image, context); } async function domToBlob(node, options) { const context = await orCreateContext(node, options); const { log, type, quality, dpi } = context; const canvas = await domToCanvas(context); log.time("canvas to blob"); const blob = await canvasToBlob(canvas, type, quality); if (["image/png", "image/jpeg"].includes(type) && dpi) { const arrayBuffer = await blobToArrayBuffer(blob.slice(0, 33)); let uint8Array = new Uint8Array(arrayBuffer); if (type === "image/png") { uint8Array = changePngDpi(uint8Array, dpi); } else if (type === "image/jpeg") { uint8Array = changeJpegDpi(uint8Array, dpi); } log.timeEnd("canvas to blob"); return new Blob([uint8Array, blob.s