☰
C#处理Word修订:Open XML与COM方案选型与实现
2026/10/13 8:23:06 网站建设 项目流程

做C#自动化的人,早晚都要碰到Word修订(Track Changes)处理。我在某企业合同流转项目里就接到过这个需求:一堆人审过的合同文档,打开全是红色的插入和删除痕迹,还有批注,最终要自动生成一份干净定稿版。手动逐条接受或者拒绝,一份五六十页的文件就能点得人怀疑人生,更别提每天要处理几十份。当时团队不想用宏,因为宏只能在桌面Word里跑,而且改完底稿不好审计,最终敲定用C#来处理接受或拒绝Word修订内容这个需求。

先说结论:这事本身不复杂,核心就是读取文档里的修订集合,挨个判断是做接受还是拒绝,但实现路径和坑比想象中多。选错方案,轻则性能慢到没法忍,重则打开文档直接报错,甚至把文件搞坏。这篇我按实际项目里的技术选型、代码实现、典型报错和绕坑经验展开讲,给后面接手类似活的人省点时间。

1. 先把需求看清楚:修订处理的难点不在API,而在状态语义

1.1 业务场景里最常见的三类处理诉求

纯从操作看,Word的修订功能就是给文档打了标记:插入的内容标下划线,删除的内容标删除线,格式变化用侧边批注条标出来。但在真实项目里,C#处理这个功能会遇到三种典型诉求:

第一种是整体清理,比如某公司要求所有评审文档在归档前必须抹掉全部修订痕迹,这时就是无条件接受所有修订,生成定稿。第二种是筛选清理,比如需要保留某位领导的修订结论,但把其他人的建议全部拒绝,这时就要按作者筛选。第三种是时间维度处理,比如只处理某个时间节点之后的修订,之前的全部保留。

前两种用现有API很快,第三种也不难,但容易被忽略的是:处理的顺序不能乱,因为修订之间存在嵌套关系。比如一段文字先被删除,又被另一条修订重新标为插入,如果先接受删除再接受插入,结果可能是空的;顺序反过来结果就不一样。这是我最初做方案时没考虑到的地方,Logo它成了整个项目里最容易被吐槽的部分。

1.2 三条技术路线怎么选:COM、Open XML SDK、商业库

C#操作Word修订,主流有三条路:通过Word提供的COM组件(Interop.Word),通过Open XML SDK直接解析docx文件,还有利用第三方商业库。选型时我列过一张对比表,后来项目落地就照着这表定的:

对比维度Word COM/InteropOpen XML SDK第三方商业库
环境要求必须安装Word,只能跑Windows无需安装Office无需安装Office
跨平台能力差linux服务器可跑一般支持
API复杂度低,符合直觉中高,需要理解文件结构极低,几行解决
执行性能慢,修订多时特别明显快快
成本Word授权即可免费开源需要买License
适合场景桌面工具、少量文档服务端批量处理预算充足快速交付

如果只是自己写个小工具在Windows桌面跑,选COM最省事,代码短,也好理解。但如果是服务端自动化,比如定时任务、接口调用那种,千万别用COM,一是服务器上装Office本来就拖累性能,二是很多部署环境根本不给你装GUI程序的权限。我们后来选的是Open XML SDK,因为文档量大,还要上Linux容器跑批处理,用COM必然卡死。

如果团队有预算买第三方库,另一个选择是用商业库直接调接受所有修订的接口,代码量会少很多,甚至可以在几行之内完成。但我不建议一开始就图省事买库,因为修订处理涉及文档格式的各种边角情况,商业库也会有一堆格式兼容性问题,不如先理解底层结构。

1.3 修订操作的底层语义:为什么“接受”和“拒绝”总是容易反

很多人写第一版代码时都会把接受和拒绝搞反,原因是没理解Word的修订逻辑。以插入修订为例:某个人新插入了一段文字,这段文字是“候补状态”,接受的意思是让它成为正文正式内容,拒绝的意思是这段文字从文档里消失。

删除修订正好相反:某个人的操作是删掉一段文字,这段文字在文档里仍然可见,但被标成删除状态。接受删除,是让文字真正消失;拒绝删除,是把删掉的文字恢复成原文。

格式修订则更隐蔽,比如改了字体颜色、段落缩进,接受意味着保留新格式,拒绝意味着恢复到旧格式。

一句话总结:接受 = 让文档按修订后的样子定稿;拒绝 = 让文档回到修订前的样子。想通这一层,后面代码写起来才不容易绕晕。

2. 先看最直觉的方案:用Word COM对象模型处理修订

2.1 最小可用代码:遍历所有修订并全部接受或全部拒绝

COM方案最朴素的写法是通过Word应用程序对象的ActiveDocument或者直接打开文件,拿到Document.Revisions集合,然后循环处理。Revisions集合里的每个对象都代表一条修订记录,提供了Accept和Reject方法,还有Author、Date、Type属性供筛选使用。

先看一段能够直接运行的完整代码,功能是把指定文档的所有修订全部接受:

using Word = Microsoft.Office.Interop.Word; public static void AcceptAllRevisions(string filePath) { var wordApp = new Word.Application(); Word.Document doc = null; try { wordApp.Visible = false; wordApp.DisplayAlerts = Word.WdAlertLevel.wdAlertsNone; doc = wordApp.Documents.Open(filePath, ReadOnly: false, Visible: false); foreach (Word.Revision revision in doc.Revisions) { revision.Accept(); } doc.Save(); } finally { doc?.Close(); wordApp.Quit(); System.Runtime.InteropServices.Marshal.ReleaseComObject(doc); System.Runtime.InteropServices.Marshal.ReleaseComObject(wordApp); } }

这里有几个必须注意的点。Word.Application对象要在using之外声明,因为COM对象的释放不是普通的托管内存回收,必须显式Quit,并且调用Marshal.ReleaseComObject来释放引用,否则会在任务管理器里留下一堆WINWORD进程,服务器跑久了内存直接就爆。

doc.Save()前记得确保文档不是只读打开。打开文档时如果文件本身处于受保护状态,还需要先解除保护,否则Accept方法虽然不会抛异常,但操作不会生效,保存时修订还在。

这样的写法够用,但只适合几十条修订的小文件。一旦修订数量过千,这个foreach循环会慢得令人崩溃。原因后面专门讲。

2.2 按作者、按修订类型筛选的写法

项目里更常见的是不全部接受,而是筛选特定人的修订。此时无非就是在循环里加条件判断:

public static void AcceptRevisionsByAuthor(string filePath, string[] targetAuthors) { var wordApp = new Word.Application(); Word.Document doc = null; try { wordApp.Visible = false; wordApp.DisplayAlerts = Word.WdAlertLevel.wdAlertsNone; doc = wordApp.Documents.Open(filePath, ReadOnly: false); var targetSet = new HashSet<string>(targetAuthors, StringComparer.OrdinalIgnoreCase); List<Word.Revision> revisions = new List<Word.Revision>(); foreach (Word.Revision revision in doc.Revisions) { if (targetSet.Contains(revision.Author)) { revisions.Add(revision); } } foreach (Word.Revision revision in revisions) { revision.Accept(); } doc.Save(); } finally { doc?.Close(); wordApp.Quit(); } }

我特意先把符合条件的修订放到一个List里,再遍历这个List去Accept,而不是在第一个foreach里直接Accept。原因很实在:Revision集合是动态的,Accept或者Reject一条之后,文档结构变化,原来的索引和枚举状态可能失效,轻则跳过了后面的项,重则抛COMException说RPC服务器不可用。这个坑我实际踩过,建议照抄这个“先收集再处理”的模式。

按时间筛选也一样,Revision对象有Date属性,拿它跟截止时间比较就行。不过注意COM返回的Date是object类型,可能存在地区格式差异,最好显式转成DateTime再比较:

DateTime deadline = new DateTime(2025, 5, 1); foreach (Word.Revision revision in revisions) { if (DateTime.TryParse(revision.Date.ToString(), out DateTime revTime) && revTime <= deadline) { revision.Accept(); } }

2.3 COM方案绕不开的五个现实问题

COM方案代码短、直观,但实际项目里很多环境根本扛不住它的副作用。我总结几个高频问题:

第一个是性能问题。Word处理修订是走用户界面那套逻辑的,Accept一次就等于UI里点一次“接受修订”,代价是动辄耗时几十毫秒,一批500条修订要跑两三百秒。如果文档有几千条修订,跑完得半小时,这在自动化流水线里是不可接受的。

第二个是环境限制。Windows容器里装Office本来就是灰色地带,Linux更不可能。只要是服务端大规模处理,COM基本可以排除。

第三个是隐藏进程残留风险。只要有一次异常导致Quit没有执行,服务器上就会残留一个Word进程,占用文件锁,别的任务可能再也打不开那个文档。

第四个是格式差异问题。COM方案保留所有Word原生格式基本没问题,因为它本身就是Word,但遇到Tracker窗口或者有宏的文档,可能触发安全提示弹窗,导致程序挂在那里等用户操作。

第五个是审核留痕困难。自动化脚本在服务器上操作COM,很难在出问题的时候定位是哪一条修订导致的崩溃。不像直接操作文件结构那样可控。

所以我们当时只是在快速验证原型的时候用COM,正式系统最终是走了Open XML SDK那套路线。

3. 能扛住批量处理的方案:Open XML SDK直接操作docx

3.1 先看懂修订在docx里到底长什么样

docx本质是一个zip压缩包,里面是各种XML文件。Word的修订信息存得非常有规律:插入的修订用w:ins标签包起来,被删除的内容用w:del标签包起来,这些标签上带着作者、日期、修改ID。下面是一个很典型的已跟踪插入片段:

<w:ins w:id="10" w:author="张三" w:date="2025-04-12T10:30:00Z"> <w:r> <w:t>新增加的内容</w:t> </w:r> </w:ins>

反过来,删除修订通常长这样:

<w:del w:id="11" w:author="张三" w:date="2025-04-12T10:30:00Z"> <w:r> <w:delText>被删掉的内容</w:delText> </w:r> </w:del>

注意,删除修订里面用的标签是w:delText而不是w:t,这在语义上是一个关键区分。w:delText表示这段文字处于“已删除但未定稿”的状态,渲染出来是带删除线的。在做接受和拒绝操作时,这个区分直接影响处理逻辑。

用Open XML SDK处理,核心思路就是:找到这些标签,根据接受还是拒绝,决定保留内层内容并去掉外层修订标记,或者反过来把被删除的内容恢复。没有现成的AllAccept方法,需要自己写算法,但这套逻辑一旦跑通,处理速度快得多,而且不依赖Word环境。

3.2 用Open XML SDK实现接受所有插入修订

我先给出一个基于Open XML SDK的接受插入修订的算法,这个算法比较基础但稳定:

第一步,用WordprocessingDocument打开文档。第二步,找到文档正文里所有w:ins元素。第三步,对于每个w:ins元素,把它内部包含的所有子节点移动到w:ins元素所在的位置,然后移除w:ins这个容器标签。效果上就是,新插入的文字从“带标记状态”变成了普通正文。

直接看代码:

using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; public static void AcceptInsertions(string filePath) { using (WordprocessingDocument doc = WordprocessingDocument.Open(filePath, true)) { var body = doc.MainDocumentPart.Document.Body; var insertions = body.Descendants<InsertedRun>().ToList(); foreach (var ins in insertions) { var parent = ins.Parent; if (parent == null) continue; // 将修订内部的所有内容移动到修订标记之前 var children = ins.Elements().ToList(); foreach (var child in children) { parent.InsertBefore(child, ins); } // 删除空的修订标记 ins.Remove(); } doc.Save(); } }

注意,InsertedRun是有可能嵌套在段落属性里的,比如w:pPr里的修订,这个代码只处理run级别的插入,段落级格式修订没有覆盖,但对于绝大多数文档场景已经够了。这是我们需要权衡的地方,后面会展开说边界。

3.3 用Open XML SDK实现拒绝所有删除修订

拒绝删除修订的算法,核心是把w:del里的文字恢复成普通内容。因为删除修订内部用的是w:delText标签,恢复动作就是把w:delText替换回w:t,再把外层的w:del容器去掉。拒绝的意义是“我认为这个删除不合理”,于是把删掉的字恢复到正文中。

实现其实不复杂:

public static void RejectDeletions(string filePath) { using (WordprocessingDocument doc = WordprocessingDocument.Open(filePath, true)) { var body = doc.MainDocumentPart.Document.Body; var deletions = body.Descendants<DeletedRun>().ToList(); foreach (var del in deletions) { var parent = del.Parent; if (parent == null) continue; foreach (var element in del.Elements().ToList()) { var textElements = element.Descendants<DeletedText>().ToList(); foreach (var deletedText in textElements) { var newText = new Text(deletedText.Text); deletedText.Parent?.InsertAfter(newText, deletedText); deletedText.Remove(); } parent.InsertBefore(element, del); } del.Remove(); } doc.Save(); } }

这套路线的本质是:让原本带删除标记的内容“洗白”成普通正文。实际处理过程中,我先遍历DeletedRun,把里面的DeletedText替换成Text,然后把run整体移动到删除标记外面,最后移除DeletedRun外壳。跑完这个逻辑后,文档里那些被删除的文字就恢复了,删除线也消失,因为修订标记已经被剥掉。

3.4 按作者或日期做筛选处理

Open XML里的修订元素自带w:author和w:date属性,这在SDK里有对应的属性对象,比如InsertedRun的Author属性,或者DeletedRun的Author属性。筛选逻辑就比较好写:

var revisions = body.Descendants<InsertedRun>().Cast<OpenXmlElement>() .Concat(body.Descendants<DeletedRun>()) .Where(r => r.GetAttribute("w:author", "http://schemas.openxmlformats.org/wordprocessingml/2006/main")?.Value == "张三") .ToList();

有一个常见问题要注意:直接读GetAttribute时,命名空间写错会导致永远匹配不上。保险的写法是直接类型转换后取强类型属性。比如InsertedRun有Author属性,DeletedRun也有Author属性,直接访问属性即可。

Open XML SDK版本不同,属性名略有差异,建议用NuGet里最新的稳定版,并且打开文档后用OpenXmlValidator验证一下文档结构是否合法,避免保存后Word打不开。

3.5 段落级修订和更复杂的边界情况

上面的算法只处理了run级别的插入和删除。实际文档里还会有w:pPrChange这种段落格式修订,以及w:sectPrChange这类节格式修订。处理这些复杂修订,不能说不管,但多数情况下自动化定稿并不需要把它们当成必须处理的对象。

原因在于,多数业务场景关注的是“文字内容”是否定稿,格式层级的变化影响小。但如果你处理的文档是出版物排版或者法律文件,格式修订可能也是验收项,那就不能忽略。这时候我建议的做法是:先无脑把所有修订标记剥掉,保留修订后的格式,然后单独检查原有的格式差异是否影响最终版面。

我不建议在没有人工复核的情况下,用代码去处理表格单元格内的修订、浮动文本框内的修订等场景。那些结构嵌套深,处理出错后很难肉眼发现,修复成本极高。换个说法就是:用Open XML SDK做主流程,处理完的文档至少拿Word打开眼睛过一遍。

4. 实操现场记录:我踩过的坑和排查思路

4.1 坑一:接受插入修订后,文字变成一堆乱码或直接消失

这个事发生过一次。后来查了半天才定位到原因:我写的遍历逻辑把InsertedRun直接移出来,但当时没有考虑其中的字段级修订,比如w:ins里还嵌着另一层w:del,移动的时候把嵌套的删除标记也当成普通节点搬走了。Word打开时解析这种结构,就会显示异常。

解决办法是:处理前先做一次结构归一化,把所有嵌套修订全部展开成“最内层内容+外层标记”的平铺结构。如果你在项目里发现文档处理后内容错乱,优先怀疑是不是有嵌套修订。

4.2 坑二:拒绝删除修订时,下划线格式消失了

删除修订里的run通常带有删除线格式,这是合理的。但拒绝删除后,被恢复的文字在Word的视觉上应该不再有删除线。如果用Open XML SDK恢复时漏了清理处理,删除线格式会残留在run属性里,最终用户看到一段恢复了内容却还带着删除线的奇怪文本,很容易被当成新的修订。

代码上要主动移除run属性里的删除线设置。光替换delText为Text是不够的,还得处理run里的rPr,把w:strike和w:val="true"之类的属性清掉,或者直接替换整个run属性。这也是Open XML路线比COM麻烦的一个典型例子。

4.3 坑三:处理完文档,Word提示“文件已损坏”或“需要修复”

我早期用Open XML SDK改完文档后,Word经常弹这个提示。绝大多数原因出在元素的前后顺序上。Open XML对文档结构顺序有严格要求,比如段落内容必须按特定顺序排列,表格单元格里只能有块级元素,不能直接把run插到段落外。

解决办法是:每次保存前跑一遍OpenXmlValidator做校验。这个验证器是SDK自带的,使用非常简单:

var validator = new DocumentFormat.OpenXml.Validation.OpenXmlValidator(); var errors = validator.Validate(doc).ToList(); foreach (var error in errors) { Console.WriteLine(error.Description); }

凡是提示有错误的地方,逐个修正后再保存。可以说,我这套流程百分之八十的稳定运行全靠这步兜底。

4.4 坑四:性能对比和文件备份策略

实测下来,一份3000条修订的50页合同,COM方案大约要跑十几分钟,Open XML SDK方案通常三秒内完成整个接受或拒绝流程。差距是数量级的。

另一个经验是:处理文件之前先复制一份原始文件做备份,命名成带“_原始稿”后缀的文件,再在副本上操作。这样即使代码逻辑有疏漏,也不会丢失原始底稿。特别是做批量定时任务时,一旦数据库里出了错,还能从备份文档手工恢复。

4.5 常见问题速查表

故障现象可能原因解决办法
修订拒绝后内容还在处理错了Revision类型,把插入当删除先按Type打印日志核对
循环中抛COMException修订集合在遍历时被修改先收集列表再处理
Word提示文档损坏Open XML元素顺序不正确用OpenXmlValidator校验
恢复文字带删除线未清理rPr中的strike属性删除strike相关的run属性
修改后文件未真正保存COM里文档处于只读状态打开时显式ReadOnly:false
服务器上Word进程残留未显式Quit并释放COM对象使用try/finally释放

5. 工具流程之外的几个忠告

5.1 修改完成后记得关闭“修订模式”

代码把已有修订处理完了,但文档的Track Changes开关可能还开着。如果定稿文件发给别人,别人继续编辑时还会产生新的修订,这和自动化清理的初衷就矛盾了。

需要在处理完成后,把文档的TrackRevisions属性设置成false,或者对应的Open XML设置里移除跟踪标记设置。COM里写法最简单:

doc.TrackRevisions = false;

Open XML的场景则要检查settings.xml文档属性,把w:trackChanges节点置为false。不关掉这个开关,等于白处理。

5.2 文件落库前加一道人工复核环节

自动化归自动化,涉及合同、法务、审计这类文档,我还是建议在自动处理后加一个人工复核环节。最轻量的做法是把处理后的文档转成PDF预览,让人只读检查关键页有没有遗留的修订痕迹,不用重新打开Word反复确认。

我在项目里加了一个小工具,处理完成后自动用Word生成PDF预览,同时把文件名后缀改成“_clean”,攒了一堆预览文件供业务人员批量抽查。实际用下来,这种“自动处理+人工抽查”的组合效率最高,比完全人工快得多,也比完全自动化可靠。

5.3 总会有文档不按套路出牌

无论COM还是Open XML,都会遇到特殊文档:宏密码保护、嵌套表格、被锁定修订、通过域代码动态处理的内容……这些情况靠一套通用程序硬扛,大概率出问题。我现在的习惯是把处理结果做个摘要,比如成功处理了多少条修订、剩余多少条、哪几个对象没处理,输出成日志。日志在手,排查问题时能省一半时间。

说点个人经验:这个需求第一次做的时候,我花了整整两天研究Open XML的修订结构,写出来的第一版代码只覆盖了80%的场景。后来把嵌套修订、格式清理、文档校验逐步补上,才算接近完整。处理Word修订的核心其实就是两件事:理解修订状态,并确保操作后文档结构合法。如果你正在做类似的事情,建好备份、打开校验、多做日志,三样都跟上,这个功能就难不倒你了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询