Skip to content

Commit 48025e8

Browse files
authored
fix: When agent output wrapped in html_render tags is exported as a PDF or image, the content inside the html_render tags appears blank. #7222 (#7293)
1 parent d7a9955 commit 48025e8

3 files changed

Lines changed: 166 additions & 9 deletions

File tree

‎ui/src/components/markdown/HtmlRander.vue‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,28 @@ const observer = new ResizeObserver(() => {
105105
timer = setTimeout(sendHeight, 100);
106106
});
107107
observer.observe(document.body);
108+
// 导出 PDF/图片时,父页面无法读取沙箱 iframe 的内容,由 iframe 自身序列化当前 DOM 返回
109+
window.addEventListener('message', function (e) {
110+
if (e.source !== parent || e.data?.type !== 'snapshot') return;
111+
let html = '';
112+
try {
113+
const doc = document.documentElement.cloneNode(true);
114+
const srcCanvas = document.querySelectorAll('canvas');
115+
doc.querySelectorAll('canvas').forEach(function (c, i) {
116+
try {
117+
const img = document.createElement('img');
118+
img.src = srcCanvas[i].toDataURL();
119+
img.style.cssText = srcCanvas[i].style.cssText;
120+
img.style.width = srcCanvas[i].offsetWidth + 'px';
121+
img.style.height = srcCanvas[i].offsetHeight + 'px';
122+
c.replaceWith(img);
123+
} catch (err) {}
124+
});
125+
doc.querySelectorAll('script').forEach(function (s) { s.remove(); });
126+
html = '<!DOCTYPE html>' + doc.outerHTML;
127+
} catch (err) {}
128+
parent.postMessage({ type: 'snapshot:response', requestId: e.data.requestId, html: html }, '*');
129+
});
108130
<\/script>
109131
</body>
110132
</html>

‎ui/src/components/markdown/IframeRender.vue‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ const observer = new ResizeObserver(() => {
5858
timer = setTimeout(sendHeight, 100);
5959
});
6060
observer.observe(document.body);
61+
// 导出 PDF/图片时,父页面无法读取沙箱 iframe 的内容,由 iframe 自身序列化当前 DOM 返回
62+
window.addEventListener('message', function (e) {
63+
if (e.source !== parent || e.data?.type !== 'snapshot') return;
64+
let html = '';
65+
try {
66+
const doc = document.documentElement.cloneNode(true);
67+
const srcCanvas = document.querySelectorAll('canvas');
68+
doc.querySelectorAll('canvas').forEach(function (c, i) {
69+
try {
70+
const img = document.createElement('img');
71+
img.src = srcCanvas[i].toDataURL();
72+
img.style.cssText = srcCanvas[i].style.cssText;
73+
img.style.width = srcCanvas[i].offsetWidth + 'px';
74+
img.style.height = srcCanvas[i].offsetHeight + 'px';
75+
c.replaceWith(img);
76+
} catch (err) {}
77+
});
78+
doc.querySelectorAll('script').forEach(function (s) { s.remove(); });
79+
html = '<!DOCTYPE html>' + doc.outerHTML;
80+
} catch (err) {}
81+
parent.postMessage({ type: 'snapshot:response', requestId: e.data.requestId, html: html }, '*');
82+
});
6183
<\/script>
6284
</body>
6385
</html>

‎ui/src/components/pdf-export/index.vue‎

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,120 @@ const dialogVisible = ref<boolean>(false)
5151
// 保存原始元素引用,用于导出
5252
const originalElement = ref<HTMLElement | null>(null)
5353
54+
// 沙箱 iframe(无 allow-same-origin)的 contentDocument 不可访问,html-to-image 只能克隆出空 iframe。
55+
// 导出前让 iframe 自身序列化 DOM,放入不执行脚本的同源 iframe 中单独截图,再临时替换原 iframe,截图后还原。
56+
const SNAPSHOT_TIMEOUT = 3000
57+
58+
const requestIframeSnapshot = (iframe: HTMLIFrameElement): Promise<string | null> => {
59+
return new Promise((resolve) => {
60+
const win = iframe.contentWindow
61+
if (!win) return resolve(null)
62+
const requestId = Date.now() + '_' + Math.random()
63+
const timer = setTimeout(() => done(null), SNAPSHOT_TIMEOUT)
64+
function handler(e: MessageEvent) {
65+
if (e.source !== win || e.data?.type !== 'snapshot:response') return
66+
if (e.data.requestId !== requestId) return
67+
done(e.data.html || null)
68+
}
69+
function done(html: string | null) {
70+
clearTimeout(timer)
71+
window.removeEventListener('message', handler)
72+
resolve(html)
73+
}
74+
window.addEventListener('message', handler)
75+
win.postMessage({ type: 'snapshot', requestId }, '*')
76+
})
77+
}
78+
79+
const createStaticIframe = (
80+
iframe: HTMLIFrameElement,
81+
html: string,
82+
): Promise<HTMLIFrameElement> => {
83+
return new Promise((resolve) => {
84+
const staticIframe = document.createElement('iframe')
85+
// 仅同源、不允许脚本:父页面可读取内容,且不会执行任何脚本
86+
staticIframe.setAttribute('sandbox', 'allow-same-origin')
87+
staticIframe.className = iframe.className
88+
staticIframe.style.cssText = iframe.style.cssText
89+
staticIframe.style.width = iframe.offsetWidth + 'px'
90+
staticIframe.style.height = iframe.offsetHeight + 'px'
91+
// 放到屏幕外渲染,避免影响对话页面布局
92+
staticIframe.style.position = 'fixed'
93+
staticIframe.style.left = '-100000px'
94+
staticIframe.style.top = '0'
95+
staticIframe.onload = () => resolve(staticIframe)
96+
staticIframe.srcdoc = html
97+
document.body.appendChild(staticIframe)
98+
})
99+
}
100+
101+
// 将 iframe 内容单独截成 canvas。
102+
// 不直接交给 html-to-image 处理 iframe:其克隆同源 iframe 时会用 iframe 的样式覆盖 body 的样式,导致布局/背景丢失
103+
const iframeToCanvas = async (
104+
iframe: HTMLIFrameElement,
105+
options: any,
106+
): Promise<HTMLCanvasElement | null> => {
107+
const html = await requestIframeSnapshot(iframe)
108+
if (!html) return null
109+
const staticIframe = await createStaticIframe(iframe, html)
110+
try {
111+
const root = staticIframe.contentDocument?.documentElement
112+
if (!root) return null
113+
const width = iframe.offsetWidth
114+
const height = iframe.offsetHeight
115+
const canvas = await htmlToImage.toCanvas(root, {
116+
...options,
117+
backgroundColor: undefined,
118+
width,
119+
height,
120+
canvasWidth: width,
121+
canvasHeight: height,
122+
})
123+
canvas.style.cssText = iframe.style.cssText
124+
canvas.style.display = 'block'
125+
canvas.style.width = width + 'px'
126+
canvas.style.height = height + 'px'
127+
return canvas
128+
} finally {
129+
staticIframe.remove()
130+
}
131+
}
132+
133+
const toCanvasWithIframes = async (element: HTMLElement, options: any) => {
134+
const iframes = Array.from(element.querySelectorAll('iframe')).filter(
135+
(iframe) => iframe.offsetWidth > 0 && iframe.offsetHeight > 0,
136+
)
137+
const replaced: {
138+
iframe: HTMLIFrameElement
139+
canvas: HTMLCanvasElement
140+
display: string
141+
}[] = []
142+
try {
143+
const canvases = await Promise.all(
144+
iframes.map((iframe) =>
145+
iframeToCanvas(iframe, options).catch((e) => {
146+
console.error('iframe snapshot error:', e)
147+
return null
148+
}),
149+
),
150+
)
151+
// 截图全部完成后再统一替换,避免替换过程影响其他 iframe 的尺寸
152+
iframes.forEach((iframe, i) => {
153+
const canvas = canvases[i]
154+
if (!canvas) return
155+
replaced.push({ iframe, canvas, display: iframe.style.display })
156+
iframe.after(canvas)
157+
iframe.style.display = 'none'
158+
})
159+
return await htmlToImage.toCanvas(element, options)
160+
} finally {
161+
replaced.forEach(({ iframe, canvas, display }) => {
162+
canvas.remove()
163+
iframe.style.display = display
164+
})
165+
}
166+
}
167+
54168
const open = (element: HTMLElement | null) => {
55169
dialogVisible.value = true
56170
loading.value = true
@@ -63,13 +177,12 @@ const open = (element: HTMLElement | null) => {
63177
originalElement.value = element
64178
65179
nextTick(() => {
66-
htmlToImage
67-
.toCanvas(element, {
68-
pixelRatio: window.devicePixelRatio || 1,
69-
quality: 1,
70-
skipFonts: false,
71-
backgroundColor: '#ffffff',
72-
})
180+
toCanvasWithIframes(element, {
181+
pixelRatio: window.devicePixelRatio || 1,
182+
quality: 1,
183+
skipFonts: false,
184+
backgroundColor: '#ffffff',
185+
})
73186
.then((canvas) => {
74187
// 清空之前的内容
75188
svgContainerRef.value.innerHTML = ''
@@ -94,7 +207,7 @@ const exportPDF = () => {
94207
try {
95208
const targetEl = originalElement.value
96209
if (!targetEl) return
97-
const canvas = await htmlToImage.toCanvas(targetEl, {
210+
const canvas = await toCanvasWithIframes(targetEl, {
98211
pixelRatio: 2,
99212
quality: 1,
100213
skipFonts: false,
@@ -138,7 +251,7 @@ const exportJepg = () => {
138251
try {
139252
const targetEl = originalElement.value
140253
if (!targetEl) return
141-
const canvas = await htmlToImage.toCanvas(targetEl, {
254+
const canvas = await toCanvasWithIframes(targetEl, {
142255
pixelRatio: window.devicePixelRatio || 1,
143256
quality: 1,
144257
skipFonts: false,

0 commit comments

Comments
 (0)