C#调用OpenXml操作Word文档这个系列写到第20篇了。前面十几篇我们把段落、表格、图片、页眉页脚、修订批注都过了一遍今天专门说一个平时问得比较多、但网上讲得很少的内容嵌入文件类。简单说就是在Word里能双击打开的Excel表格、PDF文档、压缩包这类OLE对象用OpenXml SDK怎么读、怎么写、怎么替换。这个东西的应用场景其实非常多。比如你维护一个合同模板里面要嵌入一份授权书PDF或者你的C#上位机程序要批量生成一份包含Excel附件的Word报告再比如你手头有一堆旧Word文档需要把里面的嵌入对象全部导出到本地归档。这些需求绕不开WordprocessingDocument的Part模型而嵌入文件类恰恰是OpenXml里最容易踩坑的一环。本篇我按“先看懂结构、再上手读写、最后聊坑”的顺序把嵌入文件类的玩法一次讲透。1. 先搞清楚嵌入文件的Word底层结构1.1 OLE对象和嵌入包两种“嵌入”不一样想操作嵌入文件首先得明白Word里“嵌入”到底是什么。Word文档里的嵌入对象在OpenXml规范中分两类OLE对象OleObject和嵌入包EmbeddedPackage。这两者名字像底层完全不同。OLE对象是Windows平台很老牌的一种组件交互方式。你在Word里插入一个Excel工作表、插入一个Visio图表本质上是把源应用程序产生的二进制数据打包成OLE复合文档塞进了.docx里。Word自己对这段二进制数据的内容并没有感知它只负责把它原样保存双击时调起对应的COM组件来打开编辑。这就是OleObject。对应到OpenXml SDK里是EmbeddedObjectPart。嵌入包则要新一些它对应的是Office 2010以后支持的“插入对象-从文件创建-显示为图标”的一种包装方式。它可以嵌入任何文件最常见的场景是往Word里塞PDF。嵌入包本质上是一个原封不动的文件快照Word不解析它打开时调用系统关联程序。对应到SDK里是EmbeddedPackagePart。这两种Part的正常工作都依赖关系IDr:id。关系ID是OpenXml部件模型的精髓Word文档本身是一堆XML文件嵌入文件的二进制数据作为独立Part存在文档正文通过relationship把主体和Part关联起来。所以读嵌入文件本质上就是先在正文XML里找到OleObject节点拿到r:id再用这个r:id去MainDocumentPart里把Part取出来。1.2 关系ID和Part理解OpenXml的部件模型有不少朋友学OpenXml卡住不是卡在API语法而是卡在“Part”和“关系”这层抽象上。我习惯用一个生活化类比一个.docx就是一个压缩包里面有几十个文件夹。word/document.xml是正文word/media/下是图片word/embeddings/下就是嵌入对象。而每个外部资源都和正文建立了“关系”这个关系由r:id标识。嵌入对象的二进制数据放在word/embeddings/oleObject1.bin或者word/embeddings/embeddedPackage1.x正文里通过w:object节点指向它。注意正文里看到的不是文件路径而是r:idrId6这种关系ID。SDK加载文档时会解析这个映射所以我们代码里不要尝试直接去改XML文件地址而是用GetPartById来操作。理解这个模型之后读和写都有清晰的思路读是遍历OleObject节点、取r:id、GetPartById、抽取流写是添加Part、FeedData、创建OleObject节点、设置关系。下面两个章节分别过一遍。2. 读取Word里的嵌入文件三步拿到二进制数据2.1 遍历文档找OleObject节点读取的第一步是在正文XML里定位嵌入对象。我们可以用Descendants方法直接找出所有OleObject元素。OleObject位于DocumentFormat.OpenXml.Office命名空间下注意不是Wordprocessing命名空间很多朋友卡在using引用上。找出来之后每个OleObject都有一个Id属性这个Id保存的就是r:id。在OpenXml SDK 2.x以后OleObject的Id属性是StringValue类型代码里取.Value时注意做空判断。还有个容易忽略的点一个Word文档里可能有图片、有图表、有嵌入对象OleObject只代表嵌入对象不会和图片混淆所以直接用Descendants筛选是安全的。using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Office; using (WordprocessingDocument wordDoc WordprocessingDocument.Open(C:\temp\测试文档.docx, false)) { var mainPart wordDoc.MainDocumentPart; if (mainPart null) return; var oleObjects mainPart.Document.DescendantsOleObject().ToList(); Console.WriteLine($共找到 {oleObjects.Count} 个嵌入对象); }2.2 通过r:id取得Part并导出数据拿到OleObject之后第二步就是用Id去MainDocumentPart.GetPartById把Part取出来。这里有个非常重要的判断取出来的Part到底是EmbeddedObjectPart还是EmbeddedPackagePart这决定了你的二进制数据是什么格式。判断方式有两种。第一种是用is关键字做类型判断第二种是看Part的ContentType。实际项目中建议用is关键字因为更直观。取到Part之后用GetStream拿到只读流复制到文件即可。for (int i 0; i oleObjects.Count; i) { string relId oleObjects[i].Id?.Value; if (string.IsNullOrEmpty(relId)) continue; var part mainPart.GetPartById(relId); using (Stream srcStream part.GetStream(FileMode.Open, FileAccess.Read)) using (Stream dstStream File.Create($C:\temp\导出_{i}.bin)) { srcStream.CopyTo(dstStream); } if (part is EmbeddedObjectPart) Console.WriteLine($第{i}个是OLE对象已导出); else if (part is EmbeddedPackagePart) Console.WriteLine($第{i}个是嵌入包已导出); }2.3 导出后识别文件类型的方法导出的二进制没有扩展名怎么判断它原本是什么这是读取嵌入文件最容易困惑的地方。OLE对象和嵌入包的处理方式不一样。嵌入包好办因为嵌入包内部就是原始文件的完整拷贝所以直接读文件头就能识别。比如PDF文件头是“%PDF”Zip压缩包文件头是“PK”这个用字节流前几个字节做魔数判断即可。我一般会写一个简单的识别函数读文件流前8个字节和常见魔数比对识别率很高。OLE对象就麻烦一些。它的二进制是OLE复合文档扩展名可能是.doc、.xls、.ppt也可能是.xlsx、.docx甚至可能是其他应用程序生成的OLE流。识别这类文件有两种思路第一查看OleObject节点的ProgId属性比如“Excel.Sheet.12”代表Excel 2007以上版本“Word.Document.12”代表Word文档这个ProgId是Word写入时保存的比较可靠第二用OLE复合文档头D0 CF 11 E0 A1 B1 1A E1判断它至少是OLE格式然后根据实际业务场景去试扩展名。// 判断OLE复合文档魔数 byte[] header new byte[8]; using (var fs File.OpenRead(C:\temp\导出_0.bin)) { fs.Read(header, 0, 8); } if (header[0] 0xD0 header[1] 0xCF header[2] 0x11) { Console.WriteLine(这是一个OLE复合文档); }3. 向Word写入嵌入文件构造对象元素并建立关系3.1 准备二进制数据和图标往Word里写嵌入对象比读取要复杂不少。读取只需要“找到节点→取Part→抽数据”写入则要解决三件事二进制数据放哪里、正文怎么引用它、文档里显示什么图标。先说图标。Word里嵌入对象不直接显示文件内容而是显示一个图标双击图标才打开。这个图标是一张图片存放在word/media目录下正文里通过另一个r:id引用。所以一个标准的嵌入对象在正文XML里至少有两个r:id一个通过Imagedata指向图标图片一个通过OleObject指向嵌入Part。有些教程为了省事会省略图标只写OleObject。这样Word打开文档时会显示一个默认图标但兼容性有一定风险。我建议按标准做法来实现先准备一个图标图片文件比如64x64的PNG也可以从现有文档里提取一个。3.2 添加EmbeddedObjectPart并建立关系第一步是把二进制数据作为Part添加到MainDocumentPart里。这里有两个API容易混淆AddEmbeddedObjectPart和AddEmbeddedPackagePart。嵌入的是OLE对象比如Excel工作表用AddEmbeddedObjectPart嵌入的是普通文件比如PDF用AddEmbeddedPackagePart。AddEmbeddedObjectPart需要传一个ContentType参数通常是“application/vnd.openxmlformats-officedocument.oleObject”。AddEmbeddedPackagePart的ContentType则是“application/vnd.openxmlformats-officedocument.embeddedPackage”。添加完Part之后用GetIdOfPart拿到关系ID这个关系ID就是之后构造OleObject时要用到的r:id。using (WordprocessingDocument wordDoc WordprocessingDocument.Open(C:\temp\目标文档.docx, true)) { var mainPart wordDoc.MainDocumentPart; var embedPart mainPart.AddEmbeddedObjectPart(application/vnd.openxmlformats-officedocument.oleObject); using (var stream File.OpenRead(C:\temp\嵌入数据.bin)) { embedPart.FeedData(stream); } string embedRelId mainPart.GetIdOfPart(embedPart); // 添加图标图片Part var imagePart mainPart.AddImagePart(image/png); using (var imgStream File.OpenRead(C:\temp\icon.png)) { imagePart.FeedData(imgStream); } string imageRelId mainPart.GetIdOfPart(imagePart); }3.3 构造w:object节点完整示例数据Part加好了关系ID也拿到了接下来就是最重要的步骤构造完整的OleObject节点插入到正文的某个段落里。这个节点结构比较复杂我建议整段代码一次性展示然后逐行拆解。using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Vml; using DocumentFormat.OpenXml.Office; // 构造Object节点 var obj new DocumentFormat.OpenXml.Wordprocessing.Object( new DocumentFormat.OpenXml.Vml.Shape( new DocumentFormat.OpenXml.Vml.Imagedata() { RelationshipId imageRelId, Title 嵌入对象图标 } ) { Id _x0000_i1025, Style width:60pt;height:60pt, Type #_x0000_t75 }, new OleObject() { Type Embed, ProgId Excel.Sheet.12, ShapeId _x0000_i1025, DrawAspect Content, ObjectId _123456789, Id embedRelId } ) { Dxa 240L, Dya 240L }; // 找到要插入的段落 Paragraph targetPara mainPart.Document.DescendantsParagraph().First(); targetPara.AppendChild(obj); mainPart.Document.Save();这段代码里最值得解释的是嵌套结构。从外到内依次是w:object表示一个Word嵌入对象→ v:shape定义图标显示方式→ v:imagedata引用图标图片w:object的第二个子元素是o:OLEObject描述OLE对象的属性其中的Id属性引用的是嵌入Part的关系ID。整个结构就是“正文段落里放一个object节点object节点里描述了图标和OLE信息”。3.4 参数详解ProgId、ShapeId、DrawAspect、ObjectID这节单独写因为这几个参数直接影响文件能否正常打开也是写入时最容易出错的地方。ProgId是程序的编程标识符它决定了双击嵌入对象时系统调用哪个程序来打开。Excel对应“Excel.Sheet.12”Word对应“Word.Document.12”PDF嵌入包则是“Acrobat.Document.DC”如果遇到版本差异用“Excel.Sheet.8”这种老版ProgId也可以。ProgId不对会出现一种很典型的现象对象在文档里显示正常但双击提示“无法创建对象”。ShapeId必须和v:shape节点的Id保持一致。OpenXml规范里要求OLEObject的ShapeId引用同一个图形的ID否则在部分Office版本里图标无法交互。DrawAspect一般固定为“Content”表示显示对象内容。ObjectId是一个字符串理论上不参与实际功能但建议保持唯一可以用时间戳生成避免重复。Id属性则是嵌入Part的关系ID绝对不能弄错它会和GetPartById对应绑定。4. 替换与删除嵌入文件批处理时的扩展操作4.1 替换数据不换图标实际业务里有一个高频需求文档里已经有一个嵌入对象我只想换里面的数据图标、大小、位置都不变。这时不需要删除重建只需要定位到现有的Part用新的二进制流覆盖旧数据。操作方法很直接拿到OleObject的IdGetPartById取出Part然后重新FeedData。但注意一个坑同一个嵌入Part可能在文档中被多处引用所以替换的是Part对应的二进制流所有引用它的位置都会一起变。如果只想改其中一处需要先clone出新的Part再改关系这个复杂度会高不少大多数场景下应该用不到。using (WordprocessingDocument wordDoc WordprocessingDocument.Open(C:\temp\待替换.docx, true)) { var mainPart wordDoc.MainDocumentPart; var oleObj mainPart.Document.DescendantsOleObject().First(); string relId oleObj.Id.Value; var part mainPart.GetPartById(relId) as EmbeddedObjectPart; if (part ! null) { using (var stream File.OpenRead(C:\temp\新数据.bin)) { part.FeedData(stream); } mainPart.Document.Save(); } }4.2 删除嵌入对象及清理孤立Part删除嵌入对象比替换更麻烦。你只删除正文里的OleObject节点是不够的因为嵌入Part还残留在文档里文件体积并不会减小。更规范的做法是三步走先删正文节点再删Part最后删关系。删除Part用DeletePart方法这个API在MainDocumentPart上调用参数传Part实例。删除后必须调用Save保存。另外我踩过一个坑如果删除的是当前文档中最后一个嵌入对象但漏删了word/embeddings目录下的bin文件文档打开时Word会认为有孤立Part虽然一般不报错但文件会被Office的“文档检查器”标记。var toRemove mainPart.Document.DescendantsOleObject().ToList(); foreach (var oleObj in toRemove) { string relId oleObj.Id?.Value; if (string.IsNullOrEmpty(relId)) continue; var part mainPart.GetPartById(relId); // 先删除关系 mainPart.DeletePart(part); // 再删除XML节点 var parentObj oleObj.Parent; if (parentObj is DocumentFormat.OpenXml.Wordprocessing.Object objNode) objNode.Remove(); else oleObj.Remove(); } mainPart.Document.Save();注意删除顺序建议先DeletePart再Remove节点。反过来容易出现一种情况节点删了但Part还挂在文档里之后再按Descendants找一个不存在的节点就麻烦了。而且DeletePart会同步删除对应的relationship这样文档在压缩包层面就干净了。4.3 批量自动化处理思路批量场景下有三条经验值得分享。第一永远先备份原文件再批量操作嵌入对象文件一旦写错Word可能直接拒绝打开文档这个比段落错乱严重得多。第二批量处理建议用“读-写-校验”三步策略先遍历所有启用OpenXml的文档记录嵌入对象数量做统一处理处理后再用OpenXml重新打开校验DocumentPart是否正常而不是依赖肉眼检查。第三如果文档数量很大比如几百个Word文件做好异常隔离单个文件失败不能中断整个任务用try-catch记录文件名和异常信息最后统一查看。另外处理嵌入对象时建议使用临时副本。因为OpenXml.SDK在打开写权限的文档时如果文件被其他程序占用会抛IOException。批量处理时用File.Copy做一份临时文件处理完再替换原文件能避免很多无谓的“文件被占用”错误。5. 常见问题与排查技巧实录5.1 导出的文件打不开这是读取嵌入文件时最典型的坑。导出的二进制扩展名写了.xlsx或者.doc双击却提示文件损坏。发生这种情况要先确认你用的是哪个Part。如果是EmbeddedPackagePart导出文件一般没问题因为它是原样拷贝如果是EmbeddedObjectPart导出的原始OLE二进制需要对应应用程序打开。比如从Excel嵌入对象导出的OLE流你可以尝试用Excel打开如果还打不开那这个二进制很可能是一个复合文档容器而不是标准文件。还有种情况嵌入对象本身嵌套在另一个OLE容器里导出的是容器外壳必须用OLE结构化存储工具解析。遇到这种需求单纯用OpenXml就不够了需要引入其他第三方库解析OLE但那已经是另一条技术路线。5.2 写入后Word弹出修复提示“发现不可读取的内容是否恢复”这句话是所有玩OpenXml的朋友最不想看到的。嵌入对象写入导致这种提示90%的原因是节点结构不完整或者关系ID不对。我排查这类问题有一套固定流程。第一步用OpenXmlWriter或者先把文档上的更改去掉单独检查节点层次是否正确。第二步确认OleObject的Id确实指向EmbeddedObjectPart不是指向图片Part或者别的什么Part。第三步检查图标Imagedata的RelationshipId指向的图片Part是否存在。第四步检查Object节点是否有Dxa和Dya属性这两个属性表示显示大小缺失在部分Office版本中会导致修复提示。如果都不行就打开修复模式生成错误日志日志里会具体指出哪个Part有问题。5.3 双击无反应或提示“无法创建对象”双击嵌入对象没反应大部分时候是ProgId和系统安装的程序不匹配。比如你的文档是用WPS创建的嵌入的Excel对象ProgId可能是“ket.Application”但你的系统只装了Office Excel自然无法创建反过来也一样。还有一种是DrawAspect的问题。虽然一般固定为Content但如果你复制的节点里带着“Icon”值就会出现只有在“显示为图标”时才正常的情况。建议始终使用Content让Word自动决定显示方式。5.4 GetPartById抛异常GetPartById抛出KeyNotFoundException原因是r:id对应的关系不存在。这个r:id可能是null可能是空字符串也可能是老文档里用了不同命名空间导致读取失败。遇到这个异常时先检查OleObject节点是否位于嵌套的绘图对象内部比如v:group如果是Descendants依然能拿到节点但这个r:id可能不在MainDocumentPart下而在DrawingPart或者HeaderPart下。解决办法是找到OleObject所属的OpenXmlPart容器而不是一律从MainDocumentPart取。比如用oleObj.AncestorsDocumentFormat.OpenXml.Wordprocessing.Header()判断是否在页眉里如果在就从mainPart.HeaderParts里找PartById。5.5 嵌入文件操作的快速自检清单检查项正确做法常见错误OleObject的Id指向EmbeddedObjectPart/EmbeddedPackagePart的关系ID指向图片Part导致GetPartById类型转换异常ProgId与系统安装的程序匹配随手复制的ProgId双击崩溃ShapeId与v:shape的Id一致随手填的字符串图标无法交互Imagedata的r:id指向一个真实的图片Part指向不存在的图片导致修复提示删除顺序先DeletePart再Remove节点先删节点后续找不到Part引用写入后验证用OpenXml只读模式重新打开不验证直接交付运行时报错写这篇的时候我把这几年做文档自动化踩过的嵌入文件坑基本都翻了一遍。如果说还有什么总结性的体会那就是操作嵌入文件时一定要先接受“Word文档是一个包裹多个文件的关系集合”这个事实而不是把它当成一个文档来操作。读嵌入文件、写嵌入文件、删嵌入文件本质上都是在维护Part和关系。把r:id这个线索抓住了很多看似复杂的问题都能拆解成“找到Part—操作Part—保存Part”三步走。最后再提醒一句任何涉及嵌入对象批量处理的脚本在正式跑之前都拿三个对比样本测试一遍只读导出、写入新对象、替换已有对象。三类操作都验证通过再扩大范围能帮你省掉大量文档修复时间。
