Skip to content

Commit e8fd20e

Browse files
authored
fix: patch html-to-image for PNG export font crash and tainted canvas (#8754)
## Summary Fixes #2821 I tested a large PNG notebook download on safari, firefox and chrome and faced different issues. - Patch `html-to-image@1.11.13` to fix `normalizeFontFamily` crash when `fontFamily` is undefined on `@font-face` rules (Firefox: `TypeError: can't access property "trim", font is undefined`) - Add `imagePlaceholder` to default `html-to-image` options so cross-origin images that fail to embed fall back to a transparent pixel instead of leaving external URLs that taint the canvas (`SecurityError: Tainted canvases may not be exported`) - Improve error logging in `downloadHTMLAsImage` to surface the actual error message
1 parent f36f68a commit e8fd20e

File tree

5 files changed

+108
-32
lines changed

5 files changed

+108
-32
lines changed

frontend/src/utils/__tests__/download.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,8 @@ describe("downloadHTMLAsImage", () => {
437437
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
438438

439439
expect(toast).toHaveBeenCalledWith({
440-
title: "Error",
441-
description: "Failed to download as PNG.",
440+
title: "Failed to download as PNG",
441+
description: "Failed",
442442
variant: "danger",
443443
});
444444
});

frontend/src/utils/download.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,11 @@ export async function downloadHTMLAsImage(opts: {
156156
// Get screenshot
157157
const dataUrl = await toPng(element);
158158
downloadByURL(dataUrl, Filenames.toPNG(filename));
159-
} catch {
159+
} catch (error) {
160+
Logger.error("Error downloading as PNG", error);
160161
toast({
161-
title: "Error",
162-
description: "Failed to download as PNG.",
162+
title: "Failed to download as PNG",
163+
description: prettyError(error),
163164
variant: "danger",
164165
});
165166
} finally {

frontend/src/utils/html-to-image.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export const necessaryStyleProperties = [
140140
"cursor",
141141
];
142142

143+
// 1x1 transparent PNG as a fallback for images that fail to embed (e.g., cross-origin).
144+
// Without this, failed embeds leave external URLs in the cloned DOM, which taints the canvas.
145+
const TRANSPARENT_PIXEL =
146+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==";
147+
143148
/**
144149
* Default options for html-to-image conversions.
145150
* These handle common edge cases like filtering out toolbars and logging errors.
@@ -162,6 +167,7 @@ export const defaultHtmlToImageOptions: HtmlToImageOptions = {
162167
return true;
163168
}
164169
},
170+
imagePlaceholder: TRANSPARENT_PIXEL,
165171
onImageErrorHandler: (event) => {
166172
Logger.error("Error loading image:", event);
167173
},

patches/html-to-image@1.11.13.patch

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,59 @@
11
diff --git a/es/clone-node.js b/es/clone-node.js
2-
index 114cbf214f65cda237dbba67458c2b68fadf421e..2f31795bdb0f81b6a435b630a475b1ffe9f1d666 100644
2+
index 114cbf214f65cda237dbba67458c2b68fadf421e..8aaec57528ffc1f912c6f62ab4fbbf717cb104b2 100644
33
--- a/es/clone-node.js
44
+++ b/es/clone-node.js
5-
@@ -59,10 +59,6 @@ async function cloneChildren(nativeNode, clonedNode, options) {
5+
@@ -59,9 +59,15 @@ async function cloneChildren(nativeNode, clonedNode, options) {
66
if (isSlotElement(nativeNode) && nativeNode.assignedNodes) {
77
children = toArray(nativeNode.assignedNodes());
88
}
99
- else if (isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
1010
- ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body)) {
1111
- children = toArray(nativeNode.contentDocument.body.childNodes);
12-
- }
12+
+ else if (isInstanceOfElement(nativeNode, HTMLIFrameElement)) {
13+
+ try {
14+
+ if ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body) {
15+
+ children = toArray(nativeNode.contentDocument.body.childNodes);
16+
+ }
17+
+ }
18+
+ catch (_e) {
19+
+ // Cross-origin iframes cannot be accessed; fall through to clone as-is
20+
+ }
21+
}
1322
else {
1423
children = toArray(((_b = nativeNode.shadowRoot) !== null && _b !== void 0 ? _b : nativeNode).childNodes);
15-
}
1624
diff --git a/es/embed-webfonts.js b/es/embed-webfonts.js
17-
index f6b0d96829c8a3301c142cacd07079e85b1f64fd..f5bfa86bc18c8314ad565c73c7dfab87728f3428 100644
25+
index f6b0d96829c8a3301c142cacd07079e85b1f64fd..c0eba90427585cefadb3baab8b05e58b8115e34c 100644
1826
--- a/es/embed-webfonts.js
1927
+++ b/es/embed-webfonts.js
20-
@@ -190,9 +190,12 @@ export async function embedWebFonts(clonedNode, options) {
28+
@@ -153,15 +153,17 @@ async function parseWebFontRules(node, options) {
29+
return getWebFontRules(cssRules);
30+
}
31+
function normalizeFontFamily(font) {
32+
- return font.trim().replace(/["']/g, '');
33+
+ return (font || '').trim().replace(/["']/g, '');
34+
}
35+
function getUsedFonts(node) {
36+
const fonts = new Set();
37+
function traverse(node) {
38+
const fontFamily = node.style.fontFamily || getComputedStyle(node).fontFamily;
39+
- fontFamily.split(',').forEach((font) => {
40+
- fonts.add(normalizeFontFamily(font));
41+
- });
42+
+ if (fontFamily) {
43+
+ fontFamily.split(',').forEach((font) => {
44+
+ fonts.add(normalizeFontFamily(font));
45+
+ });
46+
+ }
47+
Array.from(node.children).forEach((child) => {
48+
if (child instanceof HTMLElement) {
49+
traverse(child);
50+
@@ -190,9 +192,12 @@ export async function embedWebFonts(clonedNode, options) {
2151
: options.skipFonts
2252
? null
2353
: await getWebFontCSS(clonedNode, options);
2454
- if (cssText) {
2555
+ const finalCssText = options.extraStyleContent != null
26-
+ ? options.extraStyleContent.concat(cssText || '')
56+
+ ? (cssText || '').concat(options.extraStyleContent)
2757
+ : cssText
2858
+ if (finalCssText) {
2959
const styleNode = document.createElement('style');
@@ -49,22 +79,30 @@ index a832baf45e95d7b3bb076ecacd46e73493f3e9ef..b487b064337bf75cb74dd530b54b7c6a
4979
* A boolean to turn off auto scaling for truly massive images..
5080
*/
5181
diff --git a/lib/clone-node.js b/lib/clone-node.js
52-
index 214f4d30a50baae7f007d2399eeac298366538c7..4de9de97b39a620ebe8ba16e5feb9e552eb95bc6 100644
82+
index 214f4d30a50baae7f007d2399eeac298366538c7..74d67830cf549c05c533904123a9482c2cf5314f 100644
5383
--- a/lib/clone-node.js
5484
+++ b/lib/clone-node.js
55-
@@ -134,10 +134,6 @@ function cloneChildren(nativeNode, clonedNode, options) {
85+
@@ -134,9 +134,15 @@ function cloneChildren(nativeNode, clonedNode, options) {
5686
if (isSlotElement(nativeNode) && nativeNode.assignedNodes) {
5787
children = (0, util_1.toArray)(nativeNode.assignedNodes());
5888
}
5989
- else if ((0, util_1.isInstanceOfElement)(nativeNode, HTMLIFrameElement) &&
6090
- ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body)) {
6191
- children = (0, util_1.toArray)(nativeNode.contentDocument.body.childNodes);
62-
- }
92+
+ else if ((0, util_1.isInstanceOfElement)(nativeNode, HTMLIFrameElement)) {
93+
+ try {
94+
+ if ((_a = nativeNode.contentDocument) === null || _a === void 0 ? void 0 : _a.body) {
95+
+ children = (0, util_1.toArray)(nativeNode.contentDocument.body.childNodes);
96+
+ }
97+
+ }
98+
+ catch (_e) {
99+
+ // Cross-origin iframes cannot be accessed; fall through to clone as-is
100+
+ }
101+
}
63102
else {
64103
children = (0, util_1.toArray)(((_b = nativeNode.shadowRoot) !== null && _b !== void 0 ? _b : nativeNode).childNodes);
65-
}
66104
diff --git a/lib/embed-webfonts.js b/lib/embed-webfonts.js
67-
index c91538bd9c3afa63326a91267fb7b5b9c999cccd..f3e3bc8f0e9e254b3c10e3c317d804e3c83f769e 100644
105+
index c91538bd9c3afa63326a91267fb7b5b9c999cccd..ca5fdedfd7904b6d99ad201d739300d4a50f451c 100644
68106
--- a/lib/embed-webfonts.js
69107
+++ b/lib/embed-webfonts.js
70108
@@ -300,9 +300,12 @@ function embedWebFonts(clonedNode, options) {
@@ -73,7 +111,7 @@ index c91538bd9c3afa63326a91267fb7b5b9c999cccd..f3e3bc8f0e9e254b3c10e3c317d804e3
73111
cssText = _a;
74112
- if (cssText) {
75113
+ const finalCssText = options.extraStyleContent != null
76-
+ ? options.extraStyleContent.concat(cssText || '')
114+
+ ? (cssText || '').concat(options.extraStyleContent)
77115
+ : cssText
78116
+ if (finalCssText) {
79117
styleNode = document.createElement('style');
@@ -99,41 +137,72 @@ index a832baf45e95d7b3bb076ecacd46e73493f3e9ef..b487b064337bf75cb74dd530b54b7c6a
99137
* A boolean to turn off auto scaling for truly massive images..
100138
*/
101139
diff --git a/src/clone-node.ts b/src/clone-node.ts
102-
index 5dfcd117a1dd3d3d5cfb7c98ead3eb828085337a..8aa85ca88f84e8e64297673dcff36b21d48a95b4 100644
140+
index 5dfcd117a1dd3d3d5cfb7c98ead3eb828085337a..e0cd29915bd053ad31c1460ead1cb3556a23a79e 100644
103141
--- a/src/clone-node.ts
104142
+++ b/src/clone-node.ts
105-
@@ -88,11 +88,6 @@ async function cloneChildren<T extends HTMLElement>(
106-
143+
@@ -89,10 +89,15 @@ async function cloneChildren<T extends HTMLElement>(
107144
if (isSlotElement(nativeNode) && nativeNode.assignedNodes) {
108145
children = toArray<T>(nativeNode.assignedNodes())
109-
- } else if (
146+
} else if (
110147
- isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
111148
- nativeNode.contentDocument?.body
112-
- ) {
149+
+ isInstanceOfElement(nativeNode, HTMLIFrameElement)
150+
) {
113151
- children = toArray<T>(nativeNode.contentDocument.body.childNodes)
152+
+ try {
153+
+ if (nativeNode.contentDocument?.body) {
154+
+ children = toArray<T>(nativeNode.contentDocument.body.childNodes)
155+
+ }
156+
+ } catch {
157+
+ // Cross-origin iframes cannot be accessed; fall through to clone as-is
158+
+ }
114159
} else {
115160
children = toArray<T>((nativeNode.shadowRoot ?? nativeNode).childNodes)
116161
}
117162
diff --git a/src/embed-webfonts.ts b/src/embed-webfonts.ts
118-
index a84a699d37aeaaea19b9b31e0e98710f1e30da3c..6835efb8a2431aa8f066cd5ed0b47234b55f347a 100644
163+
index a84a699d37aeaaea19b9b31e0e98710f1e30da3c..83e33eade74c61a5f2ff56952535292c158f9cbb 100644
119164
--- a/src/embed-webfonts.ts
120165
+++ b/src/embed-webfonts.ts
121-
@@ -258,9 +258,13 @@ export async function embedWebFonts<T extends HTMLElement>(
166+
@@ -203,7 +203,7 @@ async function parseWebFontRules<T extends HTMLElement>(
167+
}
168+
169+
function normalizeFontFamily(font: string) {
170+
- return font.trim().replace(/["']/g, '')
171+
+ return (font || '').trim().replace(/["']/g, '')
172+
}
173+
174+
function getUsedFonts(node: HTMLElement) {
175+
@@ -211,9 +211,11 @@ function getUsedFonts(node: HTMLElement) {
176+
function traverse(node: HTMLElement) {
177+
const fontFamily =
178+
node.style.fontFamily || getComputedStyle(node).fontFamily
179+
- fontFamily.split(',').forEach((font) => {
180+
- fonts.add(normalizeFontFamily(font))
181+
- })
182+
+ if (fontFamily) {
183+
+ fontFamily.split(',').forEach((font) => {
184+
+ fonts.add(normalizeFontFamily(font))
185+
+ })
186+
+ }
187+
188+
Array.from(node.children).forEach((child) => {
189+
if (child instanceof HTMLElement) {
190+
@@ -258,9 +260,13 @@ export async function embedWebFonts<T extends HTMLElement>(
122191
? null
123192
: await getWebFontCSS(clonedNode, options)
124-
193+
125194
- if (cssText) {
126195
+ const finalCssText = options.extraStyleContent != null
127-
+ ? options.extraStyleContent.concat(cssText || '')
196+
+ ? (cssText || '').concat(options.extraStyleContent)
128197
+ : cssText
129198
+
130199
+ if (finalCssText) {
131200
const styleNode = document.createElement('style')
132201
- const sytleContent = document.createTextNode(cssText)
133202
+ const sytleContent = document.createTextNode(finalCssText)
134-
203+
135204
styleNode.appendChild(sytleContent)
136-
205+
137206
diff --git a/src/types.ts b/src/types.ts
138207
index 6023c3c2def1cb3ce83112c4c902997617582f59..ef40283ab6e47c98c7f8ea477c3ae29671682d98 100644
139208
--- a/src/types.ts

pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)