.NET规则驱动Word生成:用Open XML SDK实现自动化文档

发布时间:2026/10/22 20:26:43
.NET规则驱动Word生成:用Open XML SDK实现自动化文档
1. 为什么是基于规则而不是写死代码先想清楚再动手在开始任何一行代码之前我想先聊聊一个很关键的理念问题你究竟是打算写一个生成Word文档的工具还是打算写一套生成Word文档的规则系统这两者的差距本质上决定了你未来维护这套代码是轻松还是痛苦。我接手过不少类似的需求最常见的一幕是这样的某个业务方拿着一份格式极其讲究的红头文件或者项目报告说这个每周都要出数据从系统里查出来填进去就行你帮我做个自动化。听起来简单但如果你只是在代码里硬编码一些段落、表格、字体设置把数据填进去那确实能跑。可一旦业务方说标题换个颜色表格加个序号这段要分页——你就得改代码、改布局、重新测试、重新部署一套流程下来几个小时就过去了。而业务方还觉得你就是改个参数而已。这就是基于规则的真正价值所在。所谓规则就是把排版经验、文档结构、样式约束从代码里剥离出来变成外部可配置、可扩展、可解释的数据。代码只做一件事解释规则并执行规则。业务想改文档形态的时候不用动一行程序逻辑只需要改规则配置甚至业务人员自己都能完成。这听起来像是过度设计但当你面对的是几十种文档类型、上千个文档模板的时候规则化几乎是唯一的活路。规则系统和硬编码的分界线在哪里我自己的判断标准是这样的如果文档结构基本固定、每周只生成一到两类硬编码完全够用不要引入规则引擎那是过度设计。但如果文档类型超过三类、或者结构调整频率高于一个月一次、又或者同一套排版逻辑要被多个模块复用就应该立刻切换到规则驱动的架构。你付出的代价是前期多写一层抽象换来的是后期修改文档时的从容。2. .NET里操作Word的技术路线每一条路都有看不见的坑选定规则理念之后下一个问题就是技术选型。.NET生态里操作Word文档至少有三条主流路线各自适用的场景天差地别我分别说清楚。2.1 COM自动化能不用就尽量别用COM自动化是最听起来取巧的方案——直接在代码里启动Word进程像人一样操作Word界面。这在早年间的桌面工具里非常流行缺点是极其明显慢、依赖客户端安装Office、进程崩溃后可能残留内存、服务器环境下微软也明确不推荐。杀进程、等句柄、处理弹窗这堆破事会耗掉你大量时间。但有一条COM路线例外当你的产物是体积小、逻辑完全覆盖不了复杂交互的脚本工具时用COM做快速原型几乎是最省力的。比如说做一个给同事内部用的把Excel数据塞进Word模板的小工具出了问题重启一下就好不需要追求生产级稳定性那COM的简单粗暴力完全是可以接受的。2.2 Open XML SDK生产环境的合理选择Open XML SDK走的是另一条路它不启动Word而是直接读写 .docx 文件内部的XML结构。.docx本质上是一个zip压缩包里面是若干XML文件Word只是渲染这些XML的引擎。Open XML SDK把这些底层结构映射成了强类型的.NET对象你可以用代码精确控制段落、表格、样式、分页符、页眉页脚等一切元素。这条路线的好处非常多不依赖Office安装、适合服务器批量生成、内存可控、结构清晰。但坏处也明显——学习曲线陡峭。你面对的不再是把字符串追加到文档末尾而是Paragraph、Run、Text、ParagraphProperties、RunProperties这样一个元素树初次接触很容易产生我到底在写文档还是在搭积木的困惑。正常来说Open XML SDK是大多数中大型项目的最佳选择尤其是需要规则化、批量生成、模板化处理的场景。2.3 模板引擎占位符方案业务侧的亲和力还有一个经常被低估的方案Open XML本身并不直接提供把{CustomerName}占位符替换成真实数据这样的模板能力但你可以用模板方式做。也就是说先用Word软件做好一个 .docx 模板里面写好占位符然后在代码里打开模板、定位文本、替换内容、导出新文档。这种方案最受业务欢迎因为模板本身是可视化维护的——业务人员会用Word他们可以直接调整模板样式。你只需要保证占位符名称一致以及替换逻辑健壮即可。它的局限在于复杂条件逻辑比如某些段落有数据才出现、没数据就删掉实现起来比较绕Open XML SDK的定位和删除操作需要你手工处理。如果要走模板路线我建议结合Open XML SDK来做占位符定位而不是用简单的字符串替换。因为WPS和Word在保存文档时可能会把文本拆成了多个run片段一个完整的{CustomerName}可能会被拆成{CustomerName}三段直接字符串替换会漏掉。这算是模板方案里很经典的一个坑。2.4 怎么选一张决策表帮你判断我不太喜欢说哪个最好更愿意给一张基于实际约束的决策表场景特征推荐路线理由客户端工具终端装有Office量小COM自动化快、简单、能复用Word所有能力服务端批量生成文档类型结构化Open XML SDK稳定、可部署、可控、无进程残留业务人员要维护模板样式模板Open XML替换模板可视化结构由业务方向主导超大批量、数千份文档每秒Open XML SDK纯代码依赖最少、可并行、内存可控对Word高级特效要求极高宏、嵌入式OLE、复杂协同COM或者人工处理Open XML覆盖不全面时该认怂就认怂你问我个人偏好我基本无脑选Open XML SDK尤其是当你已经决定走规则驱动这条路时。COM更适合做原型验证而不是生产跑批。3. 规则设计的核心把排版经验翻译成机器可执行配置前面说了这么多其实都在铺垫一个更核心的问题规则该如何设计才能既灵活又不过度复杂这一节我讲清楚规则系统的元模型和我自己验证过的一套设计方法。3.1 三个维度理清规则内容、结构、样式规则不是一股脑塞进一个配置项里。我拆成三层每一层关注的问题完全不同内容规则决定数据从哪里来、如何计算、哪些需要展示。比如说一份项目报告的某段摘要内容是数据库里几条记录拼接在一起如果某项为空则整段不输出。这就是内容规则的事情跟格式无关。结构规则决定文档由哪些部分构成、顺序如何、哪些重复、哪些可选。例如一份验收报告就由封面、概述、测试记录、结论、附件构成其中的测试记录是一个循环区块每条测试记录对应一张表格。结构规则是文档的骨架。样式规则决定每一个区块长什么样——字体字号、加粗斜体、对齐方式、缩进、行距、边框底纹等。样式规则要和内容、结构彻底分离这样同一套内容才能在不同场景下穿戴不同的衣服。这三层分离做得好规则系统的维护成本会急剧下降。业务要改样式只动样式规则。需求调整内容只动内容规则。新增一个结构部分只要结构规则支持加一个节点就好。3.2 一个可落地的规则配置长什么样为了让你看得见摸得着我用一个C#类的形式展示核心规则对象。下面这段来自我实际项目里的简化版设计public class DocumentRule { public string RuleId { get; set; } // 规则唯一标识比如 REPORT_COVER public string DocumentType { get; set; } // 文档类型报告、合同、验收单…… public RuleType Type { get; set; } // 内容 / 结构 / 样式 public int Priority { get; set; } // 优先级数值越大越先执行 public string TargetSelector { get; set; } // 目标选择器作用于哪个区块/元素 public Dictionarystring, object Params { get; set; } // 具体参数 } public enum RuleType { Content, Structure, Style }这里每个字段都不是随意设计的。RuleId用于追踪规则来源排错时能直接看到是哪个规则造成了问题Priority解决规则之间的冲突这一点我在下一小节细讲TargetSelector我们用了一个极简的路径表达法比如root/cover/title或者root/records/item/table类似于CSS的类路径。实际运作时我往往是把规则配置存成JSON文件或数据库表程序启动时加载到内存生成文档时按规则逐条执行。下面这段代码展示规则如何被加载进一个执行器var ruleExecutor new RuleExecutor(); ruleExecutor.LoadRules(File.ReadAllText(rules/contract_rules.json)); var documentContext new DocumentContext { DataSource GetReportData(), OutputPath output/contract_2025.docx }; ruleExecutor.Execute(documentContext);RuleExecutor内部做的事情其实不复杂遍历规则集合按照优先级排序对命中TargetSelector的部分执行对应动作。真正复杂的是规则动作本身——比如对段落应用样式这个动作背后涉及到大量Open XML节点的构建这部分我放到第4章讲。3.3 冲突解决优先级、就近原则、显式继承规则一多必然出现冲突。最典型的就是同级标题默认二号黑体但特定章节的标题要三号宋体。这时候怎么处理我的策略很简单三个原则依次判定显式优先级规则上标了Priority数值高的先执行后执行的覆盖先执行的。如果两个规则都命中同一个目标优先级高的说了算。就近原则如果规则作用在结构树的子节点上子节点规则优先于父节点规则。比如文档全局正文是小四宋体但这个段落自己标了 FONT_SIZE14那就听段落的。显式覆盖标记如果一条规则确认需要无视上述两条就给它一个强制标志ForceApplytrue少见但偶尔需要。这套逻辑的核心是避免规则之间互相踩脚。规则系统最怕的不是规则多而是规则之间的关系完全不可预测。有了明确的冲突仲裁机制至少任何一种意外输出都能追溯到某条规则身上——这在生产环境里极其重要。4. 实战用Open XML SDK实现一个规则驱动的文档生成器这章开始动手写代码。我以项目验收报告为例完整展示从空白文档到规则驱动生成的全过程。我用的环境是 .NET 8 DocumentFormat.OpenXml 3.0Visual Studio 2022代码同样适用于 .NET 6/7。4.1 项目初始化和包引入创建一个控制台应用程序然后引入必要的NuGet包。除了Open XML SDK本身我认为System.Text.Json做规则配置解析就够了不需要额外引入重型依赖。dotnet new console -n RuleDrivenWordGen cd RuleDrivenWordGen dotnet add package DocumentFormat.OpenXml在项目入口里先初始化生成器using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace RuleDrivenWordGen; class Program { static void Main(string[] args) { var generator new ReportGenerator(rules/acceptance_report.json); generator.AddData(new ReportData { ProjectName 某跨平台系统, ReportDate DateTime.Now, Tester A同学, TestItems new ListTestItem { new TestItem { Name 登录功能, Status Status.Passed, Remark 并发10用户响应时间达标 }, new TestItem { Name 订单流程, Status Status.Passed, Remark 核心链路正常 }, new TestItem { Name 异常恢复, Status Status.Failed, Remark 断网重连场景存在缺陷 } } }); generator.Generate(output/验收报告.docx); Console.WriteLine(文档生成完成); } }这里的ReportGenerator是核心类它负责读取规则、解析规则、调用Open XML生成文档。下面我分步骤展示它内部的几个关键方法。4.2 构建文档骨架页面、页眉、页脚和基础样式一个看起来专业的Word文档首先不在于内容多花哨而在于页面骨架是否完整——页边距对不对、有没有页眉页脚、页码从第几页开始、标题样式是否一致。这些基础配置在Open XML里对应的是SectionProperties和几个Paragraph组合。下面是构建页面设置的核心代码using (WordprocessingDocument doc WordprocessingDocument.Create( outputPath, WordprocessingDocumentType.Document)) { var mainPart doc.AddMainDocumentPart(); mainPart.Document new Document(); var body mainPart.Document.AppendChild(new Body()); // 页面设置A4、上下边距2.54cm、左右2.0cm var sectionProps new SectionProperties( new PageSize { Width (UInt32Value)11906U, Height (UInt32Value)16838U }, new PageMargin { Top 1440, Right 1134, Bottom 1440, Left 1134, Header 851, Footer 992 } ); body.AppendChild(sectionProps); // 添加页眉 var headerPart mainPart.AddNewPartHeaderPart(); var header new Header(); var headerParagraph new Paragraph( new ParagraphProperties( new Justification { Val JustificationValues.Right }), new Run( new RunProperties(new RunFonts { Ascii Microsoft YaHei, HighAnsi Microsoft YaHei }), new Text(某公司项目验收专用)) ); header.AppendChild(headerParagraph); headerPart.Header header; // 页眉引用到章节 sectionProps.AppendChild(new HeaderReference { Type HeaderFooterValues.Default, Id rIdHeader }); }这里有两个细节值得注意一是页边距的度量单位问题。Open XML里默认长度单位是TwentiethsOfADegree1英寸1440个单位所以边距1134实际上是0.7875英寸约2厘米。如果你直接写1134却在心里以为是1134毫米出来的文档边距会离谱到没法看。我的习惯是先封装一个单位转换方法static UInt32Value CmToTwips(double cm) (UInt32Value)(int)Math.Round(cm * 567);二是页眉引用的Id必须和mainPart的HeaderPart关系匹配。你需要先AddNewPartHeaderPart()然后在主文档里通过HeaderReference关联关系ID。这个ID不是随便写的要从关系缓存里拿。正确的做法是先创建mainPart和HeaderPart建立关系后再设置引用否则Word会提示文档损坏。完整代码如下var headerPart mainPart.AddNewPartHeaderPart(); headerPart.Header header; string headerRelId mainPart.GetIdOfPart(headerPart); sectionProps.AppendChild(new HeaderReference { Type HeaderFooterValues.Default, Id headerRelId });页脚也是一样的套路只是在页脚里加的是页码字段代码。页码的FieldChar是Open XML里最绕的部分之一好在它结构固定做成辅助方法复用即可public static Paragraph CreatePageNumberField() { var run new Run(); var fieldCharBegin new FieldChar { FieldCharType FieldCharValues.Begin }; var instrText new FieldCode( PAGE ); var fieldCharEnd new FieldChar { FieldCharType FieldCharValues.End }; run.Append(fieldCharBegin); run.Append(instrText); run.Append(fieldCharEnd); return new Paragraph( new ParagraphProperties(new Justification { Val JustificationValues.Center }), run); }4.3 规则解析器如何把JSON规则映射成文档元素骨架搭好接下来是最有意思的部分——让规则驱动正文的生成。我设计的规则JSON大致长这样[ { RuleId: SEC_TITLE, DocumentType: acceptance_report, Type: structure, Priority: 10, TargetSelector: root/section[nametitle], Params: { Text: {ProjectName}验收报告, Level: 1 } }, { RuleId: SEC_PARAGRAPH, DocumentType: acceptance_report, Type: style, Priority: 5, TargetSelector: root/section[nameoverview]/paragraph, Params: { FontSize: 12, Font: SimSun, LineSpacing: 1.5, IndentFirstLine: true } } ]解析逻辑如下拿到一个规则判断TargetSelector是否命中当前正在构建的文档元素然后根据Type分派到不同的执行器。public void ApplyRule(DocumentRule rule, DocumentContext context) { switch (rule.Type) { case RuleType.Content: ApplyContentRule(rule, context); break; case RuleType.Structure: ApplyStructureRule(rule, context); break; case RuleType.Style: ApplyStyleRule(rule, context); break; default: throw new NotSupportedException($Unsupported rule type: {rule.Type}); } }ApplyStructureRule负责在文档里创建节点骨架——标题、段落、表格、分页符。ApplyContentRule负责把数据填进这些节点——查找{ProjectName}之类的占位符替换成真实数据。ApplyStyleRule才是重头戏所有字体、颜色、间距、缩进都在这里处理。样式应用的核心代码如下我用一个辅助方法来设置段落格式public void ApplyStyleToParagraph(Paragraph paragraph, StyleRuleParams style) { var pPr paragraph.ParagraphProperties ?? paragraph.AppendChild(new ParagraphProperties()); // 字体大小 var rPr new RunProperties(); if (style.FontSize 0) { rPr.FontSize new FontSize { Val (string)(style.FontSize * 2).ToString() }; rPr.FontSizeComplexScript new FontSizeComplexScript { Val (string)(style.FontSize * 2).ToString() }; } // 字体 if (!string.IsNullOrEmpty(style.Font)) { rPr.RunFonts new RunFonts { Ascii style.Font, HighAnsi style.Font, EastAsia style.Font }; } // 行距 if (style.LineSpacing 0) { var spacing pPr.GetFirstChildSpacingBetweenLines(); if (spacing null) { spacing new SpacingBetweenLines(); pPr.Append(spacing); } spacing.Line (style.LineSpacing * 240).ToString(); spacing.LineRule LineSpacingRuleValues.Auto; } // 首行缩进2字符 if (style.IndentFirstLine) { var indentation pPr.GetFirstChildIndentation(); if (indentation null) { indentation new Indentation(); pPr.Append(indentation); } indentation.FirstLineChars 200; // 200 2字符 } // 将rPr应用到段落的所有run上 foreach (var run in paragraph.DescendantsRun()) { run.PrependChild((RunProperties)rPr.CloneNode(true)); } }注意这里的几个进制关系FontSize的单位是半磅1号1磅2个FontSize单位所以FontSize12实际要写24。LineSpacing的单位是1/240行1.5倍行距要写360。Indentation的FirstLineChars是以百分之一字符为单位的2字符缩进就是200。这些都是Open XML的经典智商税我不查文档永远记不住。4.4 表格和列表的自动排版从无序数据到规整区块验收报告的核心往往是那张测试记录表。规则驱动下表格的生成逻辑是先看结构规则里有没有表格区块再看这一区块要展示哪些数据列然后根据数据量和表格样式自动构建行和单元格。生成基础表格的代码如下public Table BuildTable(ListTestItem items) { var table new Table( new TableProperties( new TableStyle { Val TableGrid }, new TableWidth { Width 100%, Type TableWidthUnitValues.Pct }), new TableGrid( new GridColumn { Width 2200 }, new GridColumn { Width 1200 }, new GridColumn { Width 3800 }) ); // 表头 var headerRow new TableRow( new TableRowProperties(new TableRowHeight { Val 450, HeightType HeightRuleValues.Exact }), new TableCell(BuildCellParagraph(测试项, true)), new TableCell(BuildCellParagraph(结果, true)), new TableCell(BuildCellParagraph(备注, true)) ); headerRow.TableRowProperties!.AppendChild( new Shading { Fill D9E2F3, Val ShadingPatternValues.Clear } ); table.Append(headerRow); // 数据行 foreach (var item in items) { var row new TableRow( new TableCell(BuildCellParagraph(item.Name, false)), new TableCell(BuildCellParagraph(item.Status.ToString(), item.Status Status.Passed)), new TableCell(BuildCellParagraph(item.Remark, false)) ); table.Append(row); } return table; } private static Paragraph BuildCellParagraph(string text, bool bold) { var run new Run(new Text(text)) { RunProperties new RunProperties( new RunFonts { Ascii SimSun, EastAsia SimSun }, new FontSize { Val 21 }, new Bold { Val bold } ) }; var para new Paragraph(run); para.ParagraphProperties new ParagraphProperties( new Justification { Val JustificationValues.Center }); return para; }这个示例里我用了固定列宽GridColumn但在真实项目里我倾向于用百分比宽度配合自动表格布局因为不同报告的数据长短差异很大固定列宽很容易把备注挤到只剩一列字。改成自适应布局也很简单把TableProperties里的TableLayout设为Fixed或Autofit即可。需要注意的是Open XML里表格的每个TableCell内部必须至少包含一个Paragraph否则文档结构不合法Word打开时会提示修复。我吃过这个亏写了一个少段落的表格代码跑得欢打开看就报文档中不可读取的内容。4.5 输出校验、保存和自动化流程对接文档生成完毕收尾工作别轻视。我的固定动作包括三件事校验Open XML结构、关闭释放文件流、可选的Word兼容性检查。结构校验最小可行方案如下它会捕获大部分结构性错误public void ValidateDocument(WordprocessingDocument doc) { var validator new OpenXmlValidator(); var errors validator.Validate(doc).ToList(); if (errors.Any()) { foreach (var error in errors) { Console.WriteLine($Error: {error.Description} (Path: {error.Path.XPath})); } } }另外保存文档时我强烈建议用文件流的方式显式关闭避免文件被占用public void Generate(string outputPath) { using var stream new FileStream(outputPath, FileMode.Create, FileAccess.Write); using (var doc WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) { // ... 构建文档逻辑 ValidateDocument(doc); doc.MainDocumentPart!.Document.Save(); } Console.WriteLine($Saved to {outputPath}); }到这里一个面对项目验收报告的规则驱动生成器已经能用了。但你如果以为这就能应付全部业务那就太天真了——规则越加越多、文档类型越来越复杂之后你才会遇到真正的系统性问题我放在第5章讲。5. 走向稳定生产级规则库维护、回归测试与性能调优从demo能跑到生产环境天天跑之间隔着一堆工程问题。这一章聊三个我认为最重要的问题怎么管理规则库、怎么保证每次改规则不炸、怎么在批量生成时压住资源消耗。5.1 把规则配置外部化是配置文件还是数据库规则可以放在三个地方嵌入代码、独立JSON/XML文件、数据库表。我个人的经验是规则数量少于50条的阶段用独立JSON文件就够了超过了才开始考虑引入数据库。JSON的优点是改完立即生效、可以用版本管理工具追踪每次变更差异、测试时容易构造任意场景。我在项目里是这样组织的rules/ acceptance_report.json contract_copy.json test_record.json每个JSON文件对应一种文档类型相互独立。还有一种跨类型共享的基础规则我放在common/base_style.json里启动时先加载公共规则再加载文档特有规则。这里有一个继承关系的处理也就是第3章说的就近原则的实现文档特有规则覆盖公共规则所有规则合并后统一按优先级排序执行。如果哪天规则数量大到JSON文件都管理不了再考虑数据库。但我提醒一句规则是很强的代码它跟代码一样有bug、需要测试。如果数据库化的成本高于收益别为了看起来更系统而强行上。5.2 回归测试用黄金文档守住建站底线规则驱动系统最怕改一处坏了整个文档。我的土办法是黄金文档对比——把业务认可的一批标准文档保存下来每次规则变更后重新生成再用工具比较新旧文档的内容和格式差异。比较最快捷的方式是解压两个 .docx逐个对比document.xml或者用XmlDiff工具。但纯文本对比会遇到样式噪音更实用的方式是只比对关键属性标题文本、段落数量、表格行列数、关键字体字号。我写了一个简单的断言式测试框架[Fact] public void AcceptanceReport_NewRule_ShouldNotChangeCoverLayout() { var generator new ReportGenerator(rules/acceptance_report.json); generator.AddData(SampleData()); var outputPath output/test_report.docx; generator.Generate(outputPath); using var doc WordprocessingDocument.Open(outputPath, false); var body doc.MainDocumentPart!.Document.Body!; // 断言封面标题存在且是唯一 var titleParagraphs body.DescendantsParagraph() .Where(p p.InnerText.Contains(验收报告)) .ToList(); Assert.Single(titleParagraphs); // 断言表格数量和数据行数 var tables body.DescendantsTable().ToList(); Assert.Single(tables); Assert.Equal(3, tables[0].DescendantsTableRow().Count() - 1); // 减去表头 }这类测试平时看着蠢但规则系统改动频繁之后它们就是你的安全网。我见过不止一次因为调整某个通用样式导致所有合同文档的落款日期从右对齐变成了左对齐——这种问题在测试框架下一秒钟就能暴露。5.3 性能优化批量生成中的CPU、内存与并发控制批量生成几百份文档时性能问题会立刻浮出水面。Open XML SDK本身计算量不小但更大的瓶颈常常是I/O和对象分配。几个非常实用的经验复用公共资源如果所有文档都使用同一套规则、同一个模板骨架那就只初始化一次WordprocessingDocument的对象模板复制MainDocumentPart.Document.OuterXml作为新文档的基础比每次从磁盘加载模板快得多。并行生成时要限流Open XML SDK线程安全是局部的——不同的文档实例可以并行但共享同一个模板实例就可能出问题。建议用Parallel.ForEach搭配一个最大并行度。我常用Parallel.ForEachAsync配MaxDegreeOfParallelism 4在普通云服务器上CPU不会顶满文档也不会乱。及时释放对象用using包裹所有的文档流、MemoryStream、WordprocessingDocument。尤其是服务端场景打开句柄太多会拖垮整个进程。避免无意义的格式化别对每个Run都执行CloneNode深拷贝。样式是固定的那个就固定用同一个对象引用只有需要改单处的时候才克隆。5.4 可扩展性新文档类型上线的标准路径当业务方说下周要新增一个设备巡检报告规则系统应该让这件事变成一个标准流程而不是又一场代码大战。我的标准路径是梳理文档结构拆成封面、基础信息区、巡检项表格、异常记录区、结论区。确定数据源巡检项来自哪个接口、结论字段如何计算。编写结构化规则把上面两个步骤翻译成JSON规则。写一份最小黄金文档示例提交给业务确认样式。把示例文档录入回归测试。这个流程走顺之后新增一种文档类型大多只需要一天时间而且大部分时间花在等业务确认样式上不是被代码卡住。这才是规则驱动架构最重要的回报——它把写文档生成代码从软件开发问题变成了配置数据模型样式参数的配置问题。6. 我踩过的坑和最终沉淀下来的建议最后这一章聊一些零散但极其影响心情的实战经验。有些坑踩到过才明白写在这里希望你能绕开。6.1 中文字体和编码的坑Word文档里中文字体设置不是只看RunProperties里的Ascii就完事很多时候真正起作用的是EastAsia属性。如果你只设置了AsciiSimSun而忘了EastAsia文档在中文字符上可能是默认字体甚至乱码。更隐蔽的问题出现在换行符和缩进上。Open XML里中文段落的缩进理想的单位是字符而不是厘米。2字符首行缩进用Indentation.FirstLineChars 200就可以了但如果你混用了FirstLinePoints单位不同Word版本的解析结果会有差异同一份文档在WPS和Word里看起来可能不一样。我的原则是中文字符相关的排版尽量用FirstLineChars这类相对单位少用绝对长度。6.2 图片插入与尺寸控制Open XML插入图片的逻辑其实是在文档里插入一个Drawing元素同时把图片二进制存成ImagePart。这里最经典的问题有两个。第一个是尺寸单位。Open XML图片尺寸默认单位是EMUEnglish Metric Unit1英寸914400 EMU。如果直接从文件流读取图片想保持原始尺寸你需要读取图片像素尺寸再计算EMU。我的做法是用System.Drawing或ImageSharp读一次图片宽高然后按比例换算int emuWidth (int)(pixelWidth / dpi * 914400); int emuHeight (int)(pixelHeight / dpi * 914400);第二个问题是占位符替换场景。你计划在模板里放一个图片占位符然后替换成真实图片这在Open XML里比想象中麻烦——你需要定位Drawing元素的Extent属性替换图片同时更新尺寸。这比纯文本替换费劲得多我的建议是如果这类需求频繁就把图片区做成规则中的一个独立区块从结构上避免占位符操作的复杂度。6.3 进程残留和服务端部署教训走COM路线时的一大坑是Word进程残留。程序崩溃或者异常退出后WINWORD.EXE会一直赖在内存里尤其在生产服务器上跑定时任务连续跑几天服务器就卡到无法动弹。我早期做服务端文档生成时踩过这个雷后来实在不堪其扰才彻底切换到Open XML SDK。切过来之后还有一个隐藏坑WordprocessingDocument在操作结束后务必显式Dispose()哪怕是在C#这种有GC的环境里。数据库连接池、文件流、临时文件这些资源都要想在前面。服务端一次性生成几十份文档时如果处理不当临时文件目录会被撑爆。6.4 从能生成到生成得好看的三个阶段如果你准备做一个长期维护的Word自动生成系统我建议把目标分三个阶段来定第一阶段能跑通。先把数据塞进去能产生合法文档不追求美观。对应的是能处理基本段落、表格、标题结构校验零错误。第二阶段能复用。抽象出规则让不同的文档类型共享样式和结构管理好优先级、冲突和文件组织。第三阶段好看。在第二阶段基础上精细打磨样式规则合理的页边距和分页策略、表格边框的视觉层次、段落在跨页时的关系设置KeepNext、KeepLines、标题与正文的字号节奏。我看到很多人一上来就追求第三阶段结果死在能跑通这道坎上。文档自动化的地基是稳定的结构生成地基不稳样式越精细文档越容易在各种边界情况下跑飞。最后分享一个我个人的习惯每次生成完文档我都会用WPS和Word各打开看一眼因为两个渲染器在个别设置上会有细微差异。这个动作花不了两分钟但能帮你提前暴露很多规则配置里的隐性错误。如果连这一步都没时间做那说明这套流程的成本控制还需要继续优化——而这恰恰是规则系统应该替你解决的事情。