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>
1264 lines
42 KiB
TypeScript
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;
|
|
}
|