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