Skip to main content

一次复杂习题册排版系统的重构实录

自动套用 Word 模板,听起来像是一个格式复制问题:读取参考文档的字体、字号、行距和页边距,再把这些参数写到目标文档中。 如果文档只有几段文字,这个理解大致成立。但当输入变成一份真实习题册——横向页面、双栏、页眉页脚、二维码、浮动图片、制表位、上千个可编辑 MathType 公式——“复制格式”很快会变成“如何在不破坏文档的前提下重写它”。 我们为此尝试的 word-format skill,最初走的是一条直观路线:让 Word 把参考文档导出为 HTML,替换文字,再通过浏览器和剪贴板粘回 Word。它在少量内容生成上工作得很好,却在完整习题册上暴露了根本缺陷:页面看起来仍有公式,但原来的 Equation.DSMT4 OLE 对象已经退化成 PNG;页眉、关系、双栏结构和嵌入对象也无法得到可靠保证。 最终,系统的主线从“重建一个看起来相似的文档”,转向“保留源 DOCX 容器,只修改经过确认的 OOXML 节点”。 这次重构带来的最大认识是:
复杂 Word 排版不是视觉参数的集合,而是一次受约束的文档结构迁移。
本文讲清楚这个结论是如何得到的、当前方案怎样工作,以及为什么“结构审计 + 全页渲染”缺一不可。

第一版为什么看起来合理

Word 自己能够把 DOCX 导出为 Filtered HTML。导出的页面保留了大量段落标签、类名和行内样式,因此最初的处理链路非常自然:
这个方案有三个现实优势。 首先,格式解释工作交给了 Word 和浏览器,而不是自行实现一套排版引擎。其次,新增少量内容时,可以直接复用参考段落的字体、缩进和行距。最后,在“续写模式”中先复制原 DOCX,再把新内容粘到副本末尾,原文档的页面设置、样式表、主题和页眉页脚仍然存在。 Windows 版本还解决了几项自动化细节:使用 Word COM 导出和保存文档;通过隔离的浏览器 profile 找到本轮渲染窗口,避免误复制用户已有的 Chrome 页面;完成后只关闭脚本自己创建的 Word 和浏览器实例。 在“根据参考格式追加几段内容”这个问题上,HTML 路线至今仍有价值。真正的问题,是我们一度试图让它承担整篇复杂文档的重建。

一个仍能打开的 DOCX,也可能已经坏了

第一次处理含大量 MathType 公式的习题册时,输出文件可以打开,页面上也能看到公式。只看截图,转换似乎成功。 但双击公式后,MathType 无法再编辑它。检查 DOCX 内部结构才发现,Word 导出的 HTML 只保留了公式的预览图,浏览器剪贴板传回的也是图片。原来的 OLE 二进制对象没有穿过这条转换链。 这类失败危险之处在于,它不是明显的文件损坏,而是能力的静默退化:
  • 公式仍然可见,但已经失去可编辑性;
  • 标题看起来在正确位置,实际可能依赖错误的分页符;
  • 页面样式大致相似,母题间距和选项对齐却被直接格式覆盖;
  • OLE 数量可能没有显著异常,公式仍可能在页面中截断或重叠。
因此,“Word 能打开”和“第一页看起来正常”都不是合格的验收条件。 要理解为什么,必须先把 DOCX 当成它真正的样子:一个由 XML、关系文件、媒体和二进制对象共同构成的 ZIP 容器。

DOCX 不是一份文本,而是一张关系网

一个典型 DOCX 可以简化为下面的结构:
正文段落和分栏符主要位于 word/document.xml,样式定义位于 word/styles.xml。图片和公式预览位于 word/media,可编辑 MathType 对象位于 word/embeddings,XML 中的 r:id 再通过 .rels 文件指向这些部件。 这意味着一个公式不是单独的 <w:object> 标签。它同时依赖正文中的对象节点、关系条目、OLE 二进制和预览资源。只复制 XML 标签而没有复制关系与部件,或让 HTML 把对象扁平化为图片,都会破坏完整性。 样式也比想象中复杂。Word 的最终视觉效果通常由两层叠加:
  • styles.xml 中的可复用样式;
  • 具体段落或 run 上的直接格式。
同一个“母题”样式,可能在首段、题间或特殊位置带有不同的段前间距;选项对齐可能依赖段落制表位;页码可能位于页脚的 VML 文本框。只复制样式名称,无法复现这些实际行为。 系统因此从“转换格式”转向一个更保守的问题:哪些节点必须改,哪些部件必须原样保留?

从模板截图到结构合同

在 M1 纯享版模板上,我们没有继续凭肉眼抄录几个字号,而是做了一次只读“模板蒸馏”:把模板拆成可解释、可测量、可执行的规则。 提取范围包括:
  • 页面方向、尺寸、页边距和文档网格;
  • 双栏数量、栏宽与栏间距;
  • 页眉、页脚、页码域和其中的图片关系;
  • “章节名称”“题型”“母题”“子题”“解析”等语义样式;
  • 样式之外的段落间距、缩进和制表位覆盖;
  • 图片、二维码、MathType/OLE 和关系部件;
  • 页面级与结构级验收条件。
模板中有 1,569 个 Equation.DSMT4 对象。这个数字并不是排版参数,却是非常重要的结构基线:任何全量格式迁移,都不能让这些对象在不知情的情况下变成图片或消失。 蒸馏后的模板不再只是一个“看起来正确的 Word 文件”,而是一份结构合同:规定页面应该如何组织、段落语义如何映射、哪些直接格式需要保留或清理,以及输出必须通过哪些检查。 这一步也改变了实现方式。程序不再试图复制模板中的全部内容,而是从模板读取规则,把这些规则应用到源文档自己的内容和容器上。

当前架构:保留源容器,只改明确目标

当前 OOXML 主线可以概括为四个阶段:检查、迁移、审计和渲染。

1. 检查:记录处理前的结构基线

系统先读取模板中的自定义样式和公式对象数量,同时检查源文档的 Equation.DSMT4、OLE、媒体和主要段落结构。 这个阶段不修改文件。它要回答的是:模板实际提供了哪些语义样式?源文档包含多少需要保留的对象?当前文档是否符合现有分类规则? 如果模板没有预期的“题型、母题、解析”等语义,继续批量执行只会让错误扩大。自动化的第一道安全门不是“脚本能不能跑”,而是输入是否属于它理解的文档类型。

2. 迁移:修改 document.xmlstyles.xml

apply_exercise_template_ooxml.ps1 打开模板和源 DOCX 的 ZIP 包,读取双方的 word/document.xmlword/styles.xml,然后执行几类受控变更:
  • 把模板的页面几何与自定义样式克隆到源文档;
  • 为克隆样式分配稳定的 m1_* ID,并修复 basedOnnextlink 引用;
  • 根据文本和旧样式特征,将段落识别为章节、题型、母题、子题、解析或正文;
  • 清理会压过模板的直接缩进、间距和加粗;
  • 删除错误的显式分页符,并在规定的语义边界插入分栏符;
  • 标准化独立公式段的 Tab、间距、缩进和对齐。
输出时,脚本从源 DOCX 复制全部 ZIP 条目,只为 word/document.xmlword/styles.xml 写入新内容。word/embeddingsword/media、关系文件、页眉页脚和其他部件继续来自源文档。 这不是“所有非目标字节都不变”的强哈希证明,因为 ZIP 条目会被重新压缩;它保证的是非目标部件的解压内容来自源包、目标 XML 的修改范围明确,并通过后续计数和结构审计检查关键对象是否保留。 写入先落到临时文件,完成后再移动为正式输出,避免失败时留下一个名称正确、内容不完整的 DOCX。

3. 审计:把排版规则变成可判定条件

脚本执行成功并不代表排版正确。audit_exercise_template_ooxml.ps1 会重新打开输出包,将关键要求转换为机器可以判断的条件,例如:
  • 显式分页符应被清除;
  • 分栏符数量应与题型、检测题和反馈通道边界匹配;
  • 解析正文和 Step n 后续内容不能残留直接加粗;
  • 普通正文不能继续携带异常的直接缩进;
  • 独立公式段不能保留错误的定位 Tab;
  • 可删除的真空段不能继续制造版面间隙;
  • 与源文档相比,公式标记、OLE 部件和媒体部件数量不能异常变化。
任一条件失败,审计器返回非零状态。这样,“排版要求”不再只存在于人的经验里,而成为可重复执行的门禁。

4. 渲染:结构正确仍不等于页面正确

审计通过后,系统用 Word COM 将 DOCX 导出为 PDF,再通过 Windows PDF 渲染 API生成逐页 PNG、检查页和 JSON 报告。 页面检查关注的是机器计数很难发现的问题:
  • 题型是否真正从下一栏顶部开始;
  • 母题是否被不必要地拆开;
  • 公式是否缺失、截断、重叠或偏移;
  • 二维码和图片是否完整;
  • 是否出现异常空白页;
  • 解析正文是否因为 run 级粗体残留而整段变黑。
渲染工具给出的 PASS 只表示 PDF 和页面图片完整生成,不能代替逐页视觉检查。结构审计证明“文档内部符合规则”,全页渲染证明“Word 实际把它画对了”。两者回答的是不同问题。

三个看似局部、实际决定可靠性的细节

分页符和分栏符不是同一件事

双栏习题册中,新题型通常应从下一栏开始,而不是跳到下一页。源文档却可能使用 page break 强行制造位置,甚至把分页符藏在一个非空标题段的 run 中。 早期清理逻辑只删除“空白分页段”,因此漏掉了这些隐藏节点。当前实现直接查找所有显式 <w:br w:type="page"/>,删除断点但保留所在段落和文字;随后再根据“题型、检测题、检测题答案、反馈通道”等语义插入真正的 column break。 这个修复说明,Word 自动化不能只按段落外观推断结构。一个段落有文字,不代表其中没有控制页面流的隐藏节点。

粗体必须在 run 层处理

“解析:D”在人眼中是一段连续文字,在 OOXML 中却可能被拆成多个 <w:r>。如果直接给整段取消粗体,会把“解析:”标签的强调也删掉;如果只改段落样式,后续 run 上的直接 w:bw:bCs 仍会压过样式。 当前实现先识别解析标签所在的 run,再只清理标签之后的直接粗体,并将处理范围延续到下一个语义标题。15 份解析版文档的批量纠偏,正是由这个细节推动完成的。

公式段不能只依赖样式名

MTDisplayEquation 表示独立公式段,但源样式可能带居中制表位,公式前还可能存在定位 Tab。直接复制样式会把错误位置一起保留下来。 当前规则在保留公式 OLE 本身的前提下,删除公式前的定位 Tab,将段前段后和缩进归零,并显式左对齐。与此同时,它不会全局删除 xml:space="preserve",因为行内公式前后的保留空格可能正是正文与对象之间所需的分隔。 这里的原则是:按语义缩小修改范围。只处理确认属于独立公式段的几何属性,不对所有 Tab 和空格进行粗暴清洗。

两条路线,而不是一个万能转换器

OOXML 主线并没有让 HTML 路线失去价值。它只是把两类不同任务分开处理。 为了防止误用,HTML 新建模式检测到公式数量标记后会停止,而不是静默输出一个“看起来正常、公式已经不可编辑”的文件。

从单文件试验到批量验证

架构是否可靠,最终要看它在真实文档上的表现。 M1 模板的只读提取记录了 1,569 个 Equation.DSMT4 对象,并形成了人工可读规范与机器可读规格。M18 源文档记录了 1,433 个公式对象,用于验证版式迁移前后的对象保留边界。 在后续批量任务中:
  • 15 份解析版文档完成了 run 级粗体纠偏和容器结构核验;
  • 9 份待续排文档逐文件执行结构审计,9/9 通过;
  • 这 9 份文档共渲染 307 页并完成逐页检查,未发现公式缺失、截断、重叠或异常空白页。
这些结果并不意味着脚本适用于任意 Word 模板。当前语义分类仍明确面向“章节、题型、母题、子题、解析、检测题、反馈通道”这一类习题册结构。 它们真正证明的是另一件事:当处理流程固定为“单文件试跑 → 结构审计 → 全页渲染 → 再批量”,复杂文档排版可以从一次性的人工操作,变成可重复验证的工程过程。

这次重构留下的工程原则

第一,先定义不变量,再讨论如何修改。对于习题册,不变量包括正文内容、公式可编辑性、OLE 与媒体部件、关系网络和未授权的用户文件。没有这些边界,“格式迁移”很容易变成不可控的文档重建。 第二,模板应该被蒸馏为规则,而不是只作为视觉参考。样式、直接格式、页面几何和语义槽位都应被记录;否则每一次套版都要重新猜测。 第三,最小修改面比万能转换更可信。保留源容器,只修改 document.xmlstyles.xml 中确认过的节点,虽然需要理解 OOXML,却显著降低了公式、图片和关系被转换的风险。 第四,自动审计与视觉检查必须并存。对象计数、断点和直接格式适合机器验证;重叠、截断、错栏和页面节奏仍需要完整渲染来发现。 第五,把失败经验固化成门禁。公式退化为 PNG 后,系统增加了 HTML 模式保护;隐藏分页符导致空白页后,清理范围扩展到 run 内部;解析正文误加粗后,处理粒度下沉到 run。可靠性不是一次设计出来的,而是把每个真实故障转化为规则、测试和拒绝条件。

结语

word-format 最初解决的是“怎样把参考 Word 的格式复制过来”。真实文档迫使我们换了一个问题:怎样在改变版式的同时,证明复杂 DOCX 中不该改变的东西仍然存在? 这个问题的答案不再是一条 HTML 转换命令,而是一套分层方法:把模板蒸馏成结构合同,以源 DOCX 为容器做定点 OOXML 修改,用自动审计证明关键约束,再用全页渲染检查 Word 的真实输出。 自动化处理复杂文档,最有价值的能力不是“生成一个能打开的文件”,而是能够清楚说明:修改发生在哪里,哪些结构被保留,以及结果通过了什么证据。