diff --git a/web/package-lock.json b/web/package-lock.json index 2c541375..6de37bec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "docx": "^9.7.1", "katex": "^0.18.7", "lucide-react": "^1.16.0", + "mathml2omml": "^0.5.0", "mermaid": "^11.17.2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2832,6 +2833,12 @@ "node": ">= 20" } }, + "node_modules/mathml2omml": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/mathml2omml/-/mathml2omml-0.5.0.tgz", + "integrity": "sha512-4eLs37a+TH+CL/M5XZZrlc75SNRJNPiZzIaeSSvH0UFCRaCdSYRHWvrlA7MUbeQks/z4sVhfg+GlcZhVVUHzqg==", + "license": "LGPL-3.0-or-later" + }, "node_modules/mermaid": { "version": "11.17.2", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", diff --git a/web/package.json b/web/package.json index e8908566..b8acd960 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "docx": "^9.7.1", "katex": "^0.18.7", "lucide-react": "^1.16.0", + "mathml2omml": "^0.5.0", "mermaid": "^11.17.2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/web/src/engine/docxRenderer.ts b/web/src/engine/docxRenderer.ts index caa0bf12..750b9303 100644 --- a/web/src/engine/docxRenderer.ts +++ b/web/src/engine/docxRenderer.ts @@ -14,63 +14,250 @@ import { 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'; /** - * parseInlineTextRuns: 将包含简易 Markdown (粗体/斜体/代码/下划线) 的字符串转换为 docx TextRun 数组 + * 自动清洗 Mermaid 代码(防止标签括号导致解析异常) */ -export function parseInlineTextRuns(text: string, defaultOptions?: { color?: string; size?: number }): TextRun[] { +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 源码 + * @param isDisplay 是否为居中展示公式 (true 生成 m:oMathPara,false 生成行内 m:oMath) + */ +export function latexToOmml(latex: string, isDisplay = true): 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(//g, ''); + const mathMatch = mathml.match(//); + if (!mathMatch) return null; + + const omml = mml2omml(mathMatch[0]); + if (!omml || !omml.trim()) return null; + + if (isDisplay) { + const oMathParaXml = `${omml}`; + return ImportedXmlComponent.fromXmlString(oMathParaXml); + } else { + return ImportedXmlComponent.fromXmlString(omml); + } + } catch (err) { + console.warn('[docxRenderer] latexToOmml failed for:', latex, err); + return null; + } +} + +/** + * 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 = 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); + + canvas.toBlob((pngBlob) => { + if (!pngBlob) { + 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'); + }; + + 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[] = []; - const regex = /(\*\*([^\*]+?)\*\*|\*([^\*]+?)\*|`([^`]+?)`|([^\*`]+))/g; + 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) { - // **粗体** - tokens.push( - new TextRun({ - text: match[2], - bold: true, - color: defaultOptions?.color || '222222', - size: defaultOptions?.size || 22, // 11pt = 22 half-pts - }) - ); + // 1. 行内数学公式 $...$ + const inlineLatex = match[2].trim(); + const omml = latexToOmml(inlineLatex, false); + 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], - italics: true, - color: defaultOptions?.color || '333333', - size: defaultOptions?.size || 22, + 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: (defaultOptions?.size || 22) - 2, + size: Math.max(18, baseSize - 2), }) ); - } else if (match[5] !== undefined) { - // 纯文本 + } else if (match[6] !== undefined) { + // 5. 纯文本 tokens.push( new TextRun({ - text: match[5], - color: defaultOptions?.color || '333333', - size: defaultOptions?.size || 22, + text: match[6], + font: baseFont, + color: baseColor, + size: baseSize, }) ); } @@ -81,40 +268,111 @@ export function parseInlineTextRuns(text: string, defaultOptions?: { color?: str : [ new TextRun({ text, - color: defaultOptions?.color || '333333', - size: defaultOptions?.size || 22, + font: baseFont, + color: baseColor, + size: baseSize, }), ]; } /** - * buildDocxModel: 将 DocumentAST 转换为完整的 docx Document 实例 + * 保持向后兼容的别名导出 */ -export function buildDocxModel(ast: DocumentAST): Document { - const docElements: (Paragraph | Table)[] = []; +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); +} - // 1. 文档大标题 +/** + * buildDocxModel: 将 DocumentAST 编译为符合国家医械法规标准排版与 Office 原生矢量规范的 Word 文档 + */ +export async function buildDocxModel(ast: DocumentAST): Promise { + const docElements: (Paragraph | Table | ImportedXmlComponent)[] = []; + + // ========================================================================= + // 1. 卷首受控公文信息 (Document Header & Title Block) + // ========================================================================= docElements.push( new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 240, after: 120 }, + spacing: { before: 80, after: 40 }, children: [ new TextRun({ - text: ast.title, + text: 'SHUIMU DONGFANG MEDICAL ROBOTICS & MINIMALLY INVASIVE SURGICAL SYSTEMS', + size: 15, // 7.5pt bold: true, - size: 36, // 18pt - color: '0F172A', + color: '0284C7', + font: 'Arial', }), ], }) ); - // 2. 受控元数据签署栏 (四列两行受控表格) - const metaBorder = { - style: BorderStyle.SINGLE, - size: 4, - color: 'CBD5E1', + 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 = { drafting: '编制中 (草稿)', @@ -126,48 +384,74 @@ export function buildDocxModel(ast: DocumentAST): Document { 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: 25, type: WidthType.PERCENTAGE }, - shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + 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 })], + children: [ + new TextRun({ text: '受控编号', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }), + ], }), ], }), new TableCell({ - width: { size: 25, type: WidthType.PERCENTAGE }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + width: { size: 32, type: WidthType.PERCENTAGE }, + borders: metaBorders, + margins: metaCellMargins, children: [ new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: ast.version, size: 20, color: '0284C7', bold: true })], + children: [ + new TextRun({ text: ast.docId, size: 20, font: 'Consolas', color: '0284C7', bold: true }), + ], }), ], }), new TableCell({ - width: { size: 25, type: WidthType.PERCENTAGE }, - shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + 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 })], + children: [ + new TextRun({ text: '空间分类', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }), + ], }), ], }), new TableCell({ - width: { size: 25, type: WidthType.PERCENTAGE }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + width: { size: 32, type: WidthType.PERCENTAGE }, + borders: metaBorders, + margins: metaCellMargins, children: [ new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: statusMap[ast.lifecycleState] || ast.lifecycleState, size: 20 })], + children: [ + new TextRun({ + text: + ast.category === 'private' + ? '个人私密生成物' + : ast.category === 'shared' + ? '项目协作共享库' + : '流程受控交付物', + size: 20, + font: 'Microsoft YaHei', + color: '334155', + }), + ], }), ], }), @@ -176,45 +460,117 @@ export function buildDocxModel(ast: DocumentAST): Document { new TableRow({ children: [ new TableCell({ - shading: { fill: 'F8FAFC', type: ShadingType.CLEAR }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, - children: [ - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: '法定签署责任人', bold: true, size: 20 })], - }), - ], - }), - new TableCell({ - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + 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: 'F8FAFC', type: ShadingType.CLEAR }, - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + shading: { fill: 'F1F5F9', type: ShadingType.CLEAR }, + borders: metaBorders, + margins: metaCellMargins, children: [ new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ text: '更新归档日期', bold: true, size: 20 })], + children: [ + new TextRun({ text: '最新归档日期', bold: true, size: 20, font: 'Microsoft YaHei', color: '1E293B' }), + ], }), ], }), new TableCell({ - borders: { top: metaBorder, bottom: metaBorder, left: metaBorder, right: metaBorder }, + borders: metaBorders, + margins: metaCellMargins, children: [ new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: ast.updatedAt || '2026-09-08', size: 19 })], + children: [ + new TextRun({ + text: ast.updatedAt || '2026-09-08', + size: 19, + font: 'Consolas', + color: '334155', + }), + ], }), ], }), @@ -225,10 +581,58 @@ export function buildDocxModel(ast: DocumentAST): Document { docElements.push(metaTable); - // 空行分割 - docElements.push(new Paragraph({ spacing: { before: 180, after: 120 } })); + // 摘要卡片 (如果存在 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', + }), + ], + }), + ], + }), + ], + }), + ], + }) + ); + } - // 3. 递归遍历渲染 AST 子块 + // 空行分割 + docElements.push(new Paragraph({ spacing: { before: 200, after: 100 } })); + + // ========================================================================= + // 3. 递归遍历渲染 Block AST 子块 + // ========================================================================= const blocks = ast.rootBlock.children || []; for (const block of blocks) { @@ -239,28 +643,43 @@ export function buildDocxModel(ast: DocumentAST): Document { const headingText = block.numbering ? `${block.numbering} ${cleanTitle}` : cleanTitle; let hLevel: (typeof HeadingLevel)[keyof typeof HeadingLevel] = HeadingLevel.HEADING_1; - let fontSize = 28; // 14pt + let fontSize = 32; // 16pt / 三号 + let beforeSpace = 360; + let afterSpace = 120; + let color = '0F172A'; + if (level === 2) { hLevel = HeadingLevel.HEADING_2; - fontSize = 26; // 13pt + fontSize = 27; // 13.5pt / 四号 + beforeSpace = 240; + afterSpace = 90; + color = '1E293B'; } else if (level === 3) { hLevel = HeadingLevel.HEADING_3; - fontSize = 24; // 12pt + 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: 240, after: 100 }, + spacing: { before: beforeSpace, after: afterSpace, line: 360 }, + keepNext: true, // 杜绝孤行标题 children: [ new TextRun({ text: headingText, bold: true, size: fontSize, - color: level <= 2 ? '0F172A' : '334155', + font: 'Microsoft YaHei', + color, }), ], }) @@ -275,8 +694,8 @@ export function buildDocxModel(ast: DocumentAST): Document { if (!line.trim()) continue; docElements.push( new Paragraph({ - spacing: { before: 60, after: 60, line: 280 }, - children: parseInlineTextRuns(line), + spacing: { before: 60, after: 80, line: 340 }, // 1.4倍中文优雅行高 + children: parseInlineContent(line, { font: 'Microsoft YaHei', size: 21, color: '334155' }), }) ); } @@ -284,7 +703,12 @@ export function buildDocxModel(ast: DocumentAST): Document { } case 'callout': { - const typeTag = block.properties?.calloutType === 'regulatory' ? '合规依据与法规强标' : '重点提示与风险警示'; + 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 }, @@ -292,28 +716,34 @@ export function buildDocxModel(ast: DocumentAST): Document { new TableRow({ children: [ new TableCell({ - shading: { fill: 'F0F9FF', type: ShadingType.CLEAR }, + shading: { fill: bgColor, type: ShadingType.CLEAR }, borders: { - left: { style: BorderStyle.SINGLE, size: 24, color: '0284C7' }, - top: { style: BorderStyle.NONE }, - bottom: { style: BorderStyle.NONE }, - right: { style: BorderStyle.NONE }, + 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: 80, after: 40 }, + spacing: { before: 60, after: 40 }, children: [ new TextRun({ text: `【${typeTag}】`, bold: true, - size: 20, - color: '0369A1', + size: 21, + font: 'Microsoft YaHei', + color: titleColor, }), ], }), new Paragraph({ - spacing: { before: 40, after: 80 }, - children: parseInlineTextRuns(block.content, { color: '0F172A', size: 20 }), + spacing: { before: 40, after: 60, line: 320 }, + children: parseInlineContent(block.content, { + font: 'Microsoft YaHei', + size: 20, + color: '1E293B', + }), }), ], }), @@ -322,26 +752,28 @@ export function buildDocxModel(ast: DocumentAST): Document { ], }) ); - docElements.push(new Paragraph({ spacing: { before: 60, after: 60 } })); + docElements.push(new Paragraph({ spacing: { before: 80, after: 80 } })); break; } case 'table': { const data = block.properties?.tableData || []; - const caption = block.properties?.caption || '关键指标与技术评估表'; + const caption = block.properties?.caption || '关键技术指标与参数对比表'; const numLabel = block.numbering ? `${block.numbering} ${caption}` : caption; - // 题注 (表 1-1 ...) + // 表格居中题注 (表 1-1 ...) docElements.push( new Paragraph({ alignment: AlignmentType.CENTER, - spacing: { before: 180, after: 60 }, + spacing: { before: 220, after: 80 }, + keepNext: true, children: [ new TextRun({ text: numLabel, bold: true, - size: 21, - color: '334155', + size: 21, // 10.5pt + font: 'Microsoft YaHei', + color: '1E293B', }), ], }) @@ -351,10 +783,12 @@ export function buildDocxModel(ast: DocumentAST): Document { const colCount = Math.max(...data.map((r) => r.length)); const colWidthPercent = Math.floor(100 / colCount); - // 医疗器械规范标准三线表边线 - const thickBorder = { style: BorderStyle.SINGLE, size: 12, color: '0F172A' }; // 顶底粗线 - const thinBorder = { style: BorderStyle.SINGLE, size: 6, color: '334155' }; // 表头分割细线 + // 严谨医疗器械标准出版三线表 + 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[] = []; @@ -368,18 +802,24 @@ export function buildDocxModel(ast: DocumentAST): Document { cells.push( new TableCell({ width: { size: colWidthPercent, type: WidthType.PERCENTAGE }, - shading: isHeader ? { fill: 'F8FAFC', type: ShadingType.CLEAR } : undefined, + shading: isHeader + ? { fill: 'F8FAFC', type: ShadingType.CLEAR } + : rIdx % 2 === 1 + ? { fill: 'FFFFFF', type: ShadingType.CLEAR } + : { fill: 'FBFDFF', type: ShadingType.CLEAR }, borders: { - top: isHeader ? thickBorder : isLast ? thinBorder : noBorder, - bottom: isHeader ? thinBorder : isLast ? thickBorder : noBorder, + 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: 60, after: 60 }, - children: parseInlineTextRuns(cellText, { + spacing: { before: 40, after: 40, line: 300 }, + children: parseInlineContent(cellText, { + font: 'Microsoft YaHei', size: 20, color: isHeader ? '0F172A' : '334155', }), @@ -388,7 +828,7 @@ export function buildDocxModel(ast: DocumentAST): Document { }) ); } - tableRows.push(new TableRow({ children: cells })); + tableRows.push(new TableRow({ children: cells, tableHeader: isHeader })); }); docElements.push( @@ -398,127 +838,172 @@ export function buildDocxModel(ast: DocumentAST): Document { }) ); } - docElements.push(new Paragraph({ spacing: { before: 100, after: 100 } })); + 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, true); + + if (ommlComponent) { + docElements.push(ommlComponent); + } 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 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: 'E2E8F0' }, - bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, - left: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, - right: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, - }, - children: [ - new Paragraph({ - alignment: AlignmentType.CENTER, - spacing: { before: 120, after: 60 }, - children: [ - new TextRun({ - text: block.content.trim(), - font: 'Cambria Math', - size: 24, - bold: true, - color: '0F172A', - }), - ], - }), - new Paragraph({ - alignment: AlignmentType.RIGHT, - spacing: { before: 0, after: 60 }, - children: [ - new TextRun({ - text: numLabel, - size: 18, - color: '64748B', - }), - ], - }), - ], - }), - ], + 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', }), ], }) ); - docElements.push(new Paragraph({ spacing: { before: 80, after: 80 } })); 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: 'loose', + fontFamily: 'Microsoft YaHei, system-ui, sans-serif', + fontSize: 12, + }); + + 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: 120, after: 40 }, + spacing: { before: 60, after: 180 }, children: [ new TextRun({ text: numLabel, bold: true, - size: 21, + size: 20, // 10pt + font: 'Microsoft YaHei', color: '334155', }), ], }) ); - - 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.DASHED, size: 4, color: 'CBD5E1' }, - bottom: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, - left: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, - right: { style: BorderStyle.DASHED, size: 4, color: 'CBD5E1' }, - }, - children: [ - new Paragraph({ - spacing: { before: 80, after: 40 }, - children: [ - new TextRun({ - text: '[Mermaid 系统矢量拓扑图已定义,图形代码如下]:', - color: '0284C7', - bold: true, - size: 19, - }), - ], - }), - new Paragraph({ - spacing: { before: 40, after: 80 }, - children: [ - new TextRun({ - text: block.content.trim(), - font: 'Consolas', - size: 18, - color: '475569', - }), - ], - }), - ], - }), - ], - }), - ], - }) - ); break; } @@ -536,16 +1021,17 @@ export function buildDocxModel(ast: DocumentAST): Document { const prefix = isOrdered ? `${startNumber + idx}. ` : '• '; docElements.push( new Paragraph({ - spacing: { before: 40, after: 40 }, - indent: { left: 360 }, // 悬挂缩进 + 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, }), - ...parseInlineTextRuns(item, { size: 21 }), + ...parseInlineContent(item, { font: 'Microsoft YaHei', size: 21, color: '334155' }), ], }) ); @@ -556,7 +1042,7 @@ export function buildDocxModel(ast: DocumentAST): Document { case 'divider': { docElements.push( new Paragraph({ - spacing: { before: 120, after: 120 }, + spacing: { before: 140, after: 140 }, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E2E8F0' }, }, @@ -570,8 +1056,24 @@ export function buildDocxModel(ast: DocumentAST): Document { } } - // 4. 构建标准 Word Document 结构 (带页眉和页脚) + // ========================================================================= + // 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: { @@ -579,10 +1081,15 @@ export function buildDocxModel(ast: DocumentAST): Document { children: [ new Paragraph({ alignment: AlignmentType.RIGHT, + border: { + bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + }, + spacing: { after: 120 }, children: [ new TextRun({ - text: '水木东方医疗器械受控体系交付物 | 研发机密', - size: 16, + text: '水木东方医械研发体系受控交付物 | 机密受控', + size: 16, // 8pt + font: 'Microsoft YaHei', color: '94A3B8', }), ], @@ -595,30 +1102,39 @@ export function buildDocxModel(ast: DocumentAST): Document { children: [ new Paragraph({ alignment: AlignmentType.CENTER, + border: { + top: { style: BorderStyle.SINGLE, size: 4, color: 'E2E8F0' }, + }, + spacing: { before: 120 }, children: [ new TextRun({ text: '第 ', - size: 18, + size: 17, + font: 'Microsoft YaHei', color: '94A3B8', }), new TextRun({ children: [PageNumber.CURRENT], - size: 18, + size: 17, + font: 'Microsoft YaHei', color: '94A3B8', }), new TextRun({ text: ' 页,共 ', - size: 18, + size: 17, + font: 'Microsoft YaHei', color: '94A3B8', }), new TextRun({ children: [PageNumber.TOTAL_PAGES], - size: 18, + size: 17, + font: 'Microsoft YaHei', color: '94A3B8', }), new TextRun({ text: ' 页', - size: 18, + size: 17, + font: 'Microsoft YaHei', color: '94A3B8', }), ], @@ -628,15 +1144,19 @@ export function buildDocxModel(ast: DocumentAST): Document { }, properties: { page: { + size: { + width: 11906, // A4 210mm + height: 16838, // A4 297mm + }, margin: { - top: 1440, // 1 inch + top: 1440, // 25.4mm (1 inch) bottom: 1440, left: 1440, right: 1440, }, }, }, - children: docElements, + children: docElements as any, }, ], }); @@ -646,7 +1166,7 @@ export function buildDocxModel(ast: DocumentAST): Document { * exportDocumentToDocx: 编译并触发浏览器下载 DOCX 二进制文件 */ export async function exportDocumentToDocx(ast: DocumentAST): Promise { - const doc = buildDocxModel(ast); + const doc = await buildDocxModel(ast); const blob = await Packer.toBlob(doc); if (typeof window !== 'undefined' && typeof document !== 'undefined') {