From 345cd54fb98d98d2e0834e3599dd1c0aee11e172 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 20:25:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(engine):=20=E4=BF=AE=E5=A4=8D=20Mermaid=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=97=B6=E7=9A=84=20Canvas=20Tainted=20?= =?UTF-8?q?=E6=B1=A1=E6=9F=93=E4=B8=8E=20toBlob=20=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置 Mermaid flowchart.htmlLabels 为 false,禁用 foreignObject 嵌入式 HTML 标签 - 实现 cleanSvgForCanvas 深度净化 SVG,剥离 @import 与将 foreignObject 转为原生 SVG - 替换异步 toBlob/FileReader 为同步 toDataURL + atob,并在 img.onload 中增加安全沙箱 try/catch 兜底 Co-authored-by: Cursor --- web/src/engine/docxRenderer.ts | 117 +++++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/web/src/engine/docxRenderer.ts b/web/src/engine/docxRenderer.ts index 750b9303..6bf866bf 100644 --- a/web/src/engine/docxRenderer.ts +++ b/web/src/engine/docxRenderer.ts @@ -73,6 +73,65 @@ export function latexToOmml(latex: string, isDisplay = true): ImportedXmlCompone } } +/** + * cleanSvgForCanvas: 深度净化 SVG 文本,彻底消除可能导致 HTMLCanvasElement Tainted(被污染)的元素 + * 1. 剔除所有 @import 样式与外部资源引用(避免跨域网络资源导致画布被锁) + * 2. 将 foreignObject (嵌入式HTML标签) 替换或重构为 SVG 原生 ,杜绝浏览器安全沙箱将 Canvas 标记为 Tainted + */ +export function cleanSvgForCanvas(svgString: string): string { + if (!svgString) return ''; + + // 1. 剔除外部 @import 与 link 标签 + let clean = svgString + .replace(/@import\s+url\([^)]+\);?/gi, '') + .replace(/]*>/gi, ''); + + // 2. 浏览器 DOMParser 精确转换 foreignObject -> + if (typeof DOMParser !== 'undefined' && typeof XMLSerializer !== 'undefined') { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(clean, 'image/svg+xml'); + const parserError = doc.querySelector('parsererror'); + + if (!parserError) { + const foreignObjects = Array.from(doc.querySelectorAll('foreignObject')); + for (const fo of foreignObjects) { + const textContent = fo.textContent?.trim() || ''; + const x = parseFloat(fo.getAttribute('x') || '0'); + const y = parseFloat(fo.getAttribute('y') || '0'); + const width = parseFloat(fo.getAttribute('width') || '100'); + const height = parseFloat(fo.getAttribute('height') || '40'); + + const textEl = doc.createElementNS('http://www.w3.org/2000/svg', 'text'); + textEl.setAttribute('x', String(x + width / 2)); + textEl.setAttribute('y', String(y + height / 2 + 4)); + textEl.setAttribute('text-anchor', 'middle'); + textEl.setAttribute('dominant-baseline', 'central'); + textEl.setAttribute('font-family', 'Microsoft YaHei, system-ui, sans-serif'); + textEl.setAttribute('font-size', '12px'); + textEl.setAttribute('fill', '#1E293B'); + textEl.textContent = textContent; + + fo.parentNode?.replaceChild(textEl, fo); + } + clean = new XMLSerializer().serializeToString(doc); + } + } catch (e) { + console.warn('[docxRenderer] DOMParser cleaning foreignObject failed:', e); + } + } + + // 3. 兜底纯正则清洗残存 foreignObject (如无 DOMParser 或解析受限环境) + if (clean.includes('/gi, (match) => { + const text = match.replace(/<[^>]+>/g, '').trim(); + return `${text}`; + }); + } + + return clean; +} + /** * svgToPngData: 在浏览器环境中通过 Canvas 将 SVG 矢量图光栅化为 2.5x 视网膜超清 PNG 二进制数据 */ @@ -125,8 +184,8 @@ export async function svgToPngData( return; } - // 确保 SVG 根节点具备显式宽高与 xmlns - let cleanSvg = svgString; + // 确保 SVG 根节点净化、具备显式宽高与 xmlns + let cleanSvg = cleanSvgForCanvas(svgString); if (!/^\s*]*\bwidth\s*=/i.test(cleanSvg)) { cleanSvg = cleanSvg.replace(/ { - // 绘制纯白底色 - ctx.fillStyle = '#FFFFFF'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - URL.revokeObjectURL(url); + try { + // 绘制纯白底色 + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(url); - canvas.toBlob((pngBlob) => { - if (!pngBlob) { + // 优先使用同步 toDataURL 提取二进制,配合 try/catch 杜绝任何 Uncaught Tainted Canvas 异常 + const dataUrl = canvas.toDataURL('image/png'); + const base64 = dataUrl.split(',')[1]; + if (!base64) { resolve(null); return; } - const reader = new FileReader(); - reader.onloadend = () => { - const arrayBuffer = reader.result as ArrayBuffer; - resolve({ - data: new Uint8Array(arrayBuffer), - width: displayWidth, - height: displayHeight, - }); - }; - reader.readAsArrayBuffer(pngBlob); - }, 'image/png'); + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + resolve({ + data: bytes, + width: displayWidth, + height: displayHeight, + }); + } catch (err) { + console.warn('[docxRenderer] canvas toDataURL/export failed (handled taint fallback):', err); + URL.revokeObjectURL(url); + resolve(null); + } }; img.onerror = (e) => { @@ -907,6 +974,16 @@ export async function buildDocxModel(ast: DocumentAST): Promise { securityLevel: 'loose', fontFamily: 'Microsoft YaHei, system-ui, sans-serif', fontSize: 12, + flowchart: { + htmlLabels: false, + useMaxWidth: true, + }, + sequence: { + useMaxWidth: true, + }, + gantt: { + useMaxWidth: true, + }, }); const sanitized = sanitizeMermaidCode(rawCode);