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
  */
@@ -242,6 +242,30 @@ var Validator = class {
242
242
  };
243
243
  }
244
244
  /**
245
+ * Validate the backgroundColor option
246
+ *
247
+ * @param backgroundColor - CSS color string or null (transparent)
248
+ * @returns Validation result
249
+ */
250
+ validateBackgroundColor(backgroundColor) {
251
+ if (backgroundColor === null) return {
252
+ valid: true,
253
+ sanitized: null
254
+ };
255
+ if (typeof backgroundColor !== "string" || backgroundColor.length === 0) return {
256
+ valid: false,
257
+ error: "backgroundColor must be a CSS color string or null"
258
+ };
259
+ if (/[^a-zA-Z0-9%(),.#\s/-]/.test(backgroundColor)) return {
260
+ valid: false,
261
+ error: `backgroundColor contains characters that cannot appear in a CSS color: ${backgroundColor}`
262
+ };
263
+ return {
264
+ valid: true,
265
+ sanitized: backgroundColor
266
+ };
267
+ }
268
+ /**
245
269
  * Validate image timeout
246
270
  *
247
271
  * @param timeout - Timeout in milliseconds
@@ -386,6 +410,10 @@ var Validator = class {
386
410
  const nonceResult = this.validateCspNonce(options.cspNonce);
387
411
  if (!nonceResult.valid) errors.push(`CSP nonce: ${nonceResult.error}`);
388
412
  }
413
+ if (options.backgroundColor !== void 0) {
414
+ const colorResult = this.validateBackgroundColor(options.backgroundColor);
415
+ if (!colorResult.valid) errors.push(`backgroundColor: ${colorResult.error}`);
416
+ }
389
417
  if (this.config.customValidator) {
390
418
  const customResult = this.config.customValidator(options, "options");
391
419
  if (!customResult.valid) errors.push(`Custom validation: ${customResult.error}`);
@@ -6521,6 +6549,15 @@ const SHADOW_MASK_OFFSET = 1e4;
6521
6549
  */
6522
6550
  const CLONE_READY_TIMEOUT_MS = 3e4;
6523
6551
  /**
6552
+ * Total time (ms) to wait for the clone's external stylesheets (link and
6553
+ * @import chains) to apply. Shorter than the document readiness window: a
6554
+ * stylesheet that needs longer usually means a hung CDN, and rendering with
6555
+ * a 10s-old layout beats blocking the capture for 30s. A missed deadline
6556
+ * resolves with a warning (the capture continues with a possibly unstyled
6557
+ * clone).
6558
+ */
6559
+ const STYLESHEET_READY_TIMEOUT_MS = 1e4;
6560
+ /**
6524
6561
  * Hard ceiling (ms) for the cloned iframe's load event itself. If the event
6525
6562
  * never fires, the mount is rejected instead of hanging forever.
6526
6563
  */
@@ -8034,7 +8071,49 @@ const imagesReady = (document) => {
8034
8071
  return Promise.all([].slice.call(document.images, 0).map(imageReady));
8035
8072
  };
8036
8073
  /**
8037
- * Wait until every external stylesheet link of the clone has been applied or
8074
+ * Whether every CSS rule of the sheet (and of the @import chains it pulls
8075
+ * in) has been applied. A sheet is "attached" as soon as its rules are
8076
+ * readable; an external @import inside it may still be loading, which shows
8077
+ * up as an inaccessible CSSImportRule.sheet. `cssRules` can throw for
8078
+ * cross-origin sheets without a CORS-approved response — those rules cannot
8079
+ * be inspected, so the sheet counts as ready rather than blocking the render
8080
+ * forever.
8081
+ */
8082
+ const isSheetFullyApplied = (sheet, depth = 0) => {
8083
+ if (!sheet || depth > MAX_IMPORT_DEPTH) return true;
8084
+ let rules;
8085
+ try {
8086
+ rules = sheet.cssRules;
8087
+ } catch (_e) {
8088
+ return true;
8089
+ }
8090
+ if (!rules) return true;
8091
+ for (let i = 0; i < rules.length; i++) {
8092
+ const rule = rules[i];
8093
+ if (!rule || !("styleSheet" in rule)) continue;
8094
+ const inner = rule.styleSheet;
8095
+ if (!inner || !isSheetFullyApplied(inner, depth + 1)) return false;
8096
+ }
8097
+ return true;
8098
+ };
8099
+ const MAX_IMPORT_DEPTH = 32;
8100
+ /** Collect every <link> in the document, including links inside shadow roots. */
8101
+ const collectLinks = (root, out) => {
8102
+ const links = root.querySelectorAll("link");
8103
+ for (let i = 0; i < links.length; i++) {
8104
+ const link = links[i];
8105
+ if (link) out.push(link);
8106
+ }
8107
+ const walker = (root.ownerDocument ?? root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8108
+ let current = walker.nextNode();
8109
+ while (current) {
8110
+ const shadowRoot = current.shadowRoot;
8111
+ if (shadowRoot) collectLinks(shadowRoot, out);
8112
+ current = walker.nextNode();
8113
+ }
8114
+ };
8115
+ /**
8116
+ * Wait until every external stylesheet of the clone has been applied or
8038
8117
  * definitively failed, or the readiness budget expires.
8039
8118
  *
8040
8119
  * The clone is mounted by adoptNode() into an already-closed document
@@ -8047,35 +8126,68 @@ const imagesReady = (document) => {
8047
8126
  * an unstyled layout (issue #245). Inline <style> clones apply
8048
8127
  * synchronously and document-level adopted stylesheets are serialised at
8049
8128
  * clone time, so neither needs this wait.
8129
+ *
8130
+ * Readiness is polled rather than purely event-driven because a <style>
8131
+ * clone serialised from `createStyleClone` re-fetches its external @import
8132
+ * rules without firing link load/error events, and shadow-root links are
8133
+ * equally event-hostile in some engines. The poll covers both; link events
8134
+ * remain as the settle path for plain <link> fetches.
8050
8135
  * @internal – exported for testing only.
8051
8136
  */
8052
- const stylesheetsReady = (documentClone, logger, signal) => {
8137
+ const stylesheetsReady = (documentClone, logger, signal, timeoutMs = STYLESHEET_READY_TIMEOUT_MS) => {
8053
8138
  if (signal?.aborted) return Promise.resolve();
8054
8139
  const pending = [];
8055
- const links = documentClone.getElementsByTagName("link");
8056
- for (let i = 0; i < links.length; i++) {
8057
- const link = links[i];
8058
- if (!link || link.sheet) continue;
8140
+ const links = [];
8141
+ collectLinks(documentClone, links);
8142
+ for (const link of links) {
8143
+ if (link.sheet) continue;
8059
8144
  const rel = (link.getAttribute("rel") ?? "").trim().toLowerCase().split(/\s+/);
8060
8145
  if (!rel.includes("stylesheet") || rel.includes("alternate") || link.disabled || !link.getAttribute("href")) continue;
8061
8146
  pending.push(new Promise((resolve) => {
8062
- let timer;
8147
+ const href = link.href;
8063
8148
  const settle = () => {
8064
8149
  link.removeEventListener("load", settle);
8065
8150
  link.removeEventListener("error", settle);
8066
8151
  signal?.removeEventListener("abort", onAbort);
8067
- clearTimeout(timer);
8152
+ clearInterval(timer);
8068
8153
  resolve();
8069
8154
  };
8070
8155
  const onAbort = () => settle();
8071
- timer = setTimeout(() => {
8072
- logger?.warn(`Stylesheet ${link.href} did not finish loading within ${CLONE_READY_TIMEOUT_MS / 1e3}s; continuing with a possibly unstyled clone`);
8073
- settle();
8074
- }, CLONE_READY_TIMEOUT_MS);
8156
+ if (link.sheet) {
8157
+ resolve();
8158
+ return;
8159
+ }
8075
8160
  link.addEventListener("load", settle);
8076
8161
  link.addEventListener("error", settle);
8077
8162
  signal?.addEventListener("abort", onAbort, { once: true });
8078
- if (link.sheet) settle();
8163
+ let elapsed = 0;
8164
+ const timer = setInterval(() => {
8165
+ elapsed += 50;
8166
+ if (link.sheet) settle();
8167
+ else if (elapsed >= timeoutMs) {
8168
+ logger?.warn(`Stylesheet ${href} did not finish loading within ${timeoutMs / 1e3}s; continuing with a possibly unstyled clone`);
8169
+ settle();
8170
+ }
8171
+ }, 50);
8172
+ }));
8173
+ }
8174
+ const styleElements = documentClone.getElementsByTagName("style");
8175
+ for (let i = 0; i < styleElements.length; i++) {
8176
+ const style = styleElements[i];
8177
+ const sheet = style?.sheet;
8178
+ if (!sheet || !style?.textContent?.includes("@import")) continue;
8179
+ if (isSheetFullyApplied(sheet)) continue;
8180
+ pending.push(new Promise((resolve) => {
8181
+ let elapsed = 0;
8182
+ const timer = setInterval(() => {
8183
+ elapsed += 50;
8184
+ const currentSheet = style.sheet;
8185
+ if (currentSheet && isSheetFullyApplied(currentSheet) || elapsed >= timeoutMs) {
8186
+ 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`);
8187
+ clearInterval(timer);
8188
+ resolve();
8189
+ }
8190
+ }, 50);
8079
8191
  }));
8080
8192
  }
8081
8193
  if (pending.length === 0) return Promise.resolve();
@@ -8233,7 +8345,10 @@ const mountCloneInIFrame = (context, ownerDocument, windowSize, mount) => {
8233
8345
  addBase(adoptedNode, baseUri);
8234
8346
  documentClone.replaceChild(adoptedNode, documentClone.documentElement);
8235
8347
  const stylesheetWait = stylesheetsReady(documentClone, context.logger, mount.signal);
8236
- return iframeLoad;
8348
+ return iframeLoad.catch((error) => {
8349
+ iframe.parentNode?.removeChild(iframe);
8350
+ throw error;
8351
+ });
8237
8352
  };
8238
8353
  //#endregion
8239
8354
  //#region src/dom/document-cloner.ts
@@ -9756,7 +9871,7 @@ const parseStackTree = (parent, stackingContext, realStackingContext, listItems)
9756
9871
  if (treatAsRealStackingContext || createsStackingContext) {
9757
9872
  const parentStack = treatAsRealStackingContext || child.styles.isPositioned() ? realStackingContext : stackingContext;
9758
9873
  const stack = new StackingContext(paintContainer);
9759
- if (child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
9874
+ if (treatAsRealStackingContext || child.styles.isPositioned() || child.styles.opacity < 1 || child.styles.isTransformed()) {
9760
9875
  const order = child.styles.zIndex.order;
9761
9876
  if (order < 0) {
9762
9877
  let index = 0;
@@ -12054,6 +12169,7 @@ const paintRadialLayer = (ctx, layer, area) => {
12054
12169
  ctx.fillStyle = rasterized.lastColor;
12055
12170
  ctx.fillRect(area.left, area.top, area.width, area.height);
12056
12171
  ctx.drawImage(rasterized.canvas, area.left + cx - rx, area.top + cy - ry, rx * 2, ry * 2);
12172
+ releaseSurface(rasterized.canvas);
12057
12173
  };
12058
12174
  /** Draw one gradient layer filling the whole mask area. */
12059
12175
  const paintGradientLayer = (ctx, layer, area) => {
@@ -12967,7 +13083,10 @@ var CanvasRenderer = class CanvasRenderer {
12967
13083
  maskCanvas.width = surface.width;
12968
13084
  maskCanvas.height = surface.height;
12969
13085
  const maskCtx = maskCanvas.getContext("2d");
12970
- if (!maskCtx) return;
13086
+ if (!maskCtx) {
13087
+ releaseSurface(maskCanvas);
13088
+ return;
13089
+ }
12971
13090
  maskCtx.scale(this.options.scale, this.options.scale);
12972
13091
  maskCtx.translate(-bounds.left, -bounds.top);
12973
13092
  try {
@@ -12979,6 +13098,7 @@ var CanvasRenderer = class CanvasRenderer {
12979
13098
  }, async (url) => await this.context.cache.match(url));
12980
13099
  } catch (error) {
12981
13100
  this.context.logger.error(`Error rendering mask-image: ${error instanceof Error ? error.message : error}`);
13101
+ releaseSurface(maskCanvas);
12982
13102
  return;
12983
13103
  }
12984
13104
  ctx.save();
@@ -12988,6 +13108,7 @@ var CanvasRenderer = class CanvasRenderer {
12988
13108
  ctx.drawImage(maskCanvas, 0, 0);
12989
13109
  } finally {
12990
13110
  ctx.restore();
13111
+ releaseSurface(maskCanvas);
12991
13112
  }
12992
13113
  }
12993
13114
  /**
@@ -13392,6 +13513,7 @@ var CanvasRenderer = class CanvasRenderer {
13392
13513
  ctx.fillStyle = rasterized.lastColor;
13393
13514
  ctx.fillRect(0, 0, width, height);
13394
13515
  ctx.drawImage(rasterized.canvas, cx - rx, cy - ry, rx * 2, ry * 2);
13516
+ releaseSurface(rasterized.canvas);
13395
13517
  return canvas;
13396
13518
  }
13397
13519
  if (source.type === 5 || source.type === 6) {
@@ -13541,14 +13663,19 @@ var ForeignObjectRenderer = class {
13541
13663
  const svg = createForeignObjectSVG(this.options.width, this.options.height, 0, 0, element);
13542
13664
  const imagePromise = loadSerializedSVG(svg);
13543
13665
  restoreRootOffsets();
13544
- const img = await imagePromise;
13545
- if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13546
- if (this.options.backgroundColor) {
13547
- this.ctx.fillStyle = asString(this.options.backgroundColor);
13548
- this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13666
+ try {
13667
+ const img = await imagePromise;
13668
+ if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13669
+ if (this.options.backgroundColor) {
13670
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
13671
+ this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13672
+ }
13673
+ this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13674
+ return this.canvas;
13675
+ } catch (error) {
13676
+ if (!this.options.canvas) releaseSurface(this.canvas);
13677
+ throw error;
13549
13678
  }
13550
- this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13551
- return this.canvas;
13552
13679
  }
13553
13680
  };
13554
13681
  /**
@@ -14104,19 +14231,17 @@ const renderElement = async (element, opts, config) => {
14104
14231
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
14105
14232
  const clonedElement = documentCloner.clonedReferenceElement;
14106
14233
  if (!clonedElement) throw new Error("Unable to find element in cloned iframe");
14107
- const container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14108
- perfMonitor.end("clone");
14109
- emitProgress(opts.onProgress, context, "clone", 30);
14110
- if (signal?.aborted) {
14111
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14112
- throwIfAborted(signal);
14113
- }
14114
- const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14115
- const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14116
- const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14234
+ let container;
14117
14235
  let canvas;
14118
14236
  let root;
14119
14237
  try {
14238
+ container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14239
+ perfMonitor.end("clone");
14240
+ emitProgress(opts.onProgress, context, "clone", 30);
14241
+ throwIfAborted(signal);
14242
+ const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14243
+ const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14244
+ const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14120
14245
  if (foreignObjectRendering) {
14121
14246
  context.logger.debug(`Document cloned, using foreign object rendering`);
14122
14247
  perfMonitor.start("render-foreignobject");
@@ -14150,7 +14275,7 @@ const renderElement = async (element, opts, config) => {
14150
14275
  try {
14151
14276
  if (root) root.restoreTree();
14152
14277
  } finally {
14153
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14278
+ if (container && (opts.removeContainer ?? true)) DocumentCloner.destroy(container);
14154
14279
  perfMonitor.end("cleanup");
14155
14280
  }
14156
14281
  }