Files
Agent-System/web/src/engine/docxRenderer.ts
T
weichenandCursor d7a144de26 feat(taskrun): resume interrupted runs and harden P1 security gaps
Add confirm-gated resume API with resumable flag, Phase 5 finalize fault
and rolling-restart replay tests, Mermaid strict mode, request body limits,
and optional Runner Bearer auth between app and pi-agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:23 +00:00

1264 lines
42 KiB
TypeScript

import {
Document,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
AlignmentType,
HeadingLevel,
Packer,
Header,
Footer,
PageNumber,
ShadingType,
ImportedXmlComponent,
ImageRun,
} from 'docx';
import katex from 'katex';
import { mml2omml } from 'mathml2omml';
import mermaid from 'mermaid';
import { DocumentAST } from '../types/block';
import { cleanHeadingContent } from './autoNumber';
/**
* 自动清洗 Mermaid 代码(防止标签括号导致解析异常)
*/
export function sanitizeMermaidCode(text: string): string {
if (!text) return '';
return text
.split('\n')
.map((line) => {
return line.replace(/(\b[\w\.-]+)\s*\[\s*([^"\]\n]+?)\s*\]/g, (match, id, label) => {
if ((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'"))) {
return match;
}
if (/[\(\)<>&:#/\\@\s]/.test(label)) {
return `${id}["${label.replace(/"/g, "'")}"]`;
}
return match;
});
})
.join('\n');
}
/**
* latexToOmml: 将 LaTeX 公式字符串转为 Microsoft Word 原生矢量数学公式 (OMML)
* @param latex LaTeX 源码
*/
export function latexToOmml(latex: string, _isDisplay = false): ImportedXmlComponent | null {
if (!latex || !latex.trim()) return null;
try {
let mathml = katex.renderToString(latex.trim(), { output: 'mathml', throwOnError: false });
// 清除 KaTeX 附带的 annotation 标签,避免 mathml2omml 产生无关警告
mathml = mathml.replace(/<annotation[\s\S]*?<\/annotation>/g, '');
const mathMatch = mathml.match(/<math[\s\S]*?<\/math>/);
if (!mathMatch) return null;
const omml = mml2omml(mathMatch[0]);
if (!omml || !omml.trim()) return null;
// 关键:ImportedXmlComponent.fromXmlString 会产生一个 rootKey 为 undefined 的假外层,必须提取内部真实的根节点,杜绝破坏性的 <undefined> 标签
const imported = ImportedXmlComponent.fromXmlString(omml);
const actualNode = (imported as any).root?.[0] || imported;
return actualNode;
} catch (err) {
console.warn('[docxRenderer] latexToOmml failed for:', latex, err);
return null;
}
}
/**
* 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 二进制数据
*/
export async function svgToPngData(
svgString: string,
maxWidth = 540
): Promise<{ data: Uint8Array; width: number; height: number } | null> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
return new Promise((resolve) => {
try {
let origWidth = 600;
let origHeight = 300;
const vbMatch = svgString.match(
/viewBox\s*=\s*["']\s*([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)\s*["']/i
);
if (vbMatch) {
const w = parseFloat(vbMatch[3]);
const h = parseFloat(vbMatch[4]);
if (w > 0 && h > 0) {
origWidth = w;
origHeight = h;
}
} else {
const wMatch = svgString.match(/width\s*=\s*["']([\d.-]+)(?:px)?["']/i);
const hMatch = svgString.match(/height\s*=\s*["']([\d.-]+)(?:px)?["']/i);
if (wMatch && hMatch) {
origWidth = parseFloat(wMatch[1]) || origWidth;
origHeight = parseFloat(hMatch[1]) || origHeight;
}
}
// 计算在 Word 版面中的最佳显示宽高
const scale = origWidth > maxWidth ? maxWidth / origWidth : 1;
const displayWidth = Math.round(origWidth * scale);
const displayHeight = Math.round(origHeight * scale);
// 视网膜/出版级高清采样(2.5倍超采样防发虚)
const dpr = 2.5;
const canvas = document.createElement('canvas');
canvas.width = Math.round(displayWidth * dpr);
canvas.height = Math.round(displayHeight * dpr);
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(null);
return;
}
// 确保 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}" `);
}
if (!cleanSvg.includes('xmlns=')) {
cleanSvg = cleanSvg.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg" ');
}
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 = () => {
try {
// 绘制纯白底色
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
// 优先使用同步 toDataURL 提取二进制,配合 try/catch 杜绝任何 Uncaught Tainted Canvas 异常
const dataUrl = canvas.toDataURL('image/png');
const base64 = dataUrl.split(',')[1];
if (!base64) {
resolve(null);
return;
}
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) => {
console.warn('[docxRenderer] img.onerror failed to load svg:', e);
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
} catch (err) {
console.warn('[docxRenderer] svgToPngData exception:', err);
resolve(null);
}
});
}
/**
* parseInlineContent: 将包含 Markdown 格式与行内数学公式 ($...$) 的文本解析为 TextRun 与原生 OMML 节点
*/
export function parseInlineContent(
text: string,
defaultOptions?: { color?: string; size?: number; font?: string }
): (TextRun | ImportedXmlComponent)[] {
if (!text) return [];
const tokens: (TextRun | ImportedXmlComponent)[] = [];
const baseFont = defaultOptions?.font || 'Microsoft YaHei';
const baseSize = defaultOptions?.size || 21; // 10.5pt
const baseColor = defaultOptions?.color || '334155'; // Slate-700
// 正则匹配:$行内公式$ | **粗体** | *斜体* | `代码` | 纯文本
const regex = /(\$([^\$\n]+?)\$|\*\*([^\*]+?)\*\*|\*([^\*]+?)\*|`([^`]+?)`|([^\$\*`]+))/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match[2] !== undefined) {
// 1. 行内数学公式 $...$
const inlineLatex = match[2].trim();
const omml = latexToOmml(inlineLatex);
if (omml) {
tokens.push(omml);
} else {
// 降级使用 Cambria Math 斜体文本
tokens.push(
new TextRun({
text: inlineLatex,
font: 'Cambria Math',
italics: true,
color: '0F172A',
size: baseSize,
})
);
}
} else if (match[3] !== undefined) {
// 2. **粗体**
tokens.push(
new TextRun({
text: match[3],
bold: true,
font: baseFont,
color: '0F172A',
size: baseSize,
})
);
} else if (match[4] !== undefined) {
// 3. *斜体*
tokens.push(
new TextRun({
text: match[4],
italics: true,
font: baseFont,
color: baseColor,
size: baseSize,
})
);
} else if (match[5] !== undefined) {
// 4. `代码`
tokens.push(
new TextRun({
text: ` ${match[5]} `,
font: 'Consolas',
shading: {
type: ShadingType.CLEAR,
fill: 'F1F5F9',
},
color: '0369A1',
size: Math.max(18, baseSize - 2),
})
);
} else if (match[6] !== undefined) {
// 5. 纯文本
tokens.push(
new TextRun({
text: match[6],
font: baseFont,
color: baseColor,
size: baseSize,
})
);
}
}
return tokens.length > 0
? tokens
: [
new TextRun({
text,
font: baseFont,
color: baseColor,
size: baseSize,
}),
];
}
/**
* 保持向后兼容的别名导出
*/
export function parseInlineTextRuns(
text: string,
defaultOptions?: { color?: string; size?: number; font?: string }
): TextRun[] {
const all = parseInlineContent(text, defaultOptions);
return all.filter((item): item is TextRun => item instanceof TextRun);
}
/**
* buildDocxModel: 将 DocumentAST 编译为符合国家医械法规标准排版与 Office 原生矢量规范的 Word 文档
*/
export async function buildDocxModel(ast: DocumentAST): Promise<Document> {
const docElements: (Paragraph | Table | ImportedXmlComponent)[] = [];
// =========================================================================
// 1. 卷首受控公文信息 (Document Header & Title Block)
// =========================================================================
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 80, after: 40 },
children: [
new TextRun({
text: 'SHUIMU DONGFANG MEDICAL ROBOTICS & MINIMALLY INVASIVE SURGICAL SYSTEMS',
size: 15, // 7.5pt
bold: true,
color: '0284C7',
font: 'Arial',
}),
],
})
);
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 0, after: 140 },
children: [
new TextRun({
text: '水木东方医疗机器人与微创手术系统研发中心 · 受控体系文件',
size: 18, // 9pt
bold: true,
color: '0369A1',
font: 'Microsoft YaHei',
}),
],
})
);
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 80, after: 120 },
children: [
new TextRun({
text: ast.title,
bold: true,
size: 40, // 20pt / 二号
color: '0F172A',
font: 'Microsoft YaHei',
}),
],
})
);
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 0, after: 200 },
children: [
new TextRun({
text: `【 阶段受控基线:${ast.version} ${
ast.lifecycleState === 'approved' ? '已批准生效基线' : '受控审签稿'
} 】`,
bold: true,
size: 21, // 10.5pt
color: '0284C7',
font: 'Microsoft YaHei',
}),
],
})
);
// =========================================================================
// 2. 受控元数据签署栏 (四列三行严谨公文表格)
// =========================================================================
const metaOuterBorder = { style: BorderStyle.SINGLE, size: 8, color: '94A3B8' };
const metaInnerBorder = { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' };
const metaBorders = {
top: metaInnerBorder,
bottom: metaInnerBorder,
left: metaInnerBorder,
right: metaInnerBorder,
};
const metaCellMargins = { top: 100, bottom: 100, left: 140, right: 140 };
const statusMap: Record<string, string> = {
drafting: '编制中 (草稿)',
reviewing: '审核中 (流程锁定)',
approving: '批准中 (流程锁定)',
approved: '已批准生效 (受控基线)',
rejected: '已驳回修改',
};
const metaTable = new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: metaOuterBorder,
bottom: metaOuterBorder,
left: metaOuterBorder,
right: metaOuterBorder,
},
rows: [
new TableRow({
children: [
new TableCell({
width: { size: 18, type: WidthType.PERCENTAGE },
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '受控编号', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
width: { size: 32, type: WidthType.PERCENTAGE },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({ text: ast.docId, size: 20, font: 'Consolas', color: '0284C7', bold: true }),
],
}),
],
}),
new TableCell({
width: { size: 18, type: WidthType.PERCENTAGE },
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '空间分类', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
width: { size: 32, type: WidthType.PERCENTAGE },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({
text:
ast.category === 'private'
? '个人私密生成物'
: ast.category === 'shared'
? '项目协作共享库'
: '流程受控交付物',
size: 20,
font: 'Microsoft YaHei',
color: '334155',
}),
],
}),
],
}),
],
}),
new TableRow({
children: [
new TableCell({
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '受控版本', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({ text: ast.version, size: 20, font: 'Consolas', color: '0284C7', bold: true }),
],
}),
],
}),
new TableCell({
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '审签状态', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({
text: statusMap[ast.lifecycleState] || ast.lifecycleState,
size: 20,
font: 'Microsoft YaHei',
color: '334155',
}),
],
}),
],
}),
],
}),
new TableRow({
children: [
new TableCell({
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '法定签署责任', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({
text: `编制: ${ast.author} | 审核: ${ast.reviewer} | 批准: ${ast.approver}`,
size: 19,
font: 'Microsoft YaHei',
color: '334155',
}),
],
}),
],
}),
new TableCell({
shading: { fill: 'F1F5F9', type: ShadingType.CLEAR },
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '最新归档日期', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }),
],
}),
],
}),
new TableCell({
borders: metaBorders,
margins: metaCellMargins,
children: [
new Paragraph({
children: [
new TextRun({
text: ast.updatedAt || '2026-09-08',
size: 19,
font: 'Consolas',
color: '334155',
}),
],
}),
],
}),
],
}),
],
});
docElements.push(metaTable);
// 摘要卡片 (如果存在 summary)
if (ast.summary) {
docElements.push(new Paragraph({ spacing: { before: 120, after: 60 } }));
docElements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: [
new TableCell({
shading: { fill: 'F8FAFC', type: ShadingType.CLEAR },
borders: {
left: { style: BorderStyle.SINGLE, size: 16, color: '0284C7' },
top: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
right: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
},
margins: { top: 100, bottom: 100, left: 160, right: 140 },
children: [
new Paragraph({
spacing: { before: 40, after: 40 },
children: [
new TextRun({
text: '【受控摘要与评审核心目标】 ',
bold: true,
size: 20,
font: 'Microsoft YaHei',
color: '0369A1',
}),
new TextRun({
text: ast.summary,
size: 20,
font: 'Microsoft YaHei',
color: '334155',
}),
],
}),
],
}),
],
}),
],
})
);
}
// 空行分割
docElements.push(new Paragraph({ spacing: { before: 200, after: 100 } }));
// =========================================================================
// 3. 递归遍历渲染 Block AST 子块
// =========================================================================
const blocks = ast.rootBlock.children || [];
for (const block of blocks) {
switch (block.type) {
case 'heading': {
const level = block.level || 1;
const cleanTitle = cleanHeadingContent(block.content);
const headingText = block.numbering ? `${block.numbering} ${cleanTitle}` : cleanTitle;
let hLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel] = HeadingLevel.HEADING_1;
let fontSize = 32; // 16pt / 三号
let beforeSpace = 360;
let afterSpace = 120;
let color = '0F172A';
if (level === 2) {
hLevel = HeadingLevel.HEADING_2;
fontSize = 27; // 13.5pt / 四号
beforeSpace = 240;
afterSpace = 90;
color = '1E293B';
} else if (level === 3) {
hLevel = HeadingLevel.HEADING_3;
fontSize = 24; // 12pt / 小四号
beforeSpace = 180;
afterSpace = 70;
color = '334155';
} else if (level >= 4) {
hLevel = HeadingLevel.HEADING_4;
fontSize = 22; // 11pt
beforeSpace = 140;
afterSpace = 60;
color = '475569';
}
docElements.push(
new Paragraph({
heading: hLevel,
spacing: { before: beforeSpace, after: afterSpace, line: 360 },
keepNext: true, // 杜绝孤行标题
children: [
new TextRun({
text: headingText,
bold: true,
size: fontSize,
font: 'Microsoft YaHei',
color,
}),
],
})
);
break;
}
case 'paragraph': {
if (!block.content) break;
const lines = block.content.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
docElements.push(
new Paragraph({
spacing: { before: 60, after: 80, line: 340 }, // 1.4倍中文优雅行高
children: parseInlineContent(line, { font: 'Microsoft YaHei', size: 21, color: '334155' }),
})
);
}
break;
}
case 'callout': {
const isReg = block.properties?.calloutType === 'regulatory';
const typeTag = isReg ? '合规依据与法规强标' : '重点提示与风险警示';
const accentColor = isReg ? '0284C7' : 'D97706';
const bgColor = isReg ? 'F0F9FF' : 'FFFBEB';
const titleColor = isReg ? '0369A1' : 'B45309';
docElements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: [
new TableCell({
shading: { fill: bgColor, type: ShadingType.CLEAR },
borders: {
left: { style: BorderStyle.SINGLE, size: 24, color: accentColor },
top: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
right: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
},
margins: { top: 100, bottom: 100, left: 160, right: 140 },
children: [
new Paragraph({
spacing: { before: 60, after: 40 },
children: [
new TextRun({
text: `【${typeTag}】`,
bold: true,
size: 21,
font: 'Microsoft YaHei',
color: titleColor,
}),
],
}),
new Paragraph({
spacing: { before: 40, after: 60, line: 320 },
children: parseInlineContent(block.content, {
font: 'Microsoft YaHei',
size: 20,
color: '1E293B',
}),
}),
],
}),
],
}),
],
})
);
docElements.push(new Paragraph({ spacing: { before: 80, after: 80 } }));
break;
}
case 'table': {
const data = block.properties?.tableData || [];
const caption = block.properties?.caption || '关键技术指标与参数对比表';
const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption;
// 表格居中题注 (表 1-1 ...)
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 220, after: 80 },
keepNext: true,
children: [
new TextRun({
text: numLabel,
bold: true,
size: 21, // 10.5pt
font: 'Microsoft YaHei',
color: '1E293B',
}),
],
})
);
if (data.length > 0) {
const colCount = Math.max(...data.map((r) => r.length));
const colWidthPercent = Math.floor(100 / colCount);
// 严谨医疗器械标准出版三线表
const thickBorder = { style: BorderStyle.SINGLE, size: 14, color: '0F172A' }; // 1.75pt 顶底粗线
const thinBorder = { style: BorderStyle.SINGLE, size: 8, color: '475569' }; // 1pt 表头分割线
const rowDivider = { style: BorderStyle.SINGLE, size: 2, color: 'F1F5F9' }; // 超细行间分隔线
const noBorder = { style: BorderStyle.NONE };
const tblMargins = { top: 120, bottom: 120, left: 160, right: 160 };
const tableRows: TableRow[] = [];
data.forEach((row, rIdx) => {
const isHeader = rIdx === 0;
const isLast = rIdx === data.length - 1;
const cells: TableCell[] = [];
for (let c = 0; c < colCount; c++) {
const cellText = row[c] || '';
cells.push(
new TableCell({
width: { size: colWidthPercent, type: WidthType.PERCENTAGE },
shading: isHeader
? { fill: 'F8FAFC', type: ShadingType.CLEAR }
: rIdx % 2 === 1
? { fill: 'FFFFFF', type: ShadingType.CLEAR }
: { fill: 'FBFDFF', type: ShadingType.CLEAR },
borders: {
top: isHeader ? thickBorder : noBorder,
bottom: isHeader ? thinBorder : isLast ? thickBorder : rowDivider,
left: noBorder,
right: noBorder,
},
margins: tblMargins,
children: [
new Paragraph({
alignment: isHeader ? AlignmentType.CENTER : AlignmentType.LEFT,
spacing: { before: 40, after: 40, line: 300 },
children: parseInlineContent(cellText, {
font: 'Microsoft YaHei',
size: 20,
color: isHeader ? '0F172A' : '334155',
}),
}),
],
})
);
}
tableRows.push(new TableRow({ children: cells, tableHeader: isHeader }));
});
docElements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: tableRows,
})
);
}
docElements.push(new Paragraph({ spacing: { before: 120, after: 120 } }));
break;
}
case 'math': {
const caption = block.properties?.caption || '关键计算判据与物理算法模型';
const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption;
const rawLatex = block.content.trim();
// 尝试通过 KaTeX + mml2omml 编译为 Microsoft Word 原生矢量 OMML 公式
const ommlComponent = latexToOmml(rawLatex);
if (ommlComponent) {
// 关键修正:必须包裹在独立的 Paragraph 中,并设置居中对齐与段落间距,消除非法的 <undefined> 与根级裸露
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 180, after: 60 },
children: [ommlComponent as any],
})
);
} else {
// 优雅降级为 Cambria Math 斜体居中公式
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 180, after: 60 },
children: [
new TextRun({
text: rawLatex,
font: 'Cambria Math',
size: 24,
bold: true,
italics: true,
color: '0F172A',
}),
],
})
);
}
// 公式题注与编号 (居中或右对齐)
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 40, after: 160 },
keepNext: false,
children: [
new TextRun({
text: numLabel,
size: 19, // 9.5pt
font: 'Microsoft YaHei',
color: '64748B',
}),
],
})
);
break;
}
case 'mermaid': {
const caption = block.properties?.caption || '系统架构与时序拓扑图';
const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption;
const rawCode = block.content.trim();
let imageInserted = false;
// 在浏览器环境中,调用 mermaid 渲染引擎与 Canvas 动态生成 2.5x 视网膜高清图
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
try {
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'strict',
fontFamily: 'Microsoft YaHei, system-ui, sans-serif',
fontSize: 12,
flowchart: {
htmlLabels: false,
useMaxWidth: true,
},
sequence: {
useMaxWidth: true,
},
gantt: {
useMaxWidth: true,
},
});
const sanitized = sanitizeMermaidCode(rawCode);
const renderId = `mermaid-docx-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
const { svg } = await mermaid.render(renderId, sanitized);
const pngResult = await svgToPngData(svg, 540);
if (pngResult && pngResult.data.length > 0) {
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 180, after: 60 },
children: [
new ImageRun({
data: pngResult.data,
transformation: {
width: pngResult.width,
height: pngResult.height,
},
type: 'png',
}),
],
})
);
imageInserted = true;
}
} catch (err) {
console.warn('[docxRenderer] Mermaid rendering failed:', err);
}
}
// 降级保护:在 Node 测试环境或浏览器生成异常时,渲染整洁的架构拓扑代码卡片
if (!imageInserted) {
docElements.push(
new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: [
new TableCell({
shading: { fill: 'F8FAFC', type: ShadingType.CLEAR },
borders: {
top: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
left: { style: BorderStyle.SINGLE, size: 12, color: '0284C7' },
right: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
},
margins: { top: 80, bottom: 80, left: 140, right: 140 },
children: [
new Paragraph({
spacing: { before: 40, after: 40 },
children: [
new TextRun({
text: '【系统矢量拓扑架构定义】',
bold: true,
size: 19,
font: 'Microsoft YaHei',
color: '0369A1',
}),
],
}),
new Paragraph({
spacing: { before: 40, after: 60 },
children: [
new TextRun({
text: rawCode,
font: 'Consolas',
size: 18,
color: '475569',
}),
],
}),
],
}),
],
}),
],
})
);
}
// 图表题注 (图 1-1 系统架构拓扑图)
docElements.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 60, after: 180 },
children: [
new TextRun({
text: numLabel,
bold: true,
size: 20, // 10pt
font: 'Microsoft YaHei',
color: '334155',
}),
],
})
);
break;
}
case 'list': {
const isOrdered = block.properties?.isOrdered ?? true;
const startNumber = block.properties?.startNumber || 1;
const items =
block.properties?.listItems ||
block.content
.split('\n')
.map((line) => line.replace(/^\s*(\d+\.|[-*+])\s+/, '').trim())
.filter(Boolean);
items.forEach((item, idx) => {
const prefix = isOrdered ? `${startNumber + idx}. ` : '• ';
docElements.push(
new Paragraph({
spacing: { before: 50, after: 50, line: 320 },
indent: { left: 420, hanging: 240 }, // 舒适的悬挂缩进
children: [
new TextRun({
text: prefix,
bold: true,
color: '0284C7',
font: 'Microsoft YaHei',
size: 21,
}),
...parseInlineContent(item, { font: 'Microsoft YaHei', size: 21, color: '334155' }),
],
})
);
});
break;
}
case 'divider': {
docElements.push(
new Paragraph({
spacing: { before: 140, after: 140 },
border: {
bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E2E8F0' },
},
})
);
break;
}
default:
break;
}
}
// =========================================================================
// 4. 构建标准 A4 Word Document 结构 (带专业级页眉、页脚与样式表)
// =========================================================================
return new Document({
styles: {
default: {
document: {
run: {
font: 'Microsoft YaHei',
size: 21, // 10.5pt (五号字)
color: '334155',
},
paragraph: {
spacing: { line: 340, before: 60, after: 60 },
},
},
},
},
sections: [
{
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
border: {
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
},
spacing: { after: 120 },
children: [
new TextRun({
text: '水木东方医械研发体系受控交付物 | 机密受控',
size: 16, // 8pt
font: 'Microsoft YaHei',
color: '94A3B8',
}),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
border: {
top: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' },
},
spacing: { before: 120 },
children: [
new TextRun({
text: '第 ',
size: 17,
font: 'Microsoft YaHei',
color: '94A3B8',
}),
new TextRun({
children: [PageNumber.CURRENT],
size: 17,
font: 'Microsoft YaHei',
color: '94A3B8',
}),
new TextRun({
text: ' 页,共 ',
size: 17,
font: 'Microsoft YaHei',
color: '94A3B8',
}),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 17,
font: 'Microsoft YaHei',
color: '94A3B8',
}),
new TextRun({
text: ' 页',
size: 17,
font: 'Microsoft YaHei',
color: '94A3B8',
}),
],
}),
],
}),
},
properties: {
page: {
size: {
width: 11906, // A4 210mm
height: 16838, // A4 297mm
},
margin: {
top: 1440, // 25.4mm (1 inch)
bottom: 1440,
left: 1440,
right: 1440,
},
},
},
children: docElements as any,
},
],
});
}
/**
* exportDocumentToDocx: 编译并触发浏览器下载 DOCX 二进制文件
*/
export async function exportDocumentToDocx(ast: DocumentAST): Promise<Blob> {
const doc = await buildDocxModel(ast);
const blob = await Packer.toBlob(doc);
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${ast.title}_${ast.version}_受控交付物.docx`;
a.click();
URL.revokeObjectURL(url);
}
return blob;
}