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.
@@ -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
  */
@@ -249,6 +249,30 @@
249
249
  };
250
250
  }
251
251
  /**
252
+ * Validate the backgroundColor option
253
+ *
254
+ * @param backgroundColor - CSS color string or null (transparent)
255
+ * @returns Validation result
256
+ */
257
+ validateBackgroundColor(backgroundColor) {
258
+ if (backgroundColor === null) return {
259
+ valid: true,
260
+ sanitized: null
261
+ };
262
+ if (typeof backgroundColor !== "string" || backgroundColor.length === 0) return {
263
+ valid: false,
264
+ error: "backgroundColor must be a CSS color string or null"
265
+ };
266
+ if (/[^a-zA-Z0-9%(),.#\s/-]/.test(backgroundColor)) return {
267
+ valid: false,
268
+ error: `backgroundColor contains characters that cannot appear in a CSS color: ${backgroundColor}`
269
+ };
270
+ return {
271
+ valid: true,
272
+ sanitized: backgroundColor
273
+ };
274
+ }
275
+ /**
252
276
  * Validate image timeout
253
277
  *
254
278
  * @param timeout - Timeout in milliseconds
@@ -393,6 +417,10 @@
393
417
  const nonceResult = this.validateCspNonce(options.cspNonce);
394
418
  if (!nonceResult.valid) errors.push(`CSP nonce: ${nonceResult.error}`);
395
419
  }
420
+ if (options.backgroundColor !== void 0) {
421
+ const colorResult = this.validateBackgroundColor(options.backgroundColor);
422
+ if (!colorResult.valid) errors.push(`backgroundColor: ${colorResult.error}`);
423
+ }
396
424
  if (this.config.customValidator) {
397
425
  const customResult = this.config.customValidator(options, "options");
398
426
  if (!customResult.valid) errors.push(`Custom validation: ${customResult.error}`);
@@ -7892,19 +7920,19 @@
7892
7920
  }`;
7893
7921
  /**
7894
7922
  * Inject the stylesheet that hides pseudo-element surrogate containers.
7895
- * Called on the cloned <body> so the surrogates themselves never render.
7923
+ * Added to the cloned <head> so the surrogates themselves never render.
7896
7924
  */
7897
- const createPseudoHideStyles = (body, cspNonce) => {
7898
- createStyles(body, `.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
7925
+ const createPseudoHideStyles = (parent, cspNonce) => {
7926
+ createStyles(parent, `.${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE}${PSEUDO_BEFORE}${PSEUDO_HIDE_ELEMENT_STYLE}
7899
7927
  .${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}${PSEUDO_AFTER}${PSEUDO_HIDE_ELEMENT_STYLE}`, cspNonce);
7900
7928
  };
7901
- const createStyles = (body, styles, cspNonce) => {
7902
- const document = body.ownerDocument;
7929
+ const createStyles = (parent, styles, cspNonce) => {
7930
+ const document = parent.ownerDocument;
7903
7931
  if (document) {
7904
7932
  const style = document.createElement("style");
7905
7933
  style.textContent = styles;
7906
7934
  if (cspNonce) style.nonce = cspNonce;
7907
- body.appendChild(style);
7935
+ parent.appendChild(style);
7908
7936
  }
7909
7937
  };
7910
7938
  /**
@@ -8024,9 +8052,20 @@
8024
8052
  cloneIframeContainer.style.position = "fixed";
8025
8053
  cloneIframeContainer.style.left = "-10000px";
8026
8054
  cloneIframeContainer.style.top = "0px";
8027
- cloneIframeContainer.style.border = "0";
8028
8055
  cloneIframeContainer.width = bounds.width.toString();
8029
8056
  cloneIframeContainer.height = bounds.height.toString();
8057
+ const contentBox = [
8058
+ ["width", `${bounds.width}px`],
8059
+ ["height", `${bounds.height}px`],
8060
+ ["min-width", "0px"],
8061
+ ["max-width", "none"],
8062
+ ["min-height", "0px"],
8063
+ ["max-height", "none"],
8064
+ ["box-sizing", "content-box"],
8065
+ ["padding", "0px"],
8066
+ ["border", "0"]
8067
+ ];
8068
+ for (const [property, value] of contentBox) cloneIframeContainer.style.setProperty(property, value, "important");
8030
8069
  cloneIframeContainer.scrolling = "no";
8031
8070
  cloneIframeContainer.setAttribute(IGNORE_ATTRIBUTE, "true");
8032
8071
  (customContainer || ownerDocument.body).appendChild(cloneIframeContainer);
@@ -8069,9 +8108,9 @@
8069
8108
  if (!rules) return true;
8070
8109
  for (let i = 0; i < rules.length; i++) {
8071
8110
  const rule = rules[i];
8072
- if (rule && typeof CSSImportRule !== "undefined" && rule instanceof CSSImportRule) {
8073
- if (!rule.styleSheet || !isSheetFullyApplied(rule.styleSheet, depth + 1)) return false;
8074
- }
8111
+ if (!rule || !("styleSheet" in rule)) continue;
8112
+ const inner = rule.styleSheet;
8113
+ if (!inner || !isSheetFullyApplied(inner, depth + 1)) return false;
8075
8114
  }
8076
8115
  return true;
8077
8116
  };
@@ -8113,7 +8152,7 @@
8113
8152
  * remain as the settle path for plain <link> fetches.
8114
8153
  * @internal – exported for testing only.
8115
8154
  */
8116
- const stylesheetsReady = (documentClone, logger, signal) => {
8155
+ const stylesheetsReady = (documentClone, logger, signal, timeoutMs = STYLESHEET_READY_TIMEOUT_MS) => {
8117
8156
  if (signal?.aborted) return Promise.resolve();
8118
8157
  const pending = [];
8119
8158
  const links = [];
@@ -8143,8 +8182,8 @@
8143
8182
  const timer = setInterval(() => {
8144
8183
  elapsed += 50;
8145
8184
  if (link.sheet) settle();
8146
- else if (elapsed >= 1e4) {
8147
- logger?.warn(`Stylesheet ${href} did not finish loading within ${STYLESHEET_READY_TIMEOUT_MS / 1e3}s; continuing with a possibly unstyled clone`);
8185
+ else if (elapsed >= timeoutMs) {
8186
+ logger?.warn(`Stylesheet ${href} did not finish loading within ${timeoutMs / 1e3}s; continuing with a possibly unstyled clone`);
8148
8187
  settle();
8149
8188
  }
8150
8189
  }, 50);
@@ -8161,8 +8200,8 @@
8161
8200
  const timer = setInterval(() => {
8162
8201
  elapsed += 50;
8163
8202
  const currentSheet = style.sheet;
8164
- if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >= 1e4) {
8165
- 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`);
8203
+ if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >= timeoutMs) {
8204
+ 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`);
8166
8205
  clearInterval(timer);
8167
8206
  resolve();
8168
8207
  }
@@ -8375,6 +8414,7 @@
8375
8414
  }
8376
8415
  this.documentElement = this.cloneNode(element.ownerDocument.documentElement, false);
8377
8416
  this.appendDocumentAdoptedStyles(element.ownerDocument);
8417
+ this.appendPseudoHideStyles();
8378
8418
  }
8379
8419
  /**
8380
8420
  * Re-materialise the source document's constructable stylesheets
@@ -8394,6 +8434,16 @@
8394
8434
  const style = createAdoptedStylesElement(this.documentElement.ownerDocument ?? ownerDocument, sheets, this.options.cspNonce);
8395
8435
  if (style) head.appendChild(style);
8396
8436
  }
8437
+ /**
8438
+ * Add the rules that hide the source ::before / ::after (the clone draws
8439
+ * them as surrogate elements) to the clone's <head>. Inside <body> the
8440
+ * <style> would be one of its children, and `body > :first-child`,
8441
+ * `:nth-child()` and sibling selectors would match the wrong elements.
8442
+ */
8443
+ appendPseudoHideStyles() {
8444
+ const head = this.documentElement.getElementsByTagName("head").item(0);
8445
+ createPseudoHideStyles(head ?? this.documentElement, this.options.cspNonce);
8446
+ }
8397
8447
  toIFrame(ownerDocument, windowSize) {
8398
8448
  return mountCloneInIFrame(this.context, ownerDocument, windowSize, {
8399
8449
  documentElement: this.documentElement,
@@ -8529,7 +8579,6 @@
8529
8579
  const style = window.getComputedStyle(node);
8530
8580
  const checkPseudoElements = canHavePseudoElements(node);
8531
8581
  if (this.referenceElement === node && isHTMLElementNode(clone)) this.clonedReferenceElement = clone;
8532
- if (isBodyElement(clone)) createPseudoHideStyles(clone, this.options.cspNonce);
8533
8582
  const counters = this.counters.parse(new CSSParsedCounterDeclaration(this.context, style));
8534
8583
  if (checkPseudoElements) {
8535
8584
  const styleBefore = window.getComputedStyle(node, ":before");
@@ -8653,6 +8702,115 @@
8653
8702
  }
8654
8703
  };
8655
8704
  //#endregion
8705
+ //#region src/dom/filter-reference.ts
8706
+ const SVG_NS$1 = "http://www.w3.org/2000/svg";
8707
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
8708
+ const REGION_DEFAULTS = {
8709
+ x: "-10%",
8710
+ y: "-10%",
8711
+ width: "120%",
8712
+ height: "120%"
8713
+ };
8714
+ const PRESENTATION_PROPERTIES = [
8715
+ "color-interpolation-filters",
8716
+ "flood-color",
8717
+ "flood-opacity",
8718
+ "lighting-color"
8719
+ ];
8720
+ const REFERENCE_RE = /^(["']?)(#[^"'\s()]+)\1$/;
8721
+ const URL_RE = /url\(\s*([^)]*?)\s*\)/g;
8722
+ /** The `#id` of a same-document url() body (`"#id"`, `'#id'` or `#id`), or null. */
8723
+ const parseFilterReference = (body) => {
8724
+ const match = REFERENCE_RE.exec(body.trim());
8725
+ return match ? match[2] : null;
8726
+ };
8727
+ /**
8728
+ * Resolve every same-document url() reference of a computed filter value.
8729
+ * References that cannot be expressed in the serialized SVG are left out, so
8730
+ * the compositor rejects the chain and the subtree keeps the legacy renderer.
8731
+ */
8732
+ function resolveFilterReferences(element, filter, box) {
8733
+ if (!filter || filter.indexOf("url(") === -1) return void 0;
8734
+ const references = /* @__PURE__ */ new Map();
8735
+ const seen = /* @__PURE__ */ new Set();
8736
+ for (const match of filter.matchAll(URL_RE)) {
8737
+ const ref = parseFilterReference(match[1]);
8738
+ if (!ref || seen.has(ref)) continue;
8739
+ seen.add(ref);
8740
+ const reference = resolveFilterReference(element.ownerDocument, ref, box);
8741
+ if (reference) references.set(ref, reference);
8742
+ }
8743
+ return references;
8744
+ }
8745
+ function resolveFilterReference(document, ref, box) {
8746
+ const source = document.getElementById(ref.slice(1));
8747
+ if (!source || source.namespaceURI !== SVG_NS$1 || source.localName !== "filter") return null;
8748
+ if (!isSelfContained(source)) return null;
8749
+ const region = filterRegion(source, box);
8750
+ if (!region) return null;
8751
+ const copy = source.cloneNode(true);
8752
+ inlinePresentation(source, copy);
8753
+ copy.removeAttribute("id");
8754
+ copy.setAttribute("filterUnits", "userSpaceOnUse");
8755
+ Object.keys(region).forEach((name) => copy.setAttribute(name, String(region[name])));
8756
+ const outset = Math.max(0, -region.x, -region.y, region.x + region.width - box.width, region.y + region.height - box.height);
8757
+ return {
8758
+ element: copy,
8759
+ region,
8760
+ outset: Math.ceil(outset)
8761
+ };
8762
+ }
8763
+ const hasHref = (node) => node.hasAttribute("href") || node.hasAttributeNS(XLINK_NS, "href");
8764
+ function isSelfContained(source) {
8765
+ if (source.getAttribute("primitiveUnits") === "objectBoundingBox" || hasHref(source)) return false;
8766
+ for (const primitive of Array.from(source.querySelectorAll("*"))) {
8767
+ if (primitive.localName === "feImage" || hasHref(primitive)) return false;
8768
+ for (const name of [
8769
+ "x",
8770
+ "y",
8771
+ "width",
8772
+ "height"
8773
+ ]) if ((primitive.getAttribute(name) ?? "").indexOf("%") !== -1) return false;
8774
+ }
8775
+ return true;
8776
+ }
8777
+ function filterRegion(source, box) {
8778
+ const boundingBox = source.getAttribute("filterUnits") !== "userSpaceOnUse";
8779
+ const read = (name, size) => {
8780
+ const raw = (source.getAttribute(name) ?? (boundingBox ? REGION_DEFAULTS[name] : "")).trim();
8781
+ if (raw === "") return null;
8782
+ const percentage = raw.endsWith("%");
8783
+ const value = Number(percentage ? raw.slice(0, -1) : raw.replace(/px$/, ""));
8784
+ if (!Number.isFinite(value)) return null;
8785
+ if (boundingBox) return (percentage ? value / 100 : value) * size;
8786
+ return percentage ? null : value;
8787
+ };
8788
+ const x = read("x", box.width);
8789
+ const y = read("y", box.height);
8790
+ const width = read("width", box.width);
8791
+ const height = read("height", box.height);
8792
+ if (x === null || y === null || width === null || height === null || width <= 0 || height <= 0) return null;
8793
+ return {
8794
+ x,
8795
+ y,
8796
+ width,
8797
+ height
8798
+ };
8799
+ }
8800
+ function inlinePresentation(source, copy) {
8801
+ const view = source.ownerDocument.defaultView;
8802
+ if (!view) return;
8803
+ const sources = [source, ...Array.from(source.querySelectorAll("*"))];
8804
+ const copies = [copy, ...Array.from(copy.querySelectorAll("*"))];
8805
+ sources.forEach((node, index) => {
8806
+ const style = view.getComputedStyle(node);
8807
+ for (const property of PRESENTATION_PROPERTIES) {
8808
+ const value = style.getPropertyValue(property);
8809
+ if (value) copies[index]?.setAttribute(property, value);
8810
+ }
8811
+ });
8812
+ }
8813
+ //#endregion
8656
8814
  //#region src/dom/element-container.ts
8657
8815
  var ElementContainer = class {
8658
8816
  context;
@@ -8666,6 +8824,11 @@
8666
8824
  * bounds to leave a matching gap (issue #227).
8667
8825
  */
8668
8826
  legendBounds;
8827
+ /**
8828
+ * Same-document `filter: url(#id)` references, resolved while the cloned
8829
+ * DOM is available. The surface compositor renders them in an SVG pass.
8830
+ */
8831
+ filterReferences;
8669
8832
  createsStackingContext = false;
8670
8833
  createsRealStackingContext = false;
8671
8834
  isListOwner = false;
@@ -8690,6 +8853,7 @@
8690
8853
  this.bounds = new Bounds(this.bounds.left, borderBoxTop, this.bounds.width, this.bounds.height - (borderBoxTop - this.bounds.top));
8691
8854
  }
8692
8855
  }
8856
+ this.filterReferences = resolveFilterReferences(element, this.styles.filter, this.bounds);
8693
8857
  if (isDebugging(element, 4)) this.debugRender = true;
8694
8858
  }
8695
8859
  /**
@@ -9850,7 +10014,7 @@
9850
10014
  if (treatAsRealStackingContext || createsStackingContext) {
9851
10015
  const parentStack = treatAsRealStackingContext || child.styles.isPositioned() ? realStackingContext : stackingContext;
9852
10016
  const stack = new StackingContext(paintContainer);
9853
- if (child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
10017
+ if (treatAsRealStackingContext || child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
9854
10018
  const order = child.styles.zIndex.order;
9855
10019
  if (order < 0) {
9856
10020
  let index = 0;
@@ -10205,6 +10369,13 @@
10205
10369
  value: Number.parseFloat(numeric) * (percentage ? 1 / 100 : 1)
10206
10370
  };
10207
10371
  }
10372
+ if (name === "url") {
10373
+ const ref = parseFilterReference(body);
10374
+ return ref ? {
10375
+ kind: "url",
10376
+ ref
10377
+ } : null;
10378
+ }
10208
10379
  return null;
10209
10380
  };
10210
10381
  /** Split on whitespace outside parentheses so rgba(...) colours stay one token. */
@@ -10260,22 +10431,25 @@
10260
10431
  case "blur": return `blur(${fn.value * scale}px)`;
10261
10432
  case "drop-shadow": return `drop-shadow(${fn.x * scale}px ${fn.y * scale}px ${fn.blur * scale}px ${fn.color})`;
10262
10433
  case "unitless": return fn.name === "hue-rotate" ? `hue-rotate(${fn.value}deg)` : `${fn.name}(${fn.value})`;
10434
+ case "url": return `url("${fn.ref}")`;
10263
10435
  }
10264
10436
  }).join(" ");
10265
10437
  /**
10266
10438
  * Outset (in CSS pixels) a surface must grow so blur/shadow spillover is not
10267
10439
  * cropped. Unitless functions never spread beyond the bounds. Consecutive
10268
- * blurs compound, so their radii sum.
10440
+ * blurs compound, so their radii sum. A url() reference can paint up to its
10441
+ * filter region, which may extend beyond the border box.
10269
10442
  */
10270
- function filterOutset(filter) {
10443
+ function filterOutset(filter, references) {
10271
10444
  let blurSum = 0;
10272
10445
  let maxShadowOffset = 0;
10446
+ let referenceOutset = 0;
10273
10447
  for (const fn of filter.functions) if (fn.kind === "blur") blurSum += fn.value;
10274
10448
  else if (fn.kind === "drop-shadow") {
10275
10449
  blurSum += fn.blur;
10276
10450
  maxShadowOffset = Math.max(maxShadowOffset, Math.abs(fn.x), Math.abs(fn.y));
10277
- }
10278
- return Math.ceil(3 * blurSum + maxShadowOffset);
10451
+ } else if (fn.kind === "url") referenceOutset += references?.get(fn.ref)?.outset ?? 0;
10452
+ return Math.ceil(3 * blurSum + maxShadowOffset + referenceOutset);
10279
10453
  }
10280
10454
  function canvasLike(source) {
10281
10455
  const canvas = (source.ownerDocument ?? document).createElement("canvas");
@@ -10371,8 +10545,9 @@
10371
10545
  if (blurs > 0 && shadows > 0) return filter.functions.findIndex((fn) => fn.kind === "blur") < filter.functions.findIndex((fn) => fn.kind === "drop-shadow");
10372
10546
  return true;
10373
10547
  };
10374
- async function renderFilterSurface(source, filter, opacity, scale, signal) {
10548
+ async function renderFilterSurface(source, filter, opacity, scale, signal, references) {
10375
10549
  throwIfAborted(signal);
10550
+ if (filter.functions.some((fn) => fn.kind === "url")) return renderReferencedFilterSurface(source, filter, opacity, scale, signal, references);
10376
10551
  if (filter.functions.length > 0 && supportsNativeFilters(source.ownerDocument ?? document)) {
10377
10552
  const output = tryNativeFilterSurface(source, filter, opacity, scale, signal);
10378
10553
  if (output) return output;
@@ -10380,6 +10555,136 @@
10380
10555
  if (!isSvgExpressible(filter)) throw new FilterSurfaceError("Filter chain requires native canvas filters");
10381
10556
  return renderSvgFilterSurface(source, filter, opacity, scale, signal);
10382
10557
  }
10558
+ /**
10559
+ * Split a chain at its url() references: each reference is one SVG pass, and
10560
+ * the standard functions between them run through the usual backends. Every
10561
+ * step filters the previous step's complete output at alpha 1, then CSS
10562
+ * opacity is applied once, as for any other chain.
10563
+ */
10564
+ async function renderReferencedFilterSurface(source, filter, opacity, scale, signal, context) {
10565
+ const steps = [];
10566
+ for (const fn of filter.functions) {
10567
+ if (fn.kind === "url") {
10568
+ const reference = context?.references?.get(fn.ref);
10569
+ if (!reference) throw new FilterSurfaceError(`Filter reference ${fn.ref} cannot be resolved`);
10570
+ steps.push(reference);
10571
+ continue;
10572
+ }
10573
+ const last = steps[steps.length - 1];
10574
+ if (Array.isArray(last)) last.push(fn);
10575
+ else steps.push([fn]);
10576
+ }
10577
+ const origin = context?.origin ?? {
10578
+ x: 0,
10579
+ y: 0
10580
+ };
10581
+ const last = steps[steps.length - 1];
10582
+ const opacityApplied = Array.isArray(last);
10583
+ let current = source;
10584
+ try {
10585
+ for (const step of steps) {
10586
+ const next = Array.isArray(step) ? await renderFilterSurface(current, { functions: step }, step === last ? opacity : 1, scale, signal) : await renderReferenceSurface(current, step, origin, scale, signal);
10587
+ if (current !== source) releaseSurface(current);
10588
+ current = next;
10589
+ }
10590
+ if (opacityApplied || opacity === 1) {
10591
+ const output = current;
10592
+ current = source;
10593
+ return output;
10594
+ }
10595
+ return drawWithOpacity(current, opacity);
10596
+ } finally {
10597
+ if (current !== source) releaseSurface(current);
10598
+ }
10599
+ }
10600
+ function drawWithOpacity(source, opacity) {
10601
+ const output = canvasLike(source);
10602
+ const context = output.getContext("2d");
10603
+ if (!context) {
10604
+ releaseSurface(output);
10605
+ throw new FilterSurfaceError("Filter surface canvas is unavailable");
10606
+ }
10607
+ context.globalAlpha = opacity;
10608
+ try {
10609
+ context.drawImage(source, 0, 0);
10610
+ return output;
10611
+ } catch {
10612
+ releaseSurface(output);
10613
+ throw new FilterSurfaceError("Filter surface image cannot be drawn");
10614
+ }
10615
+ }
10616
+ const SVG_NS = "http://www.w3.org/2000/svg";
10617
+ function createSvgElement(owner, tag, attributes = {}) {
10618
+ const node = owner.createElementNS(SVG_NS, tag);
10619
+ Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)));
10620
+ return node;
10621
+ }
10622
+ function serializeSurface(source) {
10623
+ try {
10624
+ const url = source.toDataURL("image/png");
10625
+ if (url === "data:,") throw new Error("Canvas size is unsupported");
10626
+ return url;
10627
+ } catch {
10628
+ throw new FilterSurfaceError("Filter surface pixels cannot be serialized");
10629
+ }
10630
+ }
10631
+ /** Decode a filtered SVG image and draw it onto a surface-sized canvas. */
10632
+ async function drawSvgSurface(svg, source, opacity, signal) {
10633
+ const imageWindow = (source.ownerDocument ?? document).defaultView;
10634
+ const image = imageWindow?.Image ? new imageWindow.Image() : new Image();
10635
+ let output;
10636
+ try {
10637
+ image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
10638
+ await decodeSurface(image, signal);
10639
+ throwIfAborted(signal);
10640
+ output = canvasLike(source);
10641
+ const context = output.getContext("2d");
10642
+ if (!context) throw new FilterSurfaceError("Filter surface canvas is unavailable");
10643
+ context.globalAlpha = opacity;
10644
+ context.drawImage(image, 0, 0);
10645
+ return output;
10646
+ } catch (error) {
10647
+ if (output) releaseSurface(output);
10648
+ if (signal?.aborted) throwIfAborted(signal);
10649
+ throw error instanceof FilterSurfaceError ? error : new FilterSurfaceError("Filter surface image cannot be drawn");
10650
+ } finally {
10651
+ image.removeAttribute("src");
10652
+ }
10653
+ }
10654
+ /**
10655
+ * Apply a referenced SVG filter to the complete surface. The viewBox is in CSS
10656
+ * pixels, so the filter's user units scale with the capture, and the image is
10657
+ * placed so user space starts at the element's border box, as it does for the
10658
+ * live element. Canvas filters cannot express either: their lengths are in
10659
+ * device pixels and their bounding box is the whole canvas.
10660
+ */
10661
+ async function renderReferenceSurface(source, reference, origin, scale, signal) {
10662
+ throwIfAborted(signal);
10663
+ const owner = source.ownerDocument ?? document;
10664
+ const width = source.width / scale;
10665
+ const height = source.height / scale;
10666
+ const svg = createSvgElement(owner, "svg", {
10667
+ width: source.width,
10668
+ height: source.height,
10669
+ viewBox: `0 0 ${width} ${height}`
10670
+ });
10671
+ const defs = createSvgElement(owner, "defs");
10672
+ const filterElement = owner.importNode(reference.element, true);
10673
+ filterElement.setAttribute("id", "composited-reference");
10674
+ defs.appendChild(filterElement);
10675
+ svg.appendChild(defs);
10676
+ const group = createSvgElement(owner, "g", { transform: `translate(${origin.x} ${origin.y})` });
10677
+ group.appendChild(createSvgElement(owner, "image", {
10678
+ x: -origin.x,
10679
+ y: -origin.y,
10680
+ width,
10681
+ height,
10682
+ href: serializeSurface(source),
10683
+ filter: "url(#composited-reference)"
10684
+ }));
10685
+ svg.appendChild(group);
10686
+ return drawSvgSurface(svg, source, 1, signal);
10687
+ }
10383
10688
  async function renderSvgFilterSurface(source, filter, opacity, scale, signal) {
10384
10689
  throwIfAborted(signal);
10385
10690
  const blur = filter.functions.find((fn) => fn.kind === "blur");
@@ -10391,29 +10696,9 @@
10391
10696
  blur: shadow.blur,
10392
10697
  color: shadow.color
10393
10698
  } : void 0;
10394
- if (!blurValue && !shadowValue) {
10395
- const output = canvasLike(source);
10396
- const context = output.getContext("2d");
10397
- if (!context) {
10398
- releaseSurface(output);
10399
- throw new FilterSurfaceError("Filter surface canvas is unavailable");
10400
- }
10401
- context.globalAlpha = opacity;
10402
- try {
10403
- context.drawImage(source, 0, 0);
10404
- return output;
10405
- } catch {
10406
- releaseSurface(output);
10407
- throw new FilterSurfaceError("Filter surface image cannot be drawn");
10408
- }
10409
- }
10410
- const namespace = "http://www.w3.org/2000/svg";
10699
+ if (!blurValue && !shadowValue) return drawWithOpacity(source, opacity);
10411
10700
  const svgDocument = source.ownerDocument ?? document;
10412
- const element = (tag, attributes = {}) => {
10413
- const node = svgDocument.createElementNS(namespace, tag);
10414
- Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)));
10415
- return node;
10416
- };
10701
+ const element = (tag, attributes = {}) => createSvgElement(svgDocument, tag, attributes);
10417
10702
  const svg = element("svg", {
10418
10703
  width: source.width,
10419
10704
  height: source.height,
@@ -10455,39 +10740,13 @@
10455
10740
  }
10456
10741
  defs.appendChild(filterElement);
10457
10742
  svg.appendChild(defs);
10458
- let sourceUrl;
10459
- try {
10460
- sourceUrl = source.toDataURL("image/png");
10461
- if (sourceUrl === "data:,") throw new Error("Canvas size is unsupported");
10462
- } catch {
10463
- throw new FilterSurfaceError("Filter surface pixels cannot be serialized");
10464
- }
10465
10743
  svg.appendChild(element("image", {
10466
10744
  width: source.width,
10467
10745
  height: source.height,
10468
- href: sourceUrl,
10746
+ href: serializeSurface(source),
10469
10747
  filter: "url(#composited-filter)"
10470
10748
  }));
10471
- const imageWindow = svgDocument.defaultView;
10472
- const image = imageWindow?.Image ? new imageWindow.Image() : new Image();
10473
- let output;
10474
- try {
10475
- image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
10476
- await decodeSurface(image, signal);
10477
- throwIfAborted(signal);
10478
- output = canvasLike(source);
10479
- const context = output.getContext("2d");
10480
- if (!context) throw new FilterSurfaceError("Filter surface canvas is unavailable");
10481
- context.globalAlpha = opacity;
10482
- context.drawImage(image, 0, 0);
10483
- return output;
10484
- } catch (error) {
10485
- if (output) releaseSurface(output);
10486
- if (signal?.aborted) throwIfAborted(signal);
10487
- throw error instanceof FilterSurfaceError ? error : new FilterSurfaceError("Filter surface image cannot be drawn");
10488
- } finally {
10489
- image.removeAttribute("src");
10490
- }
10749
+ return drawSvgSurface(svg, source, opacity, signal);
10491
10750
  }
10492
10751
  function reserveSurface(budget, width, height) {
10493
10752
  const pixels = width * height * 4;
@@ -10511,7 +10770,7 @@
10511
10770
  }
10512
10771
  for (const child of container.elements) if (child.styles.isVisible()) bounds = union(bounds, surfaceBounds(child));
10513
10772
  const filter = parseFilterChain(styles.filter);
10514
- return expand(bounds, filter ? filterOutset(filter) : 0);
10773
+ return expand(bounds, filter ? filterOutset(filter, container.filterReferences) : 0);
10515
10774
  }
10516
10775
  function cropSurface(bounds, viewport, margin, scale) {
10517
10776
  const area = expand(viewport, margin);
@@ -12148,6 +12407,7 @@
12148
12407
  ctx.fillStyle = rasterized.lastColor;
12149
12408
  ctx.fillRect(area.left, area.top, area.width, area.height);
12150
12409
  ctx.drawImage(rasterized.canvas, area.left + cx - rx, area.top + cy - ry, rx * 2, ry * 2);
12410
+ releaseSurface(rasterized.canvas);
12151
12411
  };
12152
12412
  /** Draw one gradient layer filling the whole mask area. */
12153
12413
  const paintGradientLayer = (ctx, layer, area) => {
@@ -13002,7 +13262,8 @@
13002
13262
  async renderCompositedStack(stack, filter, hasMask, reflect, hasBlend = false) {
13003
13263
  const signal = this.options.signal;
13004
13264
  if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13005
- 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);
13265
+ const container = stack.element.container;
13266
+ 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);
13006
13267
  if (!bounds.width || !bounds.height) return true;
13007
13268
  const reserved = reserveSurface(this.surfaceBudget, Math.ceil(bounds.width * this.options.scale), Math.ceil(bounds.height * this.options.scale));
13008
13269
  if (!reserved) {
@@ -13024,7 +13285,13 @@
13024
13285
  source = new CanvasRenderer(this.context, options, stack.element, this.surfaceBudget);
13025
13286
  await source.renderStackContent(stack);
13026
13287
  source.effectsRenderer.applyEffects([]);
13027
- if (filter) filtered = await renderFilterSurface(source.canvas, filter, stack.element.container.styles.opacity, options.scale, signal);
13288
+ if (filter) filtered = await renderFilterSurface(source.canvas, filter, container.styles.opacity, options.scale, signal, {
13289
+ references: container.filterReferences,
13290
+ origin: {
13291
+ x: container.bounds.left - bounds.left,
13292
+ y: container.bounds.top - bounds.top
13293
+ }
13294
+ });
13028
13295
  const composited = filtered ?? source.canvas;
13029
13296
  if (hasMask) await this.applyMask(composited, stack.element.container.styles, bounds);
13030
13297
  if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
@@ -13061,7 +13328,10 @@
13061
13328
  maskCanvas.width = surface.width;
13062
13329
  maskCanvas.height = surface.height;
13063
13330
  const maskCtx = maskCanvas.getContext("2d");
13064
- if (!maskCtx) return;
13331
+ if (!maskCtx) {
13332
+ releaseSurface(maskCanvas);
13333
+ return;
13334
+ }
13065
13335
  maskCtx.scale(this.options.scale, this.options.scale);
13066
13336
  maskCtx.translate(-bounds.left, -bounds.top);
13067
13337
  try {
@@ -13073,6 +13343,7 @@
13073
13343
  }, async (url) => await this.context.cache.match(url));
13074
13344
  } catch (error) {
13075
13345
  this.context.logger.error(`Error rendering mask-image: ${error instanceof Error ? error.message : error}`);
13346
+ releaseSurface(maskCanvas);
13076
13347
  return;
13077
13348
  }
13078
13349
  ctx.save();
@@ -13082,6 +13353,7 @@
13082
13353
  ctx.drawImage(maskCanvas, 0, 0);
13083
13354
  } finally {
13084
13355
  ctx.restore();
13356
+ releaseSurface(maskCanvas);
13085
13357
  }
13086
13358
  }
13087
13359
  /**
@@ -13486,6 +13758,7 @@
13486
13758
  ctx.fillStyle = rasterized.lastColor;
13487
13759
  ctx.fillRect(0, 0, width, height);
13488
13760
  ctx.drawImage(rasterized.canvas, cx - rx, cy - ry, rx * 2, ry * 2);
13761
+ releaseSurface(rasterized.canvas);
13489
13762
  return canvas;
13490
13763
  }
13491
13764
  if (source.type === 5 || source.type === 6) {