@@ -51,6 +51,120 @@ const dialogVisible = ref<boolean>(false)
5151// 保存原始元素引用,用于导出
5252const 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+
54168const 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