html2canvas-pro 2.5.2 → 2.5.4

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
@@ -38,7 +38,7 @@ html2canvas-pro is a fork of [niklasvh/html2canvas](https://github.com/niklasvh/
38
38
  - `conic-gradient()`, `repeating-conic-gradient()` and `repeating-radial-gradient()` — complete gradient family
39
39
  - `text-emphasis` (CJK emphasis marks), `-webkit-box-reflect`, `image-set()`, `border-image-outset` / `border-image-width`
40
40
  - `accent-color`, `outline`, `-webkit-text-fill-color`, `isolation`
41
- - `filter` compositing for eligible layers — `blur()` / `drop-shadow()` rendered on a dedicated surface with correct layer `opacity` (see [filter support notes](docs/filter-support.md))
41
+ - `filter` chain compositing for eligible layers — the full standard filter function set rendered on a dedicated surface with correct layer `opacity` (see [filter support notes](docs/filter-support.md))
42
42
  - Faithful `box-shadow` rendering, including inset shadows, blur scaling, and shadows through transformed ancestors
43
43
  - `image-rendering` CSS property plus `imageSmoothing` / `imageSmoothingQuality` options for pixel-perfect output
44
44
  - Border image, counters & quotes, `direction`, `line-height`, `transform-origin`, and more — see the full [feature list](docs/features.md)
@@ -187,7 +187,7 @@ The project uses [pnpm](https://pnpm.io) (the version is pinned via the `package
187
187
  corepack pnpm install
188
188
  corepack pnpm build # tsc + Rolldown bundles (CJS/ESM/UMD)
189
189
  corepack pnpm unittest # Vitest unit tests
190
- corepack pnpm test # lint + unit tests + browser (Karma) tests
190
+ corepack pnpm test # lint + unit tests + browser reftests (Playwright)
191
191
  corepack pnpm docs:dev # VitePress dev server
192
192
  corepack pnpm docs:api # Generate the TypeDoc API reference
193
193
  ```
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * html2canvas-pro 2.5.2 <https://yorickshan.github.io/html2canvas-pro/>
2
+ * html2canvas-pro 2.5.4 <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
  */
@@ -6525,6 +6525,15 @@ const SHADOW_MASK_OFFSET = 1e4;
6525
6525
  */
6526
6526
  const CLONE_READY_TIMEOUT_MS = 3e4;
6527
6527
  /**
6528
+ * Total time (ms) to wait for the clone's external stylesheets (link and
6529
+ * @import chains) to apply. Shorter than the document readiness window: a
6530
+ * stylesheet that needs longer usually means a hung CDN, and rendering with
6531
+ * a 10s-old layout beats blocking the capture for 30s. A missed deadline
6532
+ * resolves with a warning (the capture continues with a possibly unstyled
6533
+ * clone).
6534
+ */
6535
+ const STYLESHEET_READY_TIMEOUT_MS = 1e4;
6536
+ /**
6528
6537
  * Hard ceiling (ms) for the cloned iframe's load event itself. If the event
6529
6538
  * never fires, the mount is rejected instead of hanging forever.
6530
6539
  */
@@ -8037,6 +8046,129 @@ const imageReady = (img) => {
8037
8046
  const imagesReady = (document) => {
8038
8047
  return Promise.all([].slice.call(document.images, 0).map(imageReady));
8039
8048
  };
8049
+ /**
8050
+ * Whether every CSS rule of the sheet (and of the @import chains it pulls
8051
+ * in) has been applied. A sheet is "attached" as soon as its rules are
8052
+ * readable; an external @import inside it may still be loading, which shows
8053
+ * up as an inaccessible CSSImportRule.sheet. `cssRules` can throw for
8054
+ * cross-origin sheets without a CORS-approved response — those rules cannot
8055
+ * be inspected, so the sheet counts as ready rather than blocking the render
8056
+ * forever.
8057
+ */
8058
+ const isSheetFullyApplied = (sheet, depth = 0) => {
8059
+ if (!sheet || depth > MAX_IMPORT_DEPTH) return true;
8060
+ let rules;
8061
+ try {
8062
+ rules = sheet.cssRules;
8063
+ } catch (_e) {
8064
+ return true;
8065
+ }
8066
+ if (!rules) return true;
8067
+ for (let i = 0; i < rules.length; i++) {
8068
+ 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
+ }
8072
+ }
8073
+ return true;
8074
+ };
8075
+ const MAX_IMPORT_DEPTH = 32;
8076
+ /** Collect every <link> in the document, including links inside shadow roots. */
8077
+ const collectLinks = (root, out) => {
8078
+ const links = root.querySelectorAll("link");
8079
+ for (let i = 0; i < links.length; i++) {
8080
+ const link = links[i];
8081
+ if (link) out.push(link);
8082
+ }
8083
+ const walker = (root.ownerDocument ?? root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8084
+ let current = walker.nextNode();
8085
+ while (current) {
8086
+ const shadowRoot = current.shadowRoot;
8087
+ if (shadowRoot) collectLinks(shadowRoot, out);
8088
+ current = walker.nextNode();
8089
+ }
8090
+ };
8091
+ /**
8092
+ * Wait until every external stylesheet of the clone has been applied or
8093
+ * definitively failed, or the readiness budget expires.
8094
+ *
8095
+ * The clone is mounted by adoptNode() into an already-closed document
8096
+ * (close() must precede adoptNode for Chrome uppercase class-name matching,
8097
+ * see the mount sequence below), so the cloned <link rel="stylesheet">
8098
+ * elements are inserted after the document finished loading: they neither
8099
+ * delay the iframe load event nor change readyState, and the iframeLoader
8100
+ * readiness poll cannot observe them. On a cold cache the first capture
8101
+ * could then measure the clone before its external CSS arrived and render
8102
+ * an unstyled layout (issue #245). Inline <style> clones apply
8103
+ * synchronously and document-level adopted stylesheets are serialised at
8104
+ * clone time, so neither needs this wait.
8105
+ *
8106
+ * Readiness is polled rather than purely event-driven because a <style>
8107
+ * clone serialised from `createStyleClone` re-fetches its external @import
8108
+ * rules without firing link load/error events, and shadow-root links are
8109
+ * equally event-hostile in some engines. The poll covers both; link events
8110
+ * remain as the settle path for plain <link> fetches.
8111
+ * @internal – exported for testing only.
8112
+ */
8113
+ const stylesheetsReady = (documentClone, logger, signal) => {
8114
+ if (signal?.aborted) return Promise.resolve();
8115
+ const pending = [];
8116
+ const links = [];
8117
+ collectLinks(documentClone, links);
8118
+ for (const link of links) {
8119
+ if (link.sheet) continue;
8120
+ const rel = (link.getAttribute("rel") ?? "").trim().toLowerCase().split(/\s+/);
8121
+ if (!rel.includes("stylesheet") || rel.includes("alternate") || link.disabled || !link.getAttribute("href")) continue;
8122
+ pending.push(new Promise((resolve) => {
8123
+ const href = link.href;
8124
+ const settle = () => {
8125
+ link.removeEventListener("load", settle);
8126
+ link.removeEventListener("error", settle);
8127
+ signal?.removeEventListener("abort", onAbort);
8128
+ clearInterval(timer);
8129
+ resolve();
8130
+ };
8131
+ const onAbort = () => settle();
8132
+ if (link.sheet) {
8133
+ resolve();
8134
+ return;
8135
+ }
8136
+ link.addEventListener("load", settle);
8137
+ link.addEventListener("error", settle);
8138
+ signal?.addEventListener("abort", onAbort, { once: true });
8139
+ let elapsed = 0;
8140
+ const timer = setInterval(() => {
8141
+ elapsed += 50;
8142
+ 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`);
8145
+ settle();
8146
+ }
8147
+ }, 50);
8148
+ }));
8149
+ }
8150
+ const styleElements = documentClone.getElementsByTagName("style");
8151
+ for (let i = 0; i < styleElements.length; i++) {
8152
+ const style = styleElements[i];
8153
+ const sheet = style?.sheet;
8154
+ if (!sheet || !style?.textContent?.includes("@import")) continue;
8155
+ if (isSheetFullyApplied(sheet)) continue;
8156
+ pending.push(new Promise((resolve) => {
8157
+ let elapsed = 0;
8158
+ const timer = setInterval(() => {
8159
+ elapsed += 50;
8160
+ 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`);
8163
+ clearInterval(timer);
8164
+ resolve();
8165
+ }
8166
+ }, 50);
8167
+ }));
8168
+ }
8169
+ if (pending.length === 0) return Promise.resolve();
8170
+ return Promise.all(pending).then(() => void 0);
8171
+ };
8040
8172
  const iframeLoader = (iframe, logger, signal) => {
8041
8173
  return new Promise((resolve, reject) => {
8042
8174
  const cloneWindow = iframe.contentWindow;
@@ -8079,6 +8211,15 @@ const iframeLoader = (iframe, logger, signal) => {
8079
8211
  });
8080
8212
  };
8081
8213
  /**
8214
+ * Reduce a doctype component to characters that are inert inside a
8215
+ * <!DOCTYPE ...> declaration. Doctype content is parsed as a declaration
8216
+ * token, never as HTML markup, but the fields originate from the captured
8217
+ * document — stripping markup delimiters and control characters here makes
8218
+ * that property locally verifiable (and keeps the sanitizer guarantee even
8219
+ * if a future serializer ever embeds the result elsewhere).
8220
+ */
8221
+ const sanitizeDoctypePart = (value) => value.replace(/[<>&"'`\\]|[\u0000-\u001f\u007f]/g, "");
8222
+ /**
8082
8223
  * Serialise a document type declaration to an HTML string.
8083
8224
  * @internal – exported for testing only, not part of the public API.
8084
8225
  */
@@ -8086,12 +8227,12 @@ const serializeDoctype = (doctype) => {
8086
8227
  let str = "";
8087
8228
  if (doctype) {
8088
8229
  str += "<!DOCTYPE ";
8089
- if (doctype.name) str += doctype.name;
8090
- if (doctype.internalSubset) str += " " + doctype.internalSubset.replace(/"/g, "&quot;").replace(/>/g, "&gt;");
8230
+ if (doctype.name) str += sanitizeDoctypePart(doctype.name);
8231
+ if (doctype.internalSubset) str += " " + sanitizeDoctypePart(doctype.internalSubset);
8091
8232
  if (doctype.publicId) {
8092
- str += " PUBLIC \"" + doctype.publicId.replace(/"/g, "&quot;") + "\"";
8093
- if (doctype.systemId) str += " \"" + doctype.systemId.replace(/"/g, "&quot;") + "\"";
8094
- } else if (doctype.systemId) str += " SYSTEM \"" + doctype.systemId.replace(/"/g, "&quot;") + "\"";
8233
+ str += " PUBLIC \"" + sanitizeDoctypePart(doctype.publicId) + "\"";
8234
+ if (doctype.systemId) str += " \"" + sanitizeDoctypePart(doctype.systemId) + "\"";
8235
+ } else if (doctype.systemId) str += " SYSTEM \"" + sanitizeDoctypePart(doctype.systemId) + "\"";
8095
8236
  str += ">";
8096
8237
  }
8097
8238
  return str;
@@ -8127,6 +8268,8 @@ const mountCloneInIFrame = (context, ownerDocument, windowSize, mount) => {
8127
8268
  const referenceElement = mount.clonedReferenceElement;
8128
8269
  if (typeof referenceElement === "undefined") throw new Error(`Error finding the ${mount.referenceElementName} in the cloned document`);
8129
8270
  throwIfAborted(mount.signal);
8271
+ await stylesheetWait;
8272
+ throwIfAborted(mount.signal);
8130
8273
  if (documentClone.fonts && documentClone.fonts.ready) await documentClone.fonts.ready;
8131
8274
  throwIfAborted(mount.signal);
8132
8275
  if (/(AppleWebKit)/g.test(navigator.userAgent)) {
@@ -8177,7 +8320,11 @@ const mountCloneInIFrame = (context, ownerDocument, windowSize, mount) => {
8177
8320
  const adoptedNode = documentClone.adoptNode(mount.documentElement);
8178
8321
  addBase(adoptedNode, baseUri);
8179
8322
  documentClone.replaceChild(adoptedNode, documentClone.documentElement);
8180
- return iframeLoad;
8323
+ const stylesheetWait = stylesheetsReady(documentClone, context.logger, mount.signal);
8324
+ return iframeLoad.catch((error) => {
8325
+ iframe.parentNode?.removeChild(iframe);
8326
+ throw error;
8327
+ });
8181
8328
  };
8182
8329
  //#endregion
8183
8330
  //#region src/dom/document-cloner.ts
@@ -13485,14 +13632,19 @@ var ForeignObjectRenderer = class {
13485
13632
  const svg = createForeignObjectSVG(this.options.width, this.options.height, 0, 0, element);
13486
13633
  const imagePromise = loadSerializedSVG(svg);
13487
13634
  restoreRootOffsets();
13488
- const img = await imagePromise;
13489
- if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13490
- if (this.options.backgroundColor) {
13491
- this.ctx.fillStyle = asString(this.options.backgroundColor);
13492
- this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13635
+ try {
13636
+ const img = await imagePromise;
13637
+ if (this.options.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
13638
+ if (this.options.backgroundColor) {
13639
+ this.ctx.fillStyle = asString(this.options.backgroundColor);
13640
+ this.ctx.fillRect(this.options.x, this.options.y, this.options.width, this.options.height);
13641
+ }
13642
+ this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13643
+ return this.canvas;
13644
+ } catch (error) {
13645
+ if (!this.options.canvas) releaseSurface(this.canvas);
13646
+ throw error;
13493
13647
  }
13494
- this.ctx.drawImage(img, this.options.x, this.options.y, this.options.width, this.options.height);
13495
- return this.canvas;
13496
13648
  }
13497
13649
  };
13498
13650
  /**
@@ -14048,19 +14200,17 @@ const renderElement = async (element, opts, config) => {
14048
14200
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
14049
14201
  const clonedElement = documentCloner.clonedReferenceElement;
14050
14202
  if (!clonedElement) throw new Error("Unable to find element in cloned iframe");
14051
- const container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14052
- perfMonitor.end("clone");
14053
- emitProgress(opts.onProgress, context, "clone", 30);
14054
- if (signal?.aborted) {
14055
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14056
- throwIfAborted(signal);
14057
- }
14058
- const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14059
- const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14060
- const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14203
+ let container;
14061
14204
  let canvas;
14062
14205
  let root;
14063
14206
  try {
14207
+ container = await documentCloner.toIFrame(ownerDocument, windowBounds);
14208
+ perfMonitor.end("clone");
14209
+ emitProgress(opts.onProgress, context, "clone", 30);
14210
+ throwIfAborted(signal);
14211
+ const { width, height, left, top } = isBodyElement(clonedElement) || isHTMLElement(clonedElement) ? parseDocumentSize(clonedElement.ownerDocument) : parseBounds(context, clonedElement);
14212
+ const backgroundColor = parseBackgroundColor(context, clonedElement, opts.backgroundColor);
14213
+ const renderOptions = assembleRenderOptions(opts, backgroundColor, left, top, width, height, defaultView.devicePixelRatio ?? 1);
14064
14214
  if (foreignObjectRendering) {
14065
14215
  context.logger.debug(`Document cloned, using foreign object rendering`);
14066
14216
  perfMonitor.start("render-foreignobject");
@@ -14094,7 +14244,7 @@ const renderElement = async (element, opts, config) => {
14094
14244
  try {
14095
14245
  if (root) root.restoreTree();
14096
14246
  } finally {
14097
- if (opts.removeContainer ?? true) DocumentCloner.destroy(container);
14247
+ if (container && (opts.removeContainer ?? true)) DocumentCloner.destroy(container);
14098
14248
  perfMonitor.end("cleanup");
14099
14249
  }
14100
14250
  }