Skip to content

Commit 4ca2888

Browse files
authored
add placeholder images for external iframes on export to pdf (#8003)
## 📝 Summary <!-- Provide a concise summary of what this pull request is addressing. If this PR closes any issues, list them here by number (e.g., Closes #123). --> local iframes <img width="508" height="521" alt="image" src="https://github.com/user-attachments/assets/b9ea5858-f738-4f3a-87ca-d12da0f7abb2" /> external <img width="509" height="349" alt="image" src="https://github.com/user-attachments/assets/1e702362-d92f-4236-a09f-dc5cc998fc13" /> ## 🔍 Description of Changes <!-- Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> ## 📋 Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Tests have been added for the changes made. - [ ] Documentation has been updated where applicable, including docstrings for API changes. - [x] Pull request title is a good summary of the changes - it will be used in the [release notes](https://github.com/marimo-team/marimo/releases).
1 parent 2f95e54 commit 4ca2888

File tree

4 files changed

+336
-1
lines changed

4 files changed

+336
-1
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { captureIframeAsImage } from "../iframe";
4+
5+
// Mock html-to-image
6+
vi.mock("html-to-image", () => ({
7+
toPng: vi.fn().mockResolvedValue("data:image/png;base64,mock"),
8+
}));
9+
10+
describe("captureIframeAsImage", () => {
11+
const originalCreateElement = document.createElement.bind(document);
12+
13+
beforeEach(() => {
14+
// Mock devicePixelRatio
15+
Object.defineProperty(window, "devicePixelRatio", {
16+
value: 1,
17+
writable: true,
18+
});
19+
20+
// Mock canvas for placeholder generation
21+
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
22+
const element = originalCreateElement(tagName);
23+
if (tagName === "canvas") {
24+
const mockCtx = {
25+
scale: vi.fn(),
26+
fillStyle: "",
27+
fillRect: vi.fn(),
28+
strokeStyle: "",
29+
strokeRect: vi.fn(),
30+
font: "",
31+
textAlign: "",
32+
textBaseline: "",
33+
fillText: vi.fn(),
34+
measureText: vi.fn().mockReturnValue({ width: 10 }),
35+
};
36+
vi.spyOn(element as HTMLCanvasElement, "getContext").mockReturnValue(
37+
mockCtx as unknown as CanvasRenderingContext2D,
38+
);
39+
vi.spyOn(element as HTMLCanvasElement, "toDataURL").mockReturnValue(
40+
"data:image/png;base64,placeholder",
41+
);
42+
}
43+
return element;
44+
});
45+
});
46+
47+
afterEach(() => {
48+
vi.clearAllMocks();
49+
vi.restoreAllMocks();
50+
});
51+
52+
it("should return null when element has no iframe", async () => {
53+
const element = document.createElement("div");
54+
element.innerHTML = "<p>No iframe here</p>";
55+
56+
const result = await captureIframeAsImage(element);
57+
expect(result).toBeNull();
58+
});
59+
60+
it("should return placeholder for external iframe", async () => {
61+
const element = document.createElement("div");
62+
element.innerHTML = '<iframe src="https://external.com/page"></iframe>';
63+
64+
const result = await captureIframeAsImage(element);
65+
66+
expect(result).not.toBeNull();
67+
expect(result).toMatch(/^data:image\/png;base64,/);
68+
});
69+
70+
it("should return placeholder for cross-origin iframe", async () => {
71+
const element = document.createElement("div");
72+
element.innerHTML =
73+
'<iframe src="https://www.openstreetmap.org/export/embed.html"></iframe>';
74+
75+
const result = await captureIframeAsImage(element);
76+
77+
expect(result).not.toBeNull();
78+
expect(result).toMatch(/^data:image\/png;base64,/);
79+
});
80+
81+
it("should return null for about:blank iframe without body", async () => {
82+
const element = document.createElement("div");
83+
const iframe = document.createElement("iframe");
84+
iframe.src = "about:blank";
85+
element.append(iframe);
86+
87+
// The iframe has no body accessible in jsdom
88+
const result = await captureIframeAsImage(element);
89+
90+
// In jsdom, contentDocument may not be accessible
91+
expect(result).toBeNull();
92+
});
93+
94+
it("should handle iframe with relative same-origin src", async () => {
95+
const element = document.createElement("div");
96+
element.innerHTML = '<iframe src="./@file/123.html"></iframe>';
97+
98+
// Same-origin relative URL, but contentDocument not accessible in jsdom
99+
const result = await captureIframeAsImage(element);
100+
101+
// Returns null because jsdom can't access contentDocument for file URLs
102+
expect(result).toBeNull();
103+
});
104+
105+
it("should detect external URL from various formats", async () => {
106+
const externalUrls = [
107+
"https://example.com",
108+
"http://external.org/path",
109+
"https://sub.domain.com:8080/page",
110+
];
111+
112+
for (const url of externalUrls) {
113+
const element = document.createElement("div");
114+
element.innerHTML = `<iframe src="${url}"></iframe>`;
115+
116+
const result = await captureIframeAsImage(element);
117+
118+
expect(result).not.toBeNull();
119+
expect(result).toMatch(/^data:image\/png;base64,/);
120+
}
121+
});
122+
123+
it("should not treat same-origin URLs as external", async () => {
124+
// Same-origin URLs - these should try to capture, not return placeholder
125+
const sameOriginUrls = ["/local/path", "./relative/path", "../parent/path"];
126+
127+
for (const url of sameOriginUrls) {
128+
const element = document.createElement("div");
129+
element.innerHTML = `<iframe src="${url}"></iframe>`;
130+
131+
const result = await captureIframeAsImage(element);
132+
133+
// In jsdom, these return null because contentDocument isn't accessible
134+
// but they should NOT return a placeholder (which would indicate external detection)
135+
// The key is they don't trigger the external URL path
136+
expect(result).toBeNull();
137+
}
138+
});
139+
});

‎frontend/src/utils/download.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Filenames } from "@/utils/filenames";
77
import { Paths } from "@/utils/paths";
88
import { prettyError } from "./errors";
99
import { toPng } from "./html-to-image";
10+
import { captureIframeAsImage } from "./iframe";
1011
import { Logger } from "./Logger";
1112

1213
/**
@@ -104,6 +105,12 @@ export async function getImageDataUrlForCell(
104105
if (!element) {
105106
return;
106107
}
108+
109+
const iframeDataUrl = await captureIframeAsImage(element);
110+
if (iframeDataUrl) {
111+
return iframeDataUrl;
112+
}
113+
107114
const cleanup = prepareCellElementForScreenshot(element, enablePrintMode);
108115

109116
try {
@@ -125,6 +132,13 @@ export async function downloadCellOutputAsImage(
125132
return;
126133
}
127134

135+
// Cell outputs that are iframes
136+
const iframeDataUrl = await captureIframeAsImage(element);
137+
if (iframeDataUrl) {
138+
downloadByURL(iframeDataUrl, Filenames.toPNG(filename));
139+
return;
140+
}
141+
128142
await downloadHTMLAsImage({
129143
element,
130144
filename,

‎frontend/src/utils/iframe.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
3+
import { toPng } from "./html-to-image";
4+
5+
const PLACEHOLDER_WIDTH = 320;
6+
const PLACEHOLDER_HEIGHT = 180;
7+
8+
function isExternalUrl(src: string | null): string | null {
9+
if (!src || src === "about:blank") {
10+
return null;
11+
}
12+
try {
13+
const resolved = new URL(src, window.location.href);
14+
if (resolved.origin === window.location.origin) {
15+
return null;
16+
}
17+
return resolved.href;
18+
} catch {
19+
return src;
20+
}
21+
}
22+
23+
function wrapText(
24+
ctx: CanvasRenderingContext2D,
25+
text: string,
26+
maxWidth: number,
27+
): string[] {
28+
const lines: string[] = [];
29+
let current = "";
30+
31+
for (const char of text) {
32+
const test = current + char;
33+
if (ctx.measureText(test).width <= maxWidth) {
34+
current = test;
35+
} else {
36+
if (current) {
37+
lines.push(current);
38+
}
39+
current = char;
40+
}
41+
}
42+
if (current) {
43+
lines.push(current);
44+
}
45+
46+
return lines;
47+
}
48+
49+
function createPlaceholderImage(url: string | null): string {
50+
const scale = window.devicePixelRatio || 1;
51+
const canvas = document.createElement("canvas");
52+
canvas.width = PLACEHOLDER_WIDTH * scale;
53+
canvas.height = PLACEHOLDER_HEIGHT * scale;
54+
55+
const ctx = canvas.getContext("2d");
56+
if (!ctx) {
57+
return canvas.toDataURL("image/png");
58+
}
59+
60+
ctx.scale(scale, scale);
61+
62+
// Background
63+
ctx.fillStyle = "#f3f4f6";
64+
ctx.fillRect(0, 0, PLACEHOLDER_WIDTH, PLACEHOLDER_HEIGHT);
65+
66+
// Border
67+
ctx.strokeStyle = "#d1d5db";
68+
ctx.strokeRect(0.5, 0.5, PLACEHOLDER_WIDTH - 1, PLACEHOLDER_HEIGHT - 1);
69+
70+
// Text
71+
ctx.fillStyle = "#6b7280";
72+
ctx.font = "8px sans-serif";
73+
ctx.textAlign = "center";
74+
ctx.textBaseline = "middle";
75+
76+
const lineHeight = 14;
77+
const padding = 16;
78+
const maxWidth = PLACEHOLDER_WIDTH - padding * 2;
79+
80+
const message = "External iframe";
81+
const urlLines = url ? wrapText(ctx, url, maxWidth) : [];
82+
const totalLines = 1 + urlLines.length;
83+
const totalHeight = totalLines * lineHeight;
84+
let y = (PLACEHOLDER_HEIGHT - totalHeight) / 2 + lineHeight / 2;
85+
86+
ctx.fillText(message, PLACEHOLDER_WIDTH / 2, y);
87+
y += lineHeight;
88+
89+
for (const line of urlLines) {
90+
ctx.fillText(line, PLACEHOLDER_WIDTH / 2, y);
91+
y += lineHeight;
92+
}
93+
94+
return canvas.toDataURL("image/png");
95+
}
96+
97+
/**
98+
* Capture an iframe as a PNG image. We need to do this because external iframes are not supported by html-to-image.
99+
* @param element - The element to capture the iframe from
100+
* @returns The image data URL of the iframe, or a placeholder image if the iframe is external
101+
*/
102+
export async function captureIframeAsImage(
103+
element: HTMLElement,
104+
): Promise<string | null> {
105+
const iframe = element.querySelector("iframe");
106+
if (!iframe) {
107+
return null;
108+
}
109+
110+
// Check if the iframe itself is external
111+
const externalUrl = isExternalUrl(iframe.getAttribute("src"));
112+
if (externalUrl) {
113+
return createPlaceholderImage(externalUrl);
114+
}
115+
116+
// Try to access iframe document and check for nested external iframes
117+
let doc: Document;
118+
try {
119+
const d = iframe.contentDocument || iframe.contentWindow?.document;
120+
if (!d?.body) {
121+
return null;
122+
}
123+
doc = d;
124+
} catch {
125+
return createPlaceholderImage(null);
126+
}
127+
128+
// Check for nested external iframes
129+
for (const nested of doc.querySelectorAll("iframe")) {
130+
const nestedExternal = isExternalUrl(nested.getAttribute("src"));
131+
if (nestedExternal) {
132+
return createPlaceholderImage(nestedExternal);
133+
}
134+
}
135+
136+
// Capture the iframe content
137+
try {
138+
return await toPng(doc.body);
139+
} catch {
140+
return createPlaceholderImage(null);
141+
}
142+
}

‎marimo/_smoke_tests/pdf_export/basic_example.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import marimo
22

3-
__generated_with = "0.19.2"
3+
__generated_with = "0.19.6"
44
app = marimo.App(auto_download=["ipynb"])
55

66

@@ -75,6 +75,46 @@ def _(df, plt):
7575
return
7676

7777

78+
@app.cell
79+
def _(mo):
80+
iframe = mo.iframe("""
81+
<div style='border: 2px solid #4CAF50; padding: 20px; border-radius: 10px; background: linear-gradient(135deg, #e0f7fa, #80deea);'>
82+
<h1 style='color: #00796b; font-family: Arial, sans-serif;'>Welcome to My Interactive Frame</h1>
83+
<p style='font-size: 16px; color: #004d40;'>This is a more complex div element with styled borders, gradients, and custom fonts.</p>
84+
<ul style='color: #004d40;'>
85+
<li>Feature 1: Stylish layout</li>
86+
<li>Feature 2: Custom fonts and colors</li>
87+
<li>Feature 3: Rounded corners and padding</li>
88+
</ul>
89+
<button style='background-color: #00796b; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer;' onclick="alert('Button clicked!')">Click Me</button>
90+
</div>
91+
""")
92+
iframe
93+
return (iframe,)
94+
95+
96+
@app.cell
97+
def _(iframe, mo):
98+
mo.Html(iframe.text)
99+
return
100+
101+
102+
@app.cell
103+
def _(mo):
104+
mo.iframe(
105+
'<iframe src="demo_iframe.html" height="200" width="300" title="Iframe Example"></iframe>'
106+
)
107+
return
108+
109+
110+
@app.cell
111+
def _(mo):
112+
mo.iframe(
113+
'<iframe id="inlineFrameExample" title="Inline Frame Example" width="800" height="600" src="https://www.openstreetmap.org/export/embed.html?bbox=-0.004017949104309083%2C51.47612752641776%2C0.00030577182769775396%2C51.478569861898606&amp;layer=mapnik"></iframe>'
114+
)
115+
return
116+
117+
78118
@app.cell
79119
def _(df):
80120
df

0 commit comments

Comments
 (0)