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 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="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
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> ·
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 2.5.4 <https://yorickshan.github.io/html2canvas-pro/>
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
- * Called on the cloned <body> so the surrogates themselves never render.
7920
+ * Added to the cloned <head> so the surrogates themselves never render.
7893
7921
  */
7894
- const createPseudoHideStyles = (body, cspNonce) => {
7895
- createStyles(body, `.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
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 = (body, styles, cspNonce) => {
7899
- const document = body.ownerDocument;
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
- body.appendChild(style);
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 && typeof CSSImportRule !== "undefined" && rule instanceof CSSImportRule) {
8070
- if (!rule.styleSheet || !isSheetFullyApplied(rule.styleSheet, depth + 1)) return false;
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 >= 1e4) {
8144
- logger?.warn(`Stylesheet ${href} did not finish loading within ${STYLESHEET_READY_TIMEOUT_MS / 1e3}s; continuing with a possibly unstyled clone`);
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 >= 1e4) {
8162
- if (elapsed >= 1e4 && !isSheetFullyApplied(currentSheet ?? null)) logger?.warn(`@import chain in an inline <style> did not finish loading within ${STYLESHEET_READY_TIMEOUT_MS / 1e3}s; continuing with a possibly unstyled clone`);
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: sourceUrl,
10743
+ href: serializeSurface(source),
10466
10744
  filter: "url(#composited-filter)"
10467
10745
  }));
10468
- const imageWindow = svgDocument.defaultView;
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 bounds = cropSurface(surfaceBounds(stack.element.container), new Bounds(this.options.x, this.options.y, this.options.width, this.options.height), filter ? filterOutset(filter) : 0, this.options.scale);
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, stack.element.container.styles.opacity, options.scale, signal);
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) return;
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) {