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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user