html2canvas-pro 2.5.3 → 2.6.0

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.3 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 2.6.0 <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}`);
@@ -6525,6 +6553,15 @@ const SHADOW_MASK_OFFSET = 1e4;
6525
6553
  */
6526
6554
  const CLONE_READY_TIMEOUT_MS = 3e4;
6527
6555
  /**
6556
+ * Total time (ms) to wait for the clone's external stylesheets (link and
6557
+ * @import chains) to apply. Shorter than the document readiness window: a
6558
+ * stylesheet that needs longer usually means a hung CDN, and rendering with
6559
+ * a 10s-old layout beats blocking the capture for 30s. A missed deadline
6560
+ * resolves with a warning (the capture continues with a possibly unstyled
6561
+ * clone).
6562
+ */
6563
+ const STYLESHEET_READY_TIMEOUT_MS = 1e4;
6564
+ /**
6528
6565
  * Hard ceiling (ms) for the cloned iframe's load event itself. If the event
6529
6566
  * never fires, the mount is rejected instead of hanging forever.
6530
6567
  */
@@ -8038,7 +8075,49 @@ const imagesReady = (document) => {
8038
8075
  return Promise.all([].slice.call(document.images, 0).map(imageReady));
8039
8076
  };
8040
8077
  /**
8041
- * Wait until every external stylesheet link of the clone has been applied or
8078
+ * Whether every CSS rule of the sheet (and of the @import chains it pulls
8079
+ * in) has been applied. A sheet is "attached" as soon as its rules are
8080
+ * readable; an external @import inside it may still be loading, which shows
8081
+ * up as an inaccessible CSSImportRule.sheet. `cssRules` can throw for
8082
+ * cross-origin sheets without a CORS-approved response — those rules cannot
8083
+ * be inspected, so the sheet counts as ready rather than blocking the render
8084
+ * forever.
8085
+ */
8086
+ const isSheetFullyApplied = (sheet, depth = 0) => {
8087
+ if (!sheet || depth > MAX_IMPORT_DEPTH) return true;
8088
+ let rules;
8089
+ try {
8090
+ rules = sheet.cssRules;
8091
+ } catch (_e) {
8092
+ return true;
8093
+ }
8094
+ if (!rules) return true;
8095
+ for (let i = 0; i < rules.length; i++) {
8096
+ const rule = rules[i];
8097
+ if (!rule || !("styleSheet" in rule)) continue;
8098
+ const inner = rule.styleSheet;
8099
+ if (!inner || !isSheetFullyApplied(inner, depth + 1)) return false;
8100
+ }
8101
+ return true;
8102
+ };
8103
+ const MAX_IMPORT_DEPTH = 32;
8104
+ /** Collect every <link> in the document, including links inside shadow roots. */
8105
+ const collectLinks = (root, out) => {
8106
+ const links = root.querySelectorAll("link");
8107
+ for (let i = 0; i < links.length; i++) {
8108
+ const link = links[i];
8109
+ if (link) out.push(link);
8110
+ }
8111
+ const walker = (root.ownerDocument ?? root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8112
+ let current = walker.nextNode();
8113
+ while (current) {
8114
+ const shadowRoot = current.shadowRoot;
8115
+ if (shadowRoot) collectLinks(shadowRoot, out);
8116
+ current = walker.nextNode();
8117
+ }
8118
+ };
8119
+ /**
8120
+ * Wait until every external stylesheet of the clone has been applied or
8042
8121
  * definitively failed, or the readiness budget expires.
8043
8122
  *
8044
8123
  * The clone is mounted by adoptNode() into an already-closed document
@@ -8051,35 +8130,68 @@ const imagesReady = (document) => {
8051
8130
  * an unstyled layout (issue #245). Inline <style> clones apply
8052
8131
  * synchronously and document-level adopted stylesheets are serialised at
8053
8132
  * clone time, so neither needs this wait.
8133
+ *
8134
+ * Readiness is polled rather than purely event-driven because a <style>
8135
+ * clone serialised from `createStyleClone` re-fetches its external @import
8136
+ * rules without firing link load/error events, and shadow-root links are
8137
+ * equally event-hostile in some engines. The poll covers both; link events
8138
+ * remain as the settle path for plain <link> fetches.
8054
8139
  * @internal – exported for testing only.
8055
8140
  */
8056
- const stylesheetsReady = (documentClone, logger, signal) => {
8141
+ const stylesheetsReady = (documentClone, logger, signal, timeoutMs = STYLESHEET_READY_TIMEOUT_MS) => {
8057
8142
  if (signal?.aborted) return Promise.resolve();
8058
8143
  const pending = [];
8059
- const links = documentClone.getElementsByTagName("link");
8060
- for (let i = 0; i < links.length; i++) {
8061
- const link = links[i];
8062
- if (!link || link.sheet) continue;
8144
+ const links = [];
8145
+ collectLinks(documentClone, links);
8146
+ for (const link of links) {
8147
+ if (link.sheet) continue;
8063
8148
  const rel = (link.getAttribute("rel") ?? "").trim().toLowerCase().split(/\s+/);
8064
8149
  if (!rel.includes("stylesheet") || rel.includes("alternate") || link.disabled || !link.getAttribute("href")) continue;
8065
8150
  pending.push(new Promise((resolve) => {
8066
- let timer;
8151
+ const href = link.href;
8067
8152
  const settle = () => {
8068
8153
  link.removeEventListener("load", settle);
8069
8154
  link.removeEventListener("error", settle);
8070
8155
  signal?.removeEventListener("abort", onAbort);
8071
- clearTimeout(timer);
8156
+ clearInterval(timer);
8072
8157
  resolve();
8073
8158
  };
8074
8159
  const onAbort = () => settle();
8075
- timer = setTimeout(() => {
8076
- logger?.warn(`Stylesheet ${link.href} did not finish loading within ${CLONE_READY_TIMEOUT_MS / 1e3}s; continuing with a possibly unstyled clone`);
8077
- settle();
8078
- }, CLONE_READY_TIMEOUT_MS);
8160
+ if (link.sheet) {
8161
+ resolve();
8162
+ return;
8163
+ }
8079
8164
  link.addEventListener("load", settle);
8080
8165
  link.addEventListener("error", settle);
8081
8166
  signal?.addEventListener("abort", onAbort, { once: true });
8082
- if (link.sheet) settle();
8167
+ let elapsed = 0;
8168
+ const timer = setInterval(() => {
8169
+ elapsed += 50;
8170
+ if (link.sheet) settle();
8171
+ else if (elapsed >= timeoutMs) {
8172
+ logger?.warn(`Stylesheet ${href} did not finish loading within ${timeoutMs / 1e3}s; continuing with a possibly unstyled clone`);
8173
+ settle();
8174
+ }
8175
+ }, 50);
8176
+ }));
8177
+ }
8178
+ const styleElements = documentClone.getElementsByTagName("style");
8179
+ for (let i = 0; i < styleElements.length; i++) {
8180
+ const style = styleElements[i];
8181
+ const sheet = style?.sheet;
8182
+ if (!sheet || !style?.textContent?.includes("@import")) continue;
8183
+ if (isSheetFullyApplied(sheet)) continue;
8184
+ pending.push(new Promise((resolve) => {
8185
+ let elapsed = 0;
8186
+ const timer = setInterval(() => {
8187
+ elapsed += 50;
8188
+ const currentSheet = style.sheet;
8189
+ if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >= timeoutMs) {
8190
+ 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`);
8191
+ clearInterval(timer);
8192
+ resolve();
8193
+ }
8194
+ }, 50);
8083
8195
  }));
8084
8196
  }
8085
8197
  if (pending.length === 0) return Promise.resolve();
@@ -8237,7 +8349,10 @@ const mountCloneInIFrame = (context, ownerDocument, windowSize, mount) => {
8237
8349
  addBase(adoptedNode, baseUri);
8238
8350
  documentClone.replaceChild(adoptedNode, documentClone.documentElement);
8239
8351
  const stylesheetWait = stylesheetsReady(documentClone, context.logger, mount.signal);
8240
- return iframeLoad;
8352
+ return iframeLoad.catch((error) => {
8353
+ iframe.parentNode?.removeChild(iframe);
8354
+ throw error;
8355
+ });
8241
8356
  };
8242
8357
  //#endregion
8243
8358
  //#region src/dom/document-cloner.ts
@@ -9760,7 +9875,7 @@ const parseStackTree = (parent, stackingContext, realStackingContext, listItems)
9760
9875
  if (treatAsRealStackingContext || createsStackingContext) {
9761
9876
  const parentStack = treatAsRealStackingContext || child.styles.isPositioned() ? realStackingContext : stackingContext;
9762
9877
  const stack = new StackingContext(paintContainer);
9763
- if (child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
9878
+ if (treatAsRealStackingContext || child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
9764
9879
  const order = child.styles.zIndex.order;
9765
9880
  if (order < 0) {
9766
9881
  let index = 0;
@@ -12058,6 +12173,7 @@ const paintRadialLayer = (ctx, layer, area) => {
12058
12173
  ctx.fillStyle = rasterized.lastColor;
12059
12174
  ctx.fillRect(area.left, area.top, area.width, area.height);
12060
12175
  ctx.drawImage(rasterized.canvas, area.left + cx - rx, area.top + cy - ry, rx * 2, ry * 2);
12176
+ releaseSurface(rasterized.canvas);
12061
12177
  };
12062
12178
  /** Draw one gradient layer filling the whole mask area. */
12063
12179
  const paintGradientLayer = (ctx, layer, area) => {
@@ -12971,7 +13087,10 @@ var CanvasRenderer = class CanvasRenderer {
12971
13087
  maskCanvas.width = surface.width;
12972
13088
  maskCanvas.height = surface.height;
12973
13089
  const maskCtx = maskCanvas.getContext("2d");
12974
- if (!maskCtx) return;
13090
+ if (!maskCtx) {
13091
+ releaseSurface(maskCanvas);
13092
+ return;
13093
+ }
12975
13094
  maskCtx.scale(this.options.scale, this.options.scale);
12976
13095
  maskCtx.translate(-bounds.left, -bounds.top);
12977
13096
  try {
@@ -12983,6 +13102,7 @@ var CanvasRenderer = class CanvasRenderer {
12983
13102
  }, async (url) => await this.context.cache.match(url));
12984
13103
  } catch (error) {
12985
13104
  this.context.logger.error(`Error rendering mask-image: ${error instanceof Error ? error.message : error}`);
13105
+ releaseSurface(maskCanvas);
12986
13106
  return;
12987
13107
  }
12988
13108
  ctx.save();
@@ -12992,6 +13112,7 @@ var CanvasRenderer = class CanvasRenderer {
12992
13112
  ctx.drawImage(maskCanvas, 0, 0);
12993
13113
  } finally {
12994
13114
  ctx.restore();
13115
+ releaseSurface(maskCanvas);
12995
13116
  }
12996
13117
  }
12997
13118
  /**
@@ -13396,6 +13517,7 @@ var CanvasRenderer = class CanvasRenderer {
13396
13517
  ctx.fillStyle = rasterized.lastColor;
13397
13518
  ctx.fillRect(0, 0, width, height);
13398
13519
  ctx.drawImage(rasterized.canvas, cx - rx, cy - ry, rx * 2, ry * 2);
13520
+ releaseSurface(rasterized.canvas);
13399
13521
  return canvas;
13400
13522
  }
13401
13523
  if (source.type === 5 || source.type === 6) {
@@ -13545,14 +13667,19 @@ var ForeignObjectRenderer = class {
13545
13667
  const svg = createForeignObjectSVG(this.options.width, this.options.height, 0, 0, element);
13546
13668
  const imagePromise = loadSerializedSVG(svg);
13547
13669
  restoreRootOffsets();
13548
- const img = await imagePromise;
13549
- if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13550
- if (this.options.backgroundColor) {
13551
- this.ctx.fillStyle = asString(this.options.backgroundColor);
13552
- this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13670
+ try {
13671
+ const img = await imagePromise;
13672
+ if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13673
+ if (this.options.backgroundColor) {
13674
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
13675
+ this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13676
+ }
13677
+ this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13678
+ return this.canvas;
13679
+ } catch (error) {
13680
+ if (!this.options.canvas) releaseSurface(this.canvas);
13681
+ throw error;
13553
13682
  }
13554
- this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13555
- return this.canvas;
13556
13683
  }
13557
13684
  };
13558
13685
  /**
@@ -14108,19 +14235,17 @@ const renderElement = async (element, opts, config) => {
14108
14235
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
14109
14236
  const clonedElement = documentCloner.clonedReferenceElement;
14110
14237
  if (!clonedElement) throw new Error("Unable to find element in cloned iframe");
14111
- const container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14112
- perfMonitor.end("clone");
14113
- emitProgress(opts.onProgress, context, "clone", 30);
14114
- if (signal?.aborted) {
14115
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14116
- throwIfAborted(signal);
14117
- }
14118
- const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14119
- const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14120
- const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14238
+ let container;
14121
14239
  let canvas;
14122
14240
  let root;
14123
14241
  try {
14242
+ container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14243
+ perfMonitor.end("clone");
14244
+ emitProgress(opts.onProgress, context, "clone", 30);
14245
+ throwIfAborted(signal);
14246
+ const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14247
+ const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14248
+ const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14124
14249
  if (foreignObjectRendering) {
14125
14250
  context.logger.debug(`Document cloned, using foreign object rendering`);
14126
14251
  perfMonitor.start("render-foreignobject");
@@ -14154,7 +14279,7 @@ const renderElement = async (element, opts, config) => {
14154
14279
  try {
14155
14280
  if (root) root.restoreTree();
14156
14281
  } finally {
14157
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14282
+ if (container && (opts.removeContainer ?? true)) DocumentCloner.destroy(container);
14158
14283
  perfMonitor.end("cleanup");
14159
14284
  }
14160
14285
  }