fix(engine): 修复 DOCX 公式因 undefined 虚根在 Word 中丢失的缺陷

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 20:37:28 +08:00
co-authored by Cursor
parent 29efa0d348
commit a527f4dcd4
4 changed files with 83 additions and 7 deletions
@@ -125,5 +125,21 @@ Test Files 1 passed (1)
---
## 八、 测试结论与后续建议 (Conclusion & Next Steps)
- **测试结论**:本次针对用户反馈的“Mermaid 图未正确显示”、“公式未正确显示”、“排版不美观”三大问题及实测中的“Canvas Tainted 安全异常”进行了彻底的工程重构与加固。引入 KaTeX + mml2omml 打造了真正的微软 Word 原生数学公式生成体系;通过浏览器 Canvas 防污染超采样实现了 Mermaid 矢量图的高清图像光栅化导出;重构了卷首公文签署、医疗标准三线表与字体行距防孤行系统,使导出的 DOCX 达到出版级专业公文标准,达到上线交付标准。
## 八、 突发缺陷排除记录 (Word 公式消失与 <undefined> 标签根除)
- **缺陷现象**:导出的 DOCX 中展示公式与行内公式均未渲染显示,在 Word 中直接消失不见。
- **根因分析 (Root Cause)**:
1. **docx 虚根导致的 `<undefined>` 破坏性标签**:`docx` 库的 `ImportedXmlComponent.fromXmlString(omml)` 在反序列化单一根节点(`<m:oMath>`)时,生成了一个顶层包装组件,其属性为 `rootKey: undefined`,真实的 `m:oMath` 节点被放在了 `comp.root[0]` 中;
2. **OpenXML 破坏性序列化**:当 `Packer.toBuffer()` 编译时,直接将带有 `rootKey: undefined` 的顶层组件序列化为了 `<undefined><m:oMath>...</m:oMath></undefined>`;
3. **Word Schema 严格校验与丢弃**:Microsoft Word、WPS Office 等软件在加载 `word/document.xml` 时,执行 OpenXML Schema 严格校验,识别到 `<undefined>` 属于非法标签,直接静默剔除整个标签及其内部的所有数学公式;
4. **Body 根级裸露问题**:在 OpenXML 规范中,`w:body` 的直接子节点只允许 `<w:p>`、`<w:tbl>` 等,展示公式未包裹在独立的段落 `<w:p>` 中,导致规范违规。
- **修复方案与实测验证**:
1. **精准解包真实 OMML 节点**:重构 `latexToOmml`,在由 `fromXmlString` 生成组件后,自动解包并提取其真实根节点 `const actualNode = imported.root?.[0] || imported`,其 `rootKey` 严格为 `'m:oMath'`,彻底杜绝任何 `<undefined>` 标签的生成;
2. **规范段落包裹 (`<w:p>`)**:在 `case 'math':` 中,将 `ommlComponent` 严格包裹于设置了居中对齐与段前段后间距的 `new Paragraph({ alignment: AlignmentType.CENTER, children: [ommlComponent] })` 中;
3. **集成自动化 XML 结构断言防线**:在 `renderers.test.ts` 中引入 `JSZip` 解压编译出的 DOCX 文件,针对底层 `word/document.xml` 进行严格断言:
- 断言 `!docXml.includes('<undefined>')`(100% 杜绝破坏性标签);
- 断言 `docXml.includes('<m:oMath')`(100% 确保原生微软数学矢量节点存在)。
---
## 九、 测试结论与后续建议 (Conclusion & Next Steps)
- **测试结论**:本次针对用户反馈的“Mermaid 图未正确显示”、“公式未正确显示/直接消失”、“排版不美观”三大问题及实测中的“Canvas Tainted 安全异常”、“OpenXML <undefined> 破坏性标签”进行了彻底的工程重构与深度加固。引入 KaTeX + mml2omml 打造了真正的微软 Word 原生数学公式生成体系,并彻底消除了 `<undefined>` 标签破坏;通过浏览器 Canvas 防污染超采样实现了 Mermaid 矢量图的高清图像光栅化导出;重构了卷首公文签署、医疗标准三线表与字体行距防孤行系统,使导出的 DOCX 达到出版级专业公文标准,全量测试 100% 通过。
+25 -2
View File
@@ -15,6 +15,7 @@ import {
Trash2,
Shield,
Layers,
FileText,
} from 'lucide-react';
interface DeckDrawerProps {
@@ -56,7 +57,29 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
return null;
}
const renderBadge = (state: DocLifecycleState, isClaimed: boolean) => {
const renderBadge = (state: DocLifecycleState, isClaimed: boolean, isTemplateOnly?: boolean) => {
if (isTemplateOnly || state === 'template') {
return (
<span
style={{
fontSize: '11px',
fontWeight: 600,
color: '#0284c7',
background: '#e0f2fe',
border: '1px solid #bae6fd',
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
display: 'flex',
alignItems: 'center',
gap: '3px',
}}
title="该文档为项目标准预置模板副本,点击可全景查阅大纲与章节规范"
>
<FileText size={11} />
<span>📄 初始模板</span>
</span>
);
}
switch (state) {
case 'approved':
return (
@@ -388,7 +411,7 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
{renderBadge(doc.lifecycleState, doc.isClaimed)}
{renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert';
import { DeliverableItem } from '../../types/workbench';
test('isTemplateOnly 交付物应正确识别为初始模板展示态', () => {
const item: DeliverableItem = {
docId: 'doc-p101-intake',
projectId: 101,
taskKey: 'intake',
title: '用户初始需求收集表',
stage: 'V0.1',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: true,
lifecycleState: 'template',
isClaimed: false,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '刚刚',
};
assert.strictEqual(item.isTemplateOnly, true);
const badgeLabel = item.isTemplateOnly ? '📄 初始模板' : item.lifecycleState;
assert.strictEqual(badgeLabel, '📄 初始模板');
});
+12 -3
View File
@@ -11,6 +11,7 @@ import {
} from './docxRenderer';
import { buildXlsxWorkbook, exportDocumentToXlsx } from './xlsxRenderer';
import { Packer, ImportedXmlComponent } from 'docx';
import JSZip from 'jszip';
const SAMPLE_MD = `# 4K超高清荧光内窥镜系统立项与可行性报告
@@ -59,11 +60,13 @@ test('DOCX 渲染器行内文本与公式解析测试 (parseInlineContent)', ()
test('DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml)', () => {
const latex = 'SNR = 10 \\cdot \\log_{10}\\left(\\frac{P_{signal}}{P_{noise}}\\right)';
const displayComp = latexToOmml(latex, true);
assert.ok(displayComp, '展示级公式应当成功转换为 m:oMathPara 组件');
const comp = latexToOmml(latex);
assert.ok(comp, '公式应当成功转换为 m:oMath 组件');
assert.equal((comp as any).rootKey, 'm:oMath', 'rootKey 必须为 m:oMath,绝不能为 undefined');
const inlineComp = latexToOmml('\\sigma_{ambient}', false);
const inlineComp = latexToOmml('\\sigma_{ambient}');
assert.ok(inlineComp, '行内公式应当成功转换为 m:oMath 组件');
assert.equal((inlineComp as any).rootKey, 'm:oMath');
});
test('DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode)', () => {
@@ -100,6 +103,12 @@ test('DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx)',
const buffer = await Packer.toBuffer(doc);
assert.ok(buffer.length > 5000, `生成的 DOCX Buffer 应当大于 5KB,实际为 ${buffer.length} 字节`);
// 关键回归防线:解压验证 word/document.xml 中杜绝 <undefined> 且包含合法的 <m:oMath>
const zip = await JSZip.loadAsync(buffer);
const docXml = await zip.file('word/document.xml')!.async('text');
assert.ok(!docXml.includes('<undefined>'), 'Word document.xml 绝对不得包含 <undefined> 破坏性标签');
assert.ok(docXml.includes('<m:oMath'), 'Word document.xml 必须包含合法的微软 Office Math (<m:oMath>) 节点');
// 验证 exportDocumentToDocx 输出 Blob 正常
const blob = await exportDocumentToDocx(ast);
assert.equal(blob.type, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');