html2canvas-pro 2.5.4 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/html2canvas-pro.cjs +350 -77
- package/dist/html2canvas-pro.cjs.map +1 -1
- package/dist/html2canvas-pro.esm.js +350 -77
- package/dist/html2canvas-pro.esm.js.map +1 -1
- package/dist/html2canvas-pro.js +350 -77
- package/dist/html2canvas-pro.js.map +1 -1
- package/dist/html2canvas-pro.min.js +6 -6
- package/dist/types/core/validator.d.ts +9 -1
- package/dist/types/dom/document-cloner.d.ts +7 -0
- package/dist/types/dom/element-container.d.ts +6 -0
- package/dist/types/dom/filter-reference.d.ts +35 -0
- package/dist/types/dom/iframe-mount.d.ts +1 -1
- package/dist/types/dom/pseudo-content.d.ts +2 -2
- package/dist/types/render/canvas/filter-surface.d.ts +21 -6
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Next generation JavaScript screenshot tool.
|
|
|
12
12
|
<a href="https://npm.im/html2canvas-pro"><img src="https://badgen.net/npm/v/html2canvas-pro" alt="npm version"></a>
|
|
13
13
|
<a href="http://npm.im/html2canvas-pro"><img src="https://badgen.net/npm/dm/html2canvas-pro" alt="npm downloads"></a>
|
|
14
14
|
<a href="https://www.jsdelivr.com/package/npm/html2canvas-pro"><img src="https://data.jsdelivr.com/v1/package/npm/html2canvas-pro/badge" /></a>
|
|
15
|
-
<a href="https://deepwiki.com/yorickshan/html2canvas-pro"><img src="
|
|
15
|
+
<a href="https://deepwiki.com/yorickshan/html2canvas-pro"><img src=".github/assets/deepwiki-badge.svg" alt="Ask DeepWiki"></a>
|
|
16
16
|
</p>
|
|
17
17
|
<p align="center">
|
|
18
18
|
<a href="https://yorickshan.github.io/html2canvas-pro/getting-started.html">Getting Started</a> ·
|
package/dist/html2canvas-pro.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* html2canvas-pro 2.
|
|
2
|
+
* html2canvas-pro 2.6.1 <https://yorickshan.github.io/html2canvas-pro/>
|
|
3
3
|
* Copyright (c) 2024-present yorickshan and html2canvas-pro contributors
|
|
4
4
|
* Released under MIT License
|
|
5
5
|
*/
|
|
@@ -246,6 +246,30 @@ var Validator = class {
|
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
248
|
/**
|
|
249
|
+
* Validate the backgroundColor option
|
|
250
|
+
*
|
|
251
|
+
* @param backgroundColor - CSS color string or null (transparent)
|
|
252
|
+
* @returns Validation result
|
|
253
|
+
*/
|
|
254
|
+
validateBackgroundColor(backgroundColor) {
|
|
255
|
+
if (backgroundColor === null) return {
|
|
256
|
+
valid: true,
|
|
257
|
+
sanitized: null
|
|
258
|
+
};
|
|
259
|
+
if (typeof backgroundColor !== "string" || backgroundColor.length === 0) return {
|
|
260
|
+
valid: false,
|
|
261
|
+
error: "backgroundColor must be a CSS color string or null"
|
|
262
|
+
};
|
|
263
|
+
if (/[^a-zA-Z0-9%(),.#\s/-]/.test(backgroundColor)) return {
|
|
264
|
+
valid: false,
|
|
265
|
+
error: `backgroundColor contains characters that cannot appear in a CSS color: ${backgroundColor}`
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
valid: true,
|
|
269
|
+
sanitized: backgroundColor
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
249
273
|
* Validate image timeout
|
|
250
274
|
*
|
|
251
275
|
* @param timeout - Timeout in milliseconds
|
|
@@ -390,6 +414,10 @@ var Validator = class {
|
|
|
390
414
|
const nonceResult = this.validateCspNonce(options.cspNonce);
|
|
391
415
|
if (!nonceResult.valid) errors.push(`CSP nonce: ${nonceResult.error}`);
|
|
392
416
|
}
|
|
417
|
+
if (options.backgroundColor !== void 0) {
|
|
418
|
+
const colorResult = this.validateBackgroundColor(options.backgroundColor);
|
|
419
|
+
if (!colorResult.valid) errors.push(`backgroundColor: ${colorResult.error}`);
|
|
420
|
+
}
|
|
393
421
|
if (this.config.customValidator) {
|
|
394
422
|
const customResult = this.config.customValidator(options, "options");
|
|
395
423
|
if (!customResult.valid) errors.push(`Custom validation: ${customResult.error}`);
|
|
@@ -7889,19 +7917,19 @@ const PSEUDO_HIDE_ELEMENT_STYLE = `{
|
|
|
7889
7917
|
}`;
|
|
7890
7918
|
/**
|
|
7891
7919
|
* Inject the stylesheet that hides pseudo-element surrogate containers.
|
|
7892
|
-
*
|
|
7920
|
+
* Added to the cloned <head> so the surrogates themselves never render.
|
|
7893
7921
|
*/
|
|
7894
|
-
const createPseudoHideStyles = (
|
|
7895
|
-
createStyles(
|
|
7922
|
+
const createPseudoHideStyles = (parent, cspNonce) => {
|
|
7923
|
+
createStyles(parent, `.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
|
|
7896
7924
|
.${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}${PSEUDO_AFTER}${PSEUDO_HIDE_ELEMENT_STYLE}`, cspNonce);
|
|
7897
7925
|
};
|
|
7898
|
-
const createStyles = (
|
|
7899
|
-
const document =
|
|
7926
|
+
const createStyles = (parent, styles, cspNonce) => {
|
|
7927
|
+
const document = parent.ownerDocument;
|
|
7900
7928
|
if (document) {
|
|
7901
7929
|
const style = document.createElement("style");
|
|
7902
7930
|
style.textContent = styles;
|
|
7903
7931
|
if (cspNonce) style.nonce = cspNonce;
|
|
7904
|
-
|
|
7932
|
+
parent.appendChild(style);
|
|
7905
7933
|
}
|
|
7906
7934
|
};
|
|
7907
7935
|
/**
|
|
@@ -8021,9 +8049,20 @@ const createIFrameContainer = (ownerDocument, bounds, customContainer) => {
|
|
|
8021
8049
|
cloneIframeContainer.style.position = "fixed";
|
|
8022
8050
|
cloneIframeContainer.style.left = "-10000px";
|
|
8023
8051
|
cloneIframeContainer.style.top = "0px";
|
|
8024
|
-
cloneIframeContainer.style.border = "0";
|
|
8025
8052
|
cloneIframeContainer.width = bounds.width.toString();
|
|
8026
8053
|
cloneIframeContainer.height = bounds.height.toString();
|
|
8054
|
+
const contentBox = [
|
|
8055
|
+
["width", `${bounds.width}px`],
|
|
8056
|
+
["height", `${bounds.height}px`],
|
|
8057
|
+
["min-width", "0px"],
|
|
8058
|
+
["max-width", "none"],
|
|
8059
|
+
["min-height", "0px"],
|
|
8060
|
+
["max-height", "none"],
|
|
8061
|
+
["box-sizing", "content-box"],
|
|
8062
|
+
["padding", "0px"],
|
|
8063
|
+
["border", "0"]
|
|
8064
|
+
];
|
|
8065
|
+
for (const [property, value] of contentBox) cloneIframeContainer.style.setProperty(property, value, "important");
|
|
8027
8066
|
cloneIframeContainer.scrolling = "no";
|
|
8028
8067
|
cloneIframeContainer.setAttribute(IGNORE_ATTRIBUTE, "true");
|
|
8029
8068
|
(customContainer || ownerDocument.body).appendChild(cloneIframeContainer);
|
|
@@ -8066,9 +8105,9 @@ const isSheetFullyApplied = (sheet, depth = 0) => {
|
|
|
8066
8105
|
if (!rules) return true;
|
|
8067
8106
|
for (let i = 0; i < rules.length; i++) {
|
|
8068
8107
|
const rule = rules[i];
|
|
8069
|
-
if (rule
|
|
8070
|
-
|
|
8071
|
-
|
|
8108
|
+
if (!rule || !("styleSheet" in rule)) continue;
|
|
8109
|
+
const inner = rule.styleSheet;
|
|
8110
|
+
if (!inner || !isSheetFullyApplied(inner, depth + 1)) return false;
|
|
8072
8111
|
}
|
|
8073
8112
|
return true;
|
|
8074
8113
|
};
|
|
@@ -8110,7 +8149,7 @@ const collectLinks = (root, out) => {
|
|
|
8110
8149
|
* remain as the settle path for plain <link> fetches.
|
|
8111
8150
|
* @internal – exported for testing only.
|
|
8112
8151
|
*/
|
|
8113
|
-
const stylesheetsReady = (documentClone, logger, signal) => {
|
|
8152
|
+
const stylesheetsReady = (documentClone, logger, signal, timeoutMs = STYLESHEET_READY_TIMEOUT_MS) => {
|
|
8114
8153
|
if (signal?.aborted) return Promise.resolve();
|
|
8115
8154
|
const pending = [];
|
|
8116
8155
|
const links = [];
|
|
@@ -8140,8 +8179,8 @@ const stylesheetsReady = (documentClone, logger, signal) => {
|
|
|
8140
8179
|
const timer = setInterval(() => {
|
|
8141
8180
|
elapsed += 50;
|
|
8142
8181
|
if (link.sheet) settle();
|
|
8143
|
-
else if (elapsed >=
|
|
8144
|
-
logger?.warn(`Stylesheet ${href} did not finish loading within ${
|
|
8182
|
+
else if (elapsed >= timeoutMs) {
|
|
8183
|
+
logger?.warn(`Stylesheet ${href} did not finish loading within ${timeoutMs / 1e3}s; continuing with a possibly unstyled clone`);
|
|
8145
8184
|
settle();
|
|
8146
8185
|
}
|
|
8147
8186
|
}, 50);
|
|
@@ -8158,8 +8197,8 @@ const stylesheetsReady = (documentClone, logger, signal) => {
|
|
|
8158
8197
|
const timer = setInterval(() => {
|
|
8159
8198
|
elapsed += 50;
|
|
8160
8199
|
const currentSheet = style.sheet;
|
|
8161
|
-
if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >=
|
|
8162
|
-
if (elapsed >=
|
|
8200
|
+
if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >= timeoutMs) {
|
|
8201
|
+
if (elapsed >= timeoutMs && !isSheetFullyApplied(currentSheet ?? null)) logger?.warn(`@import chain in an inline <style> did not finish loading within ${timeoutMs / 1e3}s; continuing with a possibly unstyled clone`);
|
|
8163
8202
|
clearInterval(timer);
|
|
8164
8203
|
resolve();
|
|
8165
8204
|
}
|
|
@@ -8372,6 +8411,7 @@ var DocumentCloner = class {
|
|
|
8372
8411
|
}
|
|
8373
8412
|
this.documentElement = this.cloneNode(element.ownerDocument.documentElement, false);
|
|
8374
8413
|
this.appendDocumentAdoptedStyles(element.ownerDocument);
|
|
8414
|
+
this.appendPseudoHideStyles();
|
|
8375
8415
|
}
|
|
8376
8416
|
/**
|
|
8377
8417
|
* Re-materialise the source document's constructable stylesheets
|
|
@@ -8391,6 +8431,16 @@ var DocumentCloner = class {
|
|
|
8391
8431
|
const style = createAdoptedStylesElement(this.documentElement.ownerDocument ?? ownerDocument, sheets, this.options.cspNonce);
|
|
8392
8432
|
if (style) head.appendChild(style);
|
|
8393
8433
|
}
|
|
8434
|
+
/**
|
|
8435
|
+
* Add the rules that hide the source ::before / ::after (the clone draws
|
|
8436
|
+
* them as surrogate elements) to the clone's <head>. Inside <body> the
|
|
8437
|
+
* <style> would be one of its children, and `body > :first-child`,
|
|
8438
|
+
* `:nth-child()` and sibling selectors would match the wrong elements.
|
|
8439
|
+
*/
|
|
8440
|
+
appendPseudoHideStyles() {
|
|
8441
|
+
const head = this.documentElement.getElementsByTagName("head").item(0);
|
|
8442
|
+
createPseudoHideStyles(head ?? this.documentElement, this.options.cspNonce);
|
|
8443
|
+
}
|
|
8394
8444
|
toIFrame(ownerDocument, windowSize) {
|
|
8395
8445
|
return mountCloneInIFrame(this.context, ownerDocument, windowSize, {
|
|
8396
8446
|
documentElement: this.documentElement,
|
|
@@ -8526,7 +8576,6 @@ var DocumentCloner = class {
|
|
|
8526
8576
|
const style = window.getComputedStyle(node);
|
|
8527
8577
|
const checkPseudoElements = canHavePseudoElements(node);
|
|
8528
8578
|
if (this.referenceElement === node && isHTMLElementNode(clone)) this.clonedReferenceElement = clone;
|
|
8529
|
-
if (isBodyElement(clone)) createPseudoHideStyles(clone, this.options.cspNonce);
|
|
8530
8579
|
const counters = this.counters.parse(new CSSParsedCounterDeclaration(this.context, style));
|
|
8531
8580
|
if (checkPseudoElements) {
|
|
8532
8581
|
const styleBefore = window.getComputedStyle(node, ":before");
|
|
@@ -8650,6 +8699,115 @@ var DOMNormalizer = class {
|
|
|
8650
8699
|
}
|
|
8651
8700
|
};
|
|
8652
8701
|
//#endregion
|
|
8702
|
+
//#region src/dom/filter-reference.ts
|
|
8703
|
+
const SVG_NS$1 = "http://www.w3.org/2000/svg";
|
|
8704
|
+
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
8705
|
+
const REGION_DEFAULTS = {
|
|
8706
|
+
x: "-10%",
|
|
8707
|
+
y: "-10%",
|
|
8708
|
+
width: "120%",
|
|
8709
|
+
height: "120%"
|
|
8710
|
+
};
|
|
8711
|
+
const PRESENTATION_PROPERTIES = [
|
|
8712
|
+
"color-interpolation-filters",
|
|
8713
|
+
"flood-color",
|
|
8714
|
+
"flood-opacity",
|
|
8715
|
+
"lighting-color"
|
|
8716
|
+
];
|
|
8717
|
+
const REFERENCE_RE = /^(["']?)(#[^"'\s()]+)\1$/;
|
|
8718
|
+
const URL_RE = /url\(\s*([^)]*?)\s*\)/g;
|
|
8719
|
+
/** The `#id` of a same-document url() body (`"#id"`, `'#id'` or `#id`), or null. */
|
|
8720
|
+
const parseFilterReference = (body) => {
|
|
8721
|
+
const match = REFERENCE_RE.exec(body.trim());
|
|
8722
|
+
return match ? match[2] : null;
|
|
8723
|
+
};
|
|
8724
|
+
/**
|
|
8725
|
+
* Resolve every same-document url() reference of a computed filter value.
|
|
8726
|
+
* References that cannot be expressed in the serialized SVG are left out, so
|
|
8727
|
+
* the compositor rejects the chain and the subtree keeps the legacy renderer.
|
|
8728
|
+
*/
|
|
8729
|
+
function resolveFilterReferences(element, filter, box) {
|
|
8730
|
+
if (!filter || filter.indexOf("url(") === -1) return void 0;
|
|
8731
|
+
const references = /* @__PURE__ */ new Map();
|
|
8732
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8733
|
+
for (const match of filter.matchAll(URL_RE)) {
|
|
8734
|
+
const ref = parseFilterReference(match[1]);
|
|
8735
|
+
if (!ref || seen.has(ref)) continue;
|
|
8736
|
+
seen.add(ref);
|
|
8737
|
+
const reference = resolveFilterReference(element.ownerDocument, ref, box);
|
|
8738
|
+
if (reference) references.set(ref, reference);
|
|
8739
|
+
}
|
|
8740
|
+
return references;
|
|
8741
|
+
}
|
|
8742
|
+
function resolveFilterReference(document, ref, box) {
|
|
8743
|
+
const source = document.getElementById(ref.slice(1));
|
|
8744
|
+
if (!source || source.namespaceURI !== SVG_NS$1 || source.localName !== "filter") return null;
|
|
8745
|
+
if (!isSelfContained(source)) return null;
|
|
8746
|
+
const region = filterRegion(source, box);
|
|
8747
|
+
if (!region) return null;
|
|
8748
|
+
const copy = source.cloneNode(true);
|
|
8749
|
+
inlinePresentation(source, copy);
|
|
8750
|
+
copy.removeAttribute("id");
|
|
8751
|
+
copy.setAttribute("filterUnits", "userSpaceOnUse");
|
|
8752
|
+
Object.keys(region).forEach((name) => copy.setAttribute(name, String(region[name])));
|
|
8753
|
+
const outset = Math.max(0, -region.x, -region.y, region.x + region.width - box.width, region.y + region.height - box.height);
|
|
8754
|
+
return {
|
|
8755
|
+
element: copy,
|
|
8756
|
+
region,
|
|
8757
|
+
outset: Math.ceil(outset)
|
|
8758
|
+
};
|
|
8759
|
+
}
|
|
8760
|
+
const hasHref = (node) => node.hasAttribute("href") || node.hasAttributeNS(XLINK_NS, "href");
|
|
8761
|
+
function isSelfContained(source) {
|
|
8762
|
+
if (source.getAttribute("primitiveUnits") === "objectBoundingBox" || hasHref(source)) return false;
|
|
8763
|
+
for (const primitive of Array.from(source.querySelectorAll("*"))) {
|
|
8764
|
+
if (primitive.localName === "feImage" || hasHref(primitive)) return false;
|
|
8765
|
+
for (const name of [
|
|
8766
|
+
"x",
|
|
8767
|
+
"y",
|
|
8768
|
+
"width",
|
|
8769
|
+
"height"
|
|
8770
|
+
]) if ((primitive.getAttribute(name) ?? "").indexOf("%") !== -1) return false;
|
|
8771
|
+
}
|
|
8772
|
+
return true;
|
|
8773
|
+
}
|
|
8774
|
+
function filterRegion(source, box) {
|
|
8775
|
+
const boundingBox = source.getAttribute("filterUnits") !== "userSpaceOnUse";
|
|
8776
|
+
const read = (name, size) => {
|
|
8777
|
+
const raw = (source.getAttribute(name) ?? (boundingBox ? REGION_DEFAULTS[name] : "")).trim();
|
|
8778
|
+
if (raw === "") return null;
|
|
8779
|
+
const percentage = raw.endsWith("%");
|
|
8780
|
+
const value = Number(percentage ? raw.slice(0, -1) : raw.replace(/px$/, ""));
|
|
8781
|
+
if (!Number.isFinite(value)) return null;
|
|
8782
|
+
if (boundingBox) return (percentage ? value / 100 : value) * size;
|
|
8783
|
+
return percentage ? null : value;
|
|
8784
|
+
};
|
|
8785
|
+
const x = read("x", box.width);
|
|
8786
|
+
const y = read("y", box.height);
|
|
8787
|
+
const width = read("width", box.width);
|
|
8788
|
+
const height = read("height", box.height);
|
|
8789
|
+
if (x === null || y === null || width === null || height === null || width <= 0 || height <= 0) return null;
|
|
8790
|
+
return {
|
|
8791
|
+
x,
|
|
8792
|
+
y,
|
|
8793
|
+
width,
|
|
8794
|
+
height
|
|
8795
|
+
};
|
|
8796
|
+
}
|
|
8797
|
+
function inlinePresentation(source, copy) {
|
|
8798
|
+
const view = source.ownerDocument.defaultView;
|
|
8799
|
+
if (!view) return;
|
|
8800
|
+
const sources = [source, ...Array.from(source.querySelectorAll("*"))];
|
|
8801
|
+
const copies = [copy, ...Array.from(copy.querySelectorAll("*"))];
|
|
8802
|
+
sources.forEach((node, index) => {
|
|
8803
|
+
const style = view.getComputedStyle(node);
|
|
8804
|
+
for (const property of PRESENTATION_PROPERTIES) {
|
|
8805
|
+
const value = style.getPropertyValue(property);
|
|
8806
|
+
if (value) copies[index]?.setAttribute(property, value);
|
|
8807
|
+
}
|
|
8808
|
+
});
|
|
8809
|
+
}
|
|
8810
|
+
//#endregion
|
|
8653
8811
|
//#region src/dom/element-container.ts
|
|
8654
8812
|
var ElementContainer = class {
|
|
8655
8813
|
context;
|
|
@@ -8663,6 +8821,11 @@ var ElementContainer = class {
|
|
|
8663
8821
|
* bounds to leave a matching gap (issue #227).
|
|
8664
8822
|
*/
|
|
8665
8823
|
legendBounds;
|
|
8824
|
+
/**
|
|
8825
|
+
* Same-document `filter: url(#id)` references, resolved while the cloned
|
|
8826
|
+
* DOM is available. The surface compositor renders them in an SVG pass.
|
|
8827
|
+
*/
|
|
8828
|
+
filterReferences;
|
|
8666
8829
|
createsStackingContext = false;
|
|
8667
8830
|
createsRealStackingContext = false;
|
|
8668
8831
|
isListOwner = false;
|
|
@@ -8687,6 +8850,7 @@ var ElementContainer = class {
|
|
|
8687
8850
|
this.bounds = new Bounds(this.bounds.left, borderBoxTop, this.bounds.width, this.bounds.height - (borderBoxTop - this.bounds.top));
|
|
8688
8851
|
}
|
|
8689
8852
|
}
|
|
8853
|
+
this.filterReferences = resolveFilterReferences(element, this.styles.filter, this.bounds);
|
|
8690
8854
|
if (isDebugging(element, 4)) this.debugRender = true;
|
|
8691
8855
|
}
|
|
8692
8856
|
/**
|
|
@@ -9847,7 +10011,7 @@ const parseStackTree = (parent, stackingContext, realStackingContext, listItems)
|
|
|
9847
10011
|
if (treatAsRealStackingContext || createsStackingContext) {
|
|
9848
10012
|
const parentStack = treatAsRealStackingContext || child.styles.isPositioned() ? realStackingContext : stackingContext;
|
|
9849
10013
|
const stack = new StackingContext(paintContainer);
|
|
9850
|
-
if (child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
|
|
10014
|
+
if (treatAsRealStackingContext || child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
|
|
9851
10015
|
const order = child.styles.zIndex.order;
|
|
9852
10016
|
if (order < 0) {
|
|
9853
10017
|
let index = 0;
|
|
@@ -10202,6 +10366,13 @@ const parseFilterFunction = (name, body) => {
|
|
|
10202
10366
|
value: Number.parseFloat(numeric) * (percentage ? 1 / 100 : 1)
|
|
10203
10367
|
};
|
|
10204
10368
|
}
|
|
10369
|
+
if (name === "url") {
|
|
10370
|
+
const ref = parseFilterReference(body);
|
|
10371
|
+
return ref ? {
|
|
10372
|
+
kind: "url",
|
|
10373
|
+
ref
|
|
10374
|
+
} : null;
|
|
10375
|
+
}
|
|
10205
10376
|
return null;
|
|
10206
10377
|
};
|
|
10207
10378
|
/** Split on whitespace outside parentheses so rgba(...) colours stay one token. */
|
|
@@ -10257,22 +10428,25 @@ const nativeFilterString = (filter, scale) => filter.functions.map((fn) => {
|
|
|
10257
10428
|
case "blur": return `blur(${fn.value * scale}px)`;
|
|
10258
10429
|
case "drop-shadow": return `drop-shadow(${fn.x * scale}px ${fn.y * scale}px ${fn.blur * scale}px ${fn.color})`;
|
|
10259
10430
|
case "unitless": return fn.name === "hue-rotate" ? `hue-rotate(${fn.value}deg)` : `${fn.name}(${fn.value})`;
|
|
10431
|
+
case "url": return `url("${fn.ref}")`;
|
|
10260
10432
|
}
|
|
10261
10433
|
}).join(" ");
|
|
10262
10434
|
/**
|
|
10263
10435
|
* Outset (in CSS pixels) a surface must grow so blur/shadow spillover is not
|
|
10264
10436
|
* cropped. Unitless functions never spread beyond the bounds. Consecutive
|
|
10265
|
-
* blurs compound, so their radii sum.
|
|
10437
|
+
* blurs compound, so their radii sum. A url() reference can paint up to its
|
|
10438
|
+
* filter region, which may extend beyond the border box.
|
|
10266
10439
|
*/
|
|
10267
|
-
function filterOutset(filter) {
|
|
10440
|
+
function filterOutset(filter, references) {
|
|
10268
10441
|
let blurSum = 0;
|
|
10269
10442
|
let maxShadowOffset = 0;
|
|
10443
|
+
let referenceOutset = 0;
|
|
10270
10444
|
for (const fn of filter.functions) if (fn.kind === "blur") blurSum += fn.value;
|
|
10271
10445
|
else if (fn.kind === "drop-shadow") {
|
|
10272
10446
|
blurSum += fn.blur;
|
|
10273
10447
|
maxShadowOffset = Math.max(maxShadowOffset, Math.abs(fn.x), Math.abs(fn.y));
|
|
10274
|
-
}
|
|
10275
|
-
return Math.ceil(3 * blurSum + maxShadowOffset);
|
|
10448
|
+
} else if (fn.kind === "url") referenceOutset += references?.get(fn.ref)?.outset ?? 0;
|
|
10449
|
+
return Math.ceil(3 * blurSum + maxShadowOffset + referenceOutset);
|
|
10276
10450
|
}
|
|
10277
10451
|
function canvasLike(source) {
|
|
10278
10452
|
const canvas = (source.ownerDocument ?? document).createElement("canvas");
|
|
@@ -10368,8 +10542,9 @@ const isSvgExpressible = (filter) => {
|
|
|
10368
10542
|
if (blurs > 0 && shadows > 0) return filter.functions.findIndex((fn) => fn.kind === "blur") < filter.functions.findIndex((fn) => fn.kind === "drop-shadow");
|
|
10369
10543
|
return true;
|
|
10370
10544
|
};
|
|
10371
|
-
async function renderFilterSurface(source, filter, opacity, scale, signal) {
|
|
10545
|
+
async function renderFilterSurface(source, filter, opacity, scale, signal, references) {
|
|
10372
10546
|
throwIfAborted(signal);
|
|
10547
|
+
if (filter.functions.some((fn) => fn.kind === "url")) return renderReferencedFilterSurface(source, filter, opacity, scale, signal, references);
|
|
10373
10548
|
if (filter.functions.length > 0 && supportsNativeFilters(source.ownerDocument ?? document)) {
|
|
10374
10549
|
const output = tryNativeFilterSurface(source, filter, opacity, scale, signal);
|
|
10375
10550
|
if (output) return output;
|
|
@@ -10377,6 +10552,136 @@ async function renderFilterSurface(source, filter, opacity, scale, signal) {
|
|
|
10377
10552
|
if (!isSvgExpressible(filter)) throw new FilterSurfaceError("Filter chain requires native canvas filters");
|
|
10378
10553
|
return renderSvgFilterSurface(source, filter, opacity, scale, signal);
|
|
10379
10554
|
}
|
|
10555
|
+
/**
|
|
10556
|
+
* Split a chain at its url() references: each reference is one SVG pass, and
|
|
10557
|
+
* the standard functions between them run through the usual backends. Every
|
|
10558
|
+
* step filters the previous step's complete output at alpha 1, then CSS
|
|
10559
|
+
* opacity is applied once, as for any other chain.
|
|
10560
|
+
*/
|
|
10561
|
+
async function renderReferencedFilterSurface(source, filter, opacity, scale, signal, context) {
|
|
10562
|
+
const steps = [];
|
|
10563
|
+
for (const fn of filter.functions) {
|
|
10564
|
+
if (fn.kind === "url") {
|
|
10565
|
+
const reference = context?.references?.get(fn.ref);
|
|
10566
|
+
if (!reference) throw new FilterSurfaceError(`Filter reference ${fn.ref} cannot be resolved`);
|
|
10567
|
+
steps.push(reference);
|
|
10568
|
+
continue;
|
|
10569
|
+
}
|
|
10570
|
+
const last = steps[steps.length - 1];
|
|
10571
|
+
if (Array.isArray(last)) last.push(fn);
|
|
10572
|
+
else steps.push([fn]);
|
|
10573
|
+
}
|
|
10574
|
+
const origin = context?.origin ?? {
|
|
10575
|
+
x: 0,
|
|
10576
|
+
y: 0
|
|
10577
|
+
};
|
|
10578
|
+
const last = steps[steps.length - 1];
|
|
10579
|
+
const opacityApplied = Array.isArray(last);
|
|
10580
|
+
let current = source;
|
|
10581
|
+
try {
|
|
10582
|
+
for (const step of steps) {
|
|
10583
|
+
const next = Array.isArray(step) ? await renderFilterSurface(current, { functions: step }, step === last ? opacity : 1, scale, signal) : await renderReferenceSurface(current, step, origin, scale, signal);
|
|
10584
|
+
if (current !== source) releaseSurface(current);
|
|
10585
|
+
current = next;
|
|
10586
|
+
}
|
|
10587
|
+
if (opacityApplied || opacity === 1) {
|
|
10588
|
+
const output = current;
|
|
10589
|
+
current = source;
|
|
10590
|
+
return output;
|
|
10591
|
+
}
|
|
10592
|
+
return drawWithOpacity(current, opacity);
|
|
10593
|
+
} finally {
|
|
10594
|
+
if (current !== source) releaseSurface(current);
|
|
10595
|
+
}
|
|
10596
|
+
}
|
|
10597
|
+
function drawWithOpacity(source, opacity) {
|
|
10598
|
+
const output = canvasLike(source);
|
|
10599
|
+
const context = output.getContext("2d");
|
|
10600
|
+
if (!context) {
|
|
10601
|
+
releaseSurface(output);
|
|
10602
|
+
throw new FilterSurfaceError("Filter surface canvas is unavailable");
|
|
10603
|
+
}
|
|
10604
|
+
context.globalAlpha = opacity;
|
|
10605
|
+
try {
|
|
10606
|
+
context.drawImage(source, 0, 0);
|
|
10607
|
+
return output;
|
|
10608
|
+
} catch {
|
|
10609
|
+
releaseSurface(output);
|
|
10610
|
+
throw new FilterSurfaceError("Filter surface image cannot be drawn");
|
|
10611
|
+
}
|
|
10612
|
+
}
|
|
10613
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
10614
|
+
function createSvgElement(owner, tag, attributes = {}) {
|
|
10615
|
+
const node = owner.createElementNS(SVG_NS, tag);
|
|
10616
|
+
Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)));
|
|
10617
|
+
return node;
|
|
10618
|
+
}
|
|
10619
|
+
function serializeSurface(source) {
|
|
10620
|
+
try {
|
|
10621
|
+
const url = source.toDataURL("image/png");
|
|
10622
|
+
if (url === "data:,") throw new Error("Canvas size is unsupported");
|
|
10623
|
+
return url;
|
|
10624
|
+
} catch {
|
|
10625
|
+
throw new FilterSurfaceError("Filter surface pixels cannot be serialized");
|
|
10626
|
+
}
|
|
10627
|
+
}
|
|
10628
|
+
/** Decode a filtered SVG image and draw it onto a surface-sized canvas. */
|
|
10629
|
+
async function drawSvgSurface(svg, source, opacity, signal) {
|
|
10630
|
+
const imageWindow = (source.ownerDocument ?? document).defaultView;
|
|
10631
|
+
const image = imageWindow?.Image ? new imageWindow.Image() : new Image();
|
|
10632
|
+
let output;
|
|
10633
|
+
try {
|
|
10634
|
+
image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
|
|
10635
|
+
await decodeSurface(image, signal);
|
|
10636
|
+
throwIfAborted(signal);
|
|
10637
|
+
output = canvasLike(source);
|
|
10638
|
+
const context = output.getContext("2d");
|
|
10639
|
+
if (!context) throw new FilterSurfaceError("Filter surface canvas is unavailable");
|
|
10640
|
+
context.globalAlpha = opacity;
|
|
10641
|
+
context.drawImage(image, 0, 0);
|
|
10642
|
+
return output;
|
|
10643
|
+
} catch (error) {
|
|
10644
|
+
if (output) releaseSurface(output);
|
|
10645
|
+
if (signal?.aborted) throwIfAborted(signal);
|
|
10646
|
+
throw error instanceof FilterSurfaceError ? error : new FilterSurfaceError("Filter surface image cannot be drawn");
|
|
10647
|
+
} finally {
|
|
10648
|
+
image.removeAttribute("src");
|
|
10649
|
+
}
|
|
10650
|
+
}
|
|
10651
|
+
/**
|
|
10652
|
+
* Apply a referenced SVG filter to the complete surface. The viewBox is in CSS
|
|
10653
|
+
* pixels, so the filter's user units scale with the capture, and the image is
|
|
10654
|
+
* placed so user space starts at the element's border box, as it does for the
|
|
10655
|
+
* live element. Canvas filters cannot express either: their lengths are in
|
|
10656
|
+
* device pixels and their bounding box is the whole canvas.
|
|
10657
|
+
*/
|
|
10658
|
+
async function renderReferenceSurface(source, reference, origin, scale, signal) {
|
|
10659
|
+
throwIfAborted(signal);
|
|
10660
|
+
const owner = source.ownerDocument ?? document;
|
|
10661
|
+
const width = source.width / scale;
|
|
10662
|
+
const height = source.height / scale;
|
|
10663
|
+
const svg = createSvgElement(owner, "svg", {
|
|
10664
|
+
width: source.width,
|
|
10665
|
+
height: source.height,
|
|
10666
|
+
viewBox: `0 0 ${width} ${height}`
|
|
10667
|
+
});
|
|
10668
|
+
const defs = createSvgElement(owner, "defs");
|
|
10669
|
+
const filterElement = owner.importNode(reference.element, true);
|
|
10670
|
+
filterElement.setAttribute("id", "composited-reference");
|
|
10671
|
+
defs.appendChild(filterElement);
|
|
10672
|
+
svg.appendChild(defs);
|
|
10673
|
+
const group = createSvgElement(owner, "g", { transform: `translate(${origin.x} ${origin.y})` });
|
|
10674
|
+
group.appendChild(createSvgElement(owner, "image", {
|
|
10675
|
+
x: -origin.x,
|
|
10676
|
+
y: -origin.y,
|
|
10677
|
+
width,
|
|
10678
|
+
height,
|
|
10679
|
+
href: serializeSurface(source),
|
|
10680
|
+
filter: "url(#composited-reference)"
|
|
10681
|
+
}));
|
|
10682
|
+
svg.appendChild(group);
|
|
10683
|
+
return drawSvgSurface(svg, source, 1, signal);
|
|
10684
|
+
}
|
|
10380
10685
|
async function renderSvgFilterSurface(source, filter, opacity, scale, signal) {
|
|
10381
10686
|
throwIfAborted(signal);
|
|
10382
10687
|
const blur = filter.functions.find((fn) => fn.kind === "blur");
|
|
@@ -10388,29 +10693,9 @@ async function renderSvgFilterSurface(source, filter, opacity, scale, signal) {
|
|
|
10388
10693
|
blur: shadow.blur,
|
|
10389
10694
|
color: shadow.color
|
|
10390
10695
|
} : void 0;
|
|
10391
|
-
if (!blurValue && !shadowValue)
|
|
10392
|
-
const output = canvasLike(source);
|
|
10393
|
-
const context = output.getContext("2d");
|
|
10394
|
-
if (!context) {
|
|
10395
|
-
releaseSurface(output);
|
|
10396
|
-
throw new FilterSurfaceError("Filter surface canvas is unavailable");
|
|
10397
|
-
}
|
|
10398
|
-
context.globalAlpha = opacity;
|
|
10399
|
-
try {
|
|
10400
|
-
context.drawImage(source, 0, 0);
|
|
10401
|
-
return output;
|
|
10402
|
-
} catch {
|
|
10403
|
-
releaseSurface(output);
|
|
10404
|
-
throw new FilterSurfaceError("Filter surface image cannot be drawn");
|
|
10405
|
-
}
|
|
10406
|
-
}
|
|
10407
|
-
const namespace = "http://www.w3.org/2000/svg";
|
|
10696
|
+
if (!blurValue && !shadowValue) return drawWithOpacity(source, opacity);
|
|
10408
10697
|
const svgDocument = source.ownerDocument ?? document;
|
|
10409
|
-
const element = (tag, attributes = {}) =>
|
|
10410
|
-
const node = svgDocument.createElementNS(namespace, tag);
|
|
10411
|
-
Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)));
|
|
10412
|
-
return node;
|
|
10413
|
-
};
|
|
10698
|
+
const element = (tag, attributes = {}) => createSvgElement(svgDocument, tag, attributes);
|
|
10414
10699
|
const svg = element("svg", {
|
|
10415
10700
|
width: source.width,
|
|
10416
10701
|
height: source.height,
|
|
@@ -10452,39 +10737,13 @@ async function renderSvgFilterSurface(source, filter, opacity, scale, signal) {
|
|
|
10452
10737
|
}
|
|
10453
10738
|
defs.appendChild(filterElement);
|
|
10454
10739
|
svg.appendChild(defs);
|
|
10455
|
-
let sourceUrl;
|
|
10456
|
-
try {
|
|
10457
|
-
sourceUrl = source.toDataURL("image/png");
|
|
10458
|
-
if (sourceUrl === "data:,") throw new Error("Canvas size is unsupported");
|
|
10459
|
-
} catch {
|
|
10460
|
-
throw new FilterSurfaceError("Filter surface pixels cannot be serialized");
|
|
10461
|
-
}
|
|
10462
10740
|
svg.appendChild(element("image", {
|
|
10463
10741
|
width: source.width,
|
|
10464
10742
|
height: source.height,
|
|
10465
|
-
href:
|
|
10743
|
+
href: serializeSurface(source),
|
|
10466
10744
|
filter: "url(#composited-filter)"
|
|
10467
10745
|
}));
|
|
10468
|
-
|
|
10469
|
-
const image = imageWindow?.Image ? new imageWindow.Image() : new Image();
|
|
10470
|
-
let output;
|
|
10471
|
-
try {
|
|
10472
|
-
image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
|
|
10473
|
-
await decodeSurface(image, signal);
|
|
10474
|
-
throwIfAborted(signal);
|
|
10475
|
-
output = canvasLike(source);
|
|
10476
|
-
const context = output.getContext("2d");
|
|
10477
|
-
if (!context) throw new FilterSurfaceError("Filter surface canvas is unavailable");
|
|
10478
|
-
context.globalAlpha = opacity;
|
|
10479
|
-
context.drawImage(image, 0, 0);
|
|
10480
|
-
return output;
|
|
10481
|
-
} catch (error) {
|
|
10482
|
-
if (output) releaseSurface(output);
|
|
10483
|
-
if (signal?.aborted) throwIfAborted(signal);
|
|
10484
|
-
throw error instanceof FilterSurfaceError ? error : new FilterSurfaceError("Filter surface image cannot be drawn");
|
|
10485
|
-
} finally {
|
|
10486
|
-
image.removeAttribute("src");
|
|
10487
|
-
}
|
|
10746
|
+
return drawSvgSurface(svg, source, opacity, signal);
|
|
10488
10747
|
}
|
|
10489
10748
|
function reserveSurface(budget, width, height) {
|
|
10490
10749
|
const pixels = width * height * 4;
|
|
@@ -10508,7 +10767,7 @@ function surfaceBounds(container) {
|
|
|
10508
10767
|
}
|
|
10509
10768
|
for (const child of container.elements) if (child.styles.isVisible()) bounds = union(bounds, surfaceBounds(child));
|
|
10510
10769
|
const filter = parseFilterChain(styles.filter);
|
|
10511
|
-
return expand(bounds, filter ? filterOutset(filter) : 0);
|
|
10770
|
+
return expand(bounds, filter ? filterOutset(filter, container.filterReferences) : 0);
|
|
10512
10771
|
}
|
|
10513
10772
|
function cropSurface(bounds, viewport, margin, scale) {
|
|
10514
10773
|
const area = expand(viewport, margin);
|
|
@@ -12145,6 +12404,7 @@ const paintRadialLayer = (ctx, layer, area) => {
|
|
|
12145
12404
|
ctx.fillStyle = rasterized.lastColor;
|
|
12146
12405
|
ctx.fillRect(area.left, area.top, area.width, area.height);
|
|
12147
12406
|
ctx.drawImage(rasterized.canvas, area.left + cx - rx, area.top + cy - ry, rx * 2, ry * 2);
|
|
12407
|
+
releaseSurface(rasterized.canvas);
|
|
12148
12408
|
};
|
|
12149
12409
|
/** Draw one gradient layer filling the whole mask area. */
|
|
12150
12410
|
const paintGradientLayer = (ctx, layer, area) => {
|
|
@@ -12999,7 +13259,8 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
12999
13259
|
async renderCompositedStack(stack, filter, hasMask, reflect, hasBlend = false) {
|
|
13000
13260
|
const signal = this.options.signal;
|
|
13001
13261
|
if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
|
|
13002
|
-
const
|
|
13262
|
+
const container = stack.element.container;
|
|
13263
|
+
const bounds = cropSurface(surfaceBounds(container), new Bounds(this.options.x, this.options.y, this.options.width, this.options.height), filter ? filterOutset(filter, container.filterReferences) : 0, this.options.scale);
|
|
13003
13264
|
if (!bounds.width || !bounds.height) return true;
|
|
13004
13265
|
const reserved = reserveSurface(this.surfaceBudget, Math.ceil(bounds.width * this.options.scale), Math.ceil(bounds.height * this.options.scale));
|
|
13005
13266
|
if (!reserved) {
|
|
@@ -13021,7 +13282,13 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
13021
13282
|
source = new CanvasRenderer(this.context, options, stack.element, this.surfaceBudget);
|
|
13022
13283
|
await source.renderStackContent(stack);
|
|
13023
13284
|
source.effectsRenderer.applyEffects([]);
|
|
13024
|
-
if (filter) filtered = await renderFilterSurface(source.canvas, filter,
|
|
13285
|
+
if (filter) filtered = await renderFilterSurface(source.canvas, filter, container.styles.opacity, options.scale, signal, {
|
|
13286
|
+
references: container.filterReferences,
|
|
13287
|
+
origin: {
|
|
13288
|
+
x: container.bounds.left - bounds.left,
|
|
13289
|
+
y: container.bounds.top - bounds.top
|
|
13290
|
+
}
|
|
13291
|
+
});
|
|
13025
13292
|
const composited = filtered ?? source.canvas;
|
|
13026
13293
|
if (hasMask) await this.applyMask(composited, stack.element.container.styles, bounds);
|
|
13027
13294
|
if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
|
|
@@ -13058,7 +13325,10 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
13058
13325
|
maskCanvas.width = surface.width;
|
|
13059
13326
|
maskCanvas.height = surface.height;
|
|
13060
13327
|
const maskCtx = maskCanvas.getContext("2d");
|
|
13061
|
-
if (!maskCtx)
|
|
13328
|
+
if (!maskCtx) {
|
|
13329
|
+
releaseSurface(maskCanvas);
|
|
13330
|
+
return;
|
|
13331
|
+
}
|
|
13062
13332
|
maskCtx.scale(this.options.scale, this.options.scale);
|
|
13063
13333
|
maskCtx.translate(-bounds.left, -bounds.top);
|
|
13064
13334
|
try {
|
|
@@ -13070,6 +13340,7 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
13070
13340
|
}, async (url) => await this.context.cache.match(url));
|
|
13071
13341
|
} catch (error) {
|
|
13072
13342
|
this.context.logger.error(`Error rendering mask-image: ${error instanceof Error ? error.message : error}`);
|
|
13343
|
+
releaseSurface(maskCanvas);
|
|
13073
13344
|
return;
|
|
13074
13345
|
}
|
|
13075
13346
|
ctx.save();
|
|
@@ -13079,6 +13350,7 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
13079
13350
|
ctx.drawImage(maskCanvas, 0, 0);
|
|
13080
13351
|
} finally {
|
|
13081
13352
|
ctx.restore();
|
|
13353
|
+
releaseSurface(maskCanvas);
|
|
13082
13354
|
}
|
|
13083
13355
|
}
|
|
13084
13356
|
/**
|
|
@@ -13483,6 +13755,7 @@ var CanvasRenderer = class CanvasRenderer {
|
|
|
13483
13755
|
ctx.fillStyle = rasterized.lastColor;
|
|
13484
13756
|
ctx.fillRect(0, 0, width, height);
|
|
13485
13757
|
ctx.drawImage(rasterized.canvas, cx - rx, cy - ry, rx * 2, ry * 2);
|
|
13758
|
+
releaseSurface(rasterized.canvas);
|
|
13486
13759
|
return canvas;
|
|
13487
13760
|
}
|
|
13488
13761
|
if (source.type === 5 || source.type === 6) {
|