fix(engine): 修复 Mermaid 导出时的 Canvas Tainted 污染与 toBlob 异常

- 配置 Mermaid flowchart.htmlLabels 为 false,禁用 foreignObject 嵌入式 HTML 标签
- 实现 cleanSvgForCanvas 深度净化 SVG,剥离 @import 与将 foreignObject 转为原生 SVG <text>
- 替换异步 toBlob/FileReader 为同步 toDataURL + atob,并在 img.onload 中增加安全沙箱 try/catch 兜底

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 20:25:30 +08:00
co-authored by Cursor
parent c517b4a25b
commit 345cd54fb9
+97 -20
View File
@@ -73,6 +73,65 @@ export function latexToOmml(latex: string, isDisplay = true): ImportedXmlCompone
}
}
/**
* cleanSvgForCanvas: 深度净化 SVG 文本,彻底消除可能导致 HTMLCanvasElement Tainted(被污染)的元素
* 1. 剔除所有 @import 样式与外部资源引用(避免跨域网络资源导致画布被锁)
* 2. 将 foreignObject (嵌入式HTML标签) 替换或重构为 SVG 原生 <text>,杜绝浏览器安全沙箱将 Canvas 标记为 Tainted
*/
export function cleanSvgForCanvas(svgString: string): string {
if (!svgString) return '';
// 1. 剔除外部 @import 与 link 标签
let clean = svgString
.replace(/@import\s+url\([^)]+\);?/gi, '')
.replace(/<link\b[^>]*>/gi, '');
// 2. 浏览器 DOMParser 精确转换 foreignObject -> <text>
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('<foreignObject')) {
clean = clean.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, (match) => {
const text = match.replace(/<[^>]+>/g, '').trim();
return `<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" font-family="Microsoft YaHei" font-size="12" fill="#1E293B">${text}</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*<svg[^>]*\bwidth\s*=/i.test(cleanSvg)) {
cleanSvg = cleanSvg.replace(/<svg\b/i, `<svg width="${origWidth}" height="${origHeight}" `);
}
@@ -137,30 +196,38 @@ export async function svgToPngData(
const svgBlob = new Blob([cleanSvg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// 绘制纯白底色
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<Document> {
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);