
写C#的人拿到Word处理需求第一反应往往是找Office COM组件。但如果你在服务器环境、Linux容器或者只想顺手生成个不带模板依赖的docxOpenXml SDK才是更稳的路线。这个系列写到第20篇前面把段落、表格、图片、图表挨个过了一遍今天聊的主题是很多人容易绕开的一块嵌入文件类。所谓嵌入文件简单说就是把你磁盘上的Excel、PDF、压缩包甚至另一个Word塞进当前的docx文档里别人打开这个docx双击那个图标就能读取原始文件。反过来程序也可以从一堆docx里批量把嵌入的附件抽出来。这个功能在自动生成报价单、合同归档、资料汇总场景里非常常见。今天的主角是OpenXml SDK里的三个类型EmbeddedObject、OleObject和EmbeddedPackagePart把它们的关系弄明白嵌入和提取都顺手了。1. 先弄懂 Word 里的“嵌入文件”到底存在哪1.1 docx 本身是个 zip 包嵌入文件就是包里的一个部件很多人用OpenXml操作Word容易陷入“操作文档内容”这个惯性忘了docx的物理结构。实际上docx就是一个zip压缩包里面装着document.xml、styles.xml、media文件夹、embeddings文件夹等等。你在Word里嵌入一个文件Word不会把文件二进制直接写进document.xml而是把原文件作为一个独立部件放进docx压缩包再在document.xml里留一个“这里有个对象数据去xx关系里找”的标记。这个设计不是OpenXml独有的它来自OOXML标准里的Open Packaging Conventions简称OPC。理解了OPC再看嵌入文件就很简单我们要做的无非三件事第一把源文件作为独立部件加进包第二建立部件和document.xml之间的关系第三在文档结构里写一个引用该关系的对象节点。读取则是逆向操作。1.2 OLE对象与EmbeddedPackagePart的差别很多人第一次搜索嵌入文件会被OLE、COM、Package这几个词搞晕。简单科普一下。传统意义上的嵌入是OLE对象比如在Word里插入一个Excel工作表数据部分由Excel COM组件负责渲染和编辑docx里通过OleObject节点和ProgId比如Excel.Sheet.12告诉Word“这是一个Excel对象”。这种方式适合需要保持原始应用编辑能力的老式OLE场景但它对运行环境有要求Word打开文档时可能需要本机安装对应程序才能编辑。另有一种更轻的做法是EmbeddedPackagePart。它不关心文件是Excel还是PDF还是zip把整个文件原封不动塞进包用ProgId Package标识。Word把这种对象当成一个普通附件包来处理双击时按系统关联方式打开。对我们程序生成文档来说EmbeddedPackagePart更好用因为不需要考虑OLE服务器的注册情况兼容性也更好。后面代码演示用的就是这条路线。1.3 OpenXml SDK 中对应的类型SDK里和嵌入文件相关的类理清楚就那么几个类名作用所在命名空间EmbeddedObject对应 document.xml 里的 w:object 节点表示文档流中的一个嵌入对象DocumentFormat.OpenXml.WordprocessingOleObject对应对象节点内部的核心描述包含ProgId、ShapeId、关系Id等DocumentFormat.OpenXml.WordprocessingEmbeddedPackagePart包中的一个部件真正保存被嵌入文件二进制数据DocumentFormat.OpenXml.PackagingPreprocessingVml对应的图形外观描述负责图标、尺寸显示DocumentFormat.OpenXml.Vml注意EmbeddedObject是内容层面的节点EmbeddedPackagePart是包层面的部件两者之间靠关系ID串联。写代码的时候先从MainDocumentPart拿到关系管理器把文件加进去得到关系ID再把这个ID填到OleObject的Id属性上这就算接上了。2. 准备工作项目环境和结构观察2.1 创建项目并安装 DocumentFormat.OpenXml我一般用控制台项目做这类工具的壳方便调试也方便做批处理。项目创建好后通过NuGet安装DocumentFormat.OpenXml命令是Install-Package DocumentFormat.OpenXml也可以在Visual Studio的NuGet包管理器里搜索DocumentFormat.OpenXml稳定版本直接安装即可。SDK内部依赖WindowsBase但SDK会自动处理依赖不用额外操心。目标框架看你的情况如果放服务器上跑建议用.NET 6以上版本如果只是本地小工具.NET Framework 4.7.2也能跑。SDK版本选3.0.1以上API稳定性好很多。2.2 用“笨办法”先做一个带嵌入文件的Word文档这句建议我每次讲OpenXml都会重复一遍不要凭空猜XML结构最靠谱的学习方式是先手动做一份目标效果的文档再解剖它。具体操作很简单打开Word新建一个空白文档。在正文位置点“插入”选项卡找到“对象”。选择“由文件创建”选中任意一个测试文件比如test.xlsx或test.pdf勾选“显示为图标”。确定后保存文档命名为embed-demo.docx。这样你就得到了一个包含嵌入文件的真实文档。后面所有代码对照都以这份文档为准。2.3 用解压工具和官方工具看内部结构拿到embed-demo.docx之后别急着写代码先解剖它。最直接的方式把文件扩展名改成zip用压缩软件打开。你会看到里面有一个word文件夹word文件夹里通常还有一个embeddings文件夹里面放着被嵌入文件的实体。同时word/_rels/document.xml.rels文件里会有对应关系记录。看一眼你对“部件、关系”这两个概念会立刻有体感比看十篇理论文章都管用。想更进一步用OpenXml SDK Productivity Tool。这个工具是微软官方提供的可以打开docx文件左侧显示文档结构右侧直接生成对应的C#代码。你把embed-demo.docx拖进去找到EmbeddedObject相关节点工具会把包裹它的创建代码生成出来。我对嵌入文件类API细节记不清的时候也是这么干的。自己拼API容易遇到版本差异照着工具生成的结构抄基本不会跑偏。3. 用 C# 实现“把文件嵌入 Word”3.1 插入EmbeddedPackagePart从磁盘读文件到包内先说核心流程。打开目标docx获取MainDocumentPart然后调用AddNewPart方法添加一个EmbeddedPackagePart用FeedData把文件二进制写进去。看代码using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; string docPath C:\temp\output.docx; string fileToEmbed C:\temp\attachment.xlsx; using (WordprocessingDocument doc WordprocessingDocument.Open(docPath, true)) { MainDocumentPart mainPart doc.MainDocumentPart; if (mainPart null) { mainPart doc.AddMainDocumentPart(); } // 1. 将目标文件作为嵌入式包部件加入文档 EmbeddedPackagePart embeddedPart mainPart.AddNewPartEmbeddedPackagePart( application/vnd.openxmlformats-officedocument.package); using (FileStream fs File.OpenRead(fileToEmbed)) { embeddedPart.FeedData(fs); } // 2. 获取该部件在文档关系中的唯一ID string relId mainPart.GetIdOfPart(embeddedPart); }这里有两个细节值得展开。第一AddNewPart方法的contentType参数。如果需要嵌入的是老式OLE对象类型通常是application/vnd.openxmlformats-officedocument.oleObject如果只是当作普通包嵌入用application/vnd.openxmlformats-officedocument.package。我用后者多因为对源文件格式没有要求。第二FeedData内部其实就是把流复制到底层PackagePart里。有人会在这里先读MemoryStream再传没必要直接用FileStream就行SDK会处理。3.2 构建 EmbeddedObject OleObject 并挂到段落部件加进去了关系ID也拿到了接下来要在document.xml里写一个对象节点告诉Word“这个位置显示嵌入对象”。继续补全代码// 3. 创建内嵌对象节点 EmbeddedObject embeddedObject new EmbeddedObject(); embeddedObject.DxaOriginal 1800; // 原始宽度 embeddedObject.DyaOriginal 900; // 原始高度 OleObject oleObject new OleObject(); oleObject.ProgId Package; oleObject.ShapeId EmbeddedOleObject1; oleObject.Id relId; embeddedObject.Append(oleObject); // 4. 放入 Run 和 Paragraph Run run new Run(); run.Append(embeddedObject); Paragraph paragraph new Paragraph(); paragraph.Append(run); // 5. 追加到文档末尾 Body body mainPart.Document.Body; if (body null) { mainPart.Document.Append(new Body()); body mainPart.Document.Body; } body.Append(paragraph); mainPart.Document.Save();DxaOriginal和DyaOriginal的单位是缇twip1英寸等于1440缇。这里设置了1800乘900大概就是宽1.25英寸、高0.625英寸的显示区域。这个值不是必须精确但会影响对象在页面上的默认占位大小跑批生成文档时最好固定下来不然同一个对象在不同机器上打开显示比例可能五花八门。OleObject里的ProgId填Package这是关键。它告诉Word这是一个包嵌入对象不是特定类型OLE对象。ShapeId相当于这个形状的标识同一个文档里理论上不能重复用有规律的名字就行。Id属性填的就是第3.1步拿到的relId。这一条是连接文档节点和包部件的桥漏掉或者填错嵌入文件就是断的。3.3 设置显示大小、图标标题等参数如果按3.2的代码跑完生成的docx用Word打开能看到一个嵌入对象但外观可能是默认图标标题也可能是Package。想控制图标和标题要靠PreprocessingVml节点也就是VML图形描述。代码可以扩展成这样PreprocessingVml vml new PreprocessingVml(); vml.ShapeId _x0000_i1025; vml.Style width:120pt;height:60pt; OleObject oleObject new OleObject(); oleObject.ProgId Package; oleObject.ShapeId _x0000_i1025; oleObject.Id relId; oleObject.Type OleObjectValues.Embed; oleObject.DrawAspect OleDrawAspectValues.Content; embeddedObject.Append(vml); embeddedObject.Append(oleObject);VML这块说实话细节很碎ShapeId、Style、Type、DrawAspect每个属性都有讲究。如果你只是想要一个能用的嵌入文件不追求外观可以跳过VML只保留OleObject。如果客户要求显示指定图标或者文件名我的建议是先在Word里手动插入一次再用Productivity Tool生成结构调整VML这会比自己对着规范手写高效得多。3.4 嵌入后如何验证生成结果代码跑完别急着交付先验证。验证分两步。第一步用Word打开生成的docx确认能正常打开双击嵌入对象能打开源文件。第二步用7-Zip或SDK Productivity Tool再看一眼内部结构重点检查document.xml里是否有完整对象节点word/_rels/document.xml.rels里是否有正确的关系记录embeddings目录下是否有对应文件。我见过不少同事写嵌入功能代码跑通一次就以为完事了结果换台机器就提示“无法打开此对象因为源文件不可用”。多数原因就是关系ID写死或者部件没加对。按这个流程验一遍能规避掉一大批坑。4. 用 C# 读取并提取 Word 里的嵌入文件4.1 遍历文档中的EmbeddedObject嵌入功能做完对应的提取功能也不能少。最常见的场景是用户给你一批docx你要把里面所有嵌入的附件批量导出。提取的思路和插入是反过来的。先打开文档找到MainDocumentPart然后遍历Document.Body里的所有段落和Run判断每个Run里有没有EmbeddedObject节点。用OpenXmlReader流式遍历更稳但简单场景直接用LINQ方式也行using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; string docPath C:\temp\input.docx; string outputDir C:\temp\extracted; using (WordprocessingDocument doc WordprocessingDocument.Open(docPath, false)) { MainDocumentPart mainPart doc.MainDocumentPart; if (mainPart null) return; // 拿到所有段落再筛出所有Run中的EmbeddedObject var embeddedObjects mainPart.Document.Body .DescendantsEmbeddedObject() .ToList(); Console.WriteLine($找到 {embeddedObjects.Count} 个嵌入文件。); foreach (var embeddedObject in embeddedObjects) { // 从OleObject节点中取关系ID var oleObject embeddedObject.GetFirstChildOleObject(); if (oleObject null) continue; string relId oleObject.Id; if (string.IsNullOrEmpty(relId)) continue; // 根据关系ID从包中取出对应部件 using (Stream partStream mainPart.GetPartById(relId).GetStream()) using (FileStream fileStream File.Create(Path.Combine(outputDir, ${Guid.NewGuid():N}.bin))) { partStream.CopyTo(fileStream); } } }这段代码能找到文档里所有嵌入对象并把每个对象的数据流保存到本地文件。文件名我没有保留原名因为docx的部件Uri通常是类似/word/embeddings/oleObject1.bin这样的内部路径拿到的名字不一定是用户原始文件名。这个问题下面细说。4.2 拿到关系ID并读取EmbeddedPackagePart内容要注意EmbeddedObject节点里的OleObject的Id属性指向的是关系ID不是部件路径。关系ID形如rId6、rId7通过MainDocumentPart.GetPartById(relId)就能拿到对应部件实例。拿到部件实例后调用GetStream方法拿流再CopyTo到目标文件即可。这个流程对EmbeddedPackagePart和EmbeddedOleObjectPart都适用因为不管哪种部件本质上都是一个流的容器。这里有一个实用判断技巧OleObject的ProgId如果是Package那它对应的大概率是EmbeddedPackagePart如果是Excel.Sheet.12这种具体ProgId那它可能是老式OLE对象部件。提取时可以根据ProgId做不同处理比如老式OLE对象建议保留原始OLE复合文档格式不要贸然改扩展名。4.3 提取文件时恢复原始文件名很多人在提取那步卡住文件是提出来了但扩展名对不上或者文件名全是oleObject1.bin这种抽象名字。恢复原始文件名常用的方案有三种。第一种在嵌入时自己维护一个文件名映射表比如把原始文件名写在docx的自定义属性里提取时读出来。这种方式最稳适合批处理系统自己生成的文档。第二种利用VML节点里的o:title属性。Word在显示图标时经常把原始文件名写在title属性里提取时从PreprocessingVml里找title信息可以拼出大致文件名。第三种通过文件头判断扩展名。提取出来的部件数据本身可能是任意格式但你按文件头的魔数magic number判断类型比如PDF文件头是%PDFxlsx是PK开头。就算没有原始文件名至少扩展名不会错。我自己的习惯是第一种和第三种配合。毕竟程序生成的文档完全可以从源头把文件名写进CustomProperty而处理别人给来的文档就只能靠魔数猜扩展名了。4.4 用OpenXmlReader处理大文档的注意点上面的LINQ式遍历代码处理小文档没问题但如果docx很庞大比如几百页、几十个嵌入文件把所有元素一次性加载到内存会很难受。这时建议用OpenXmlReader做流式读取。using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using (WordprocessingDocument doc WordprocessingDocument.Open(docPath, false)) { MainDocumentPart mainPart doc.MainDocumentPart; if (mainPart null) return; using (OpenXmlReader reader OpenXmlReader.Create(mainPart)) { while (reader.Read()) { if (reader.ElementType typeof(EmbeddedObject)) { EmbeddedObject embeddedObject (EmbeddedObject)reader.LoadCurrentElement(); var oleObject embeddedObject.GetFirstChildOleObject(); if (oleObject ! null !string.IsNullOrEmpty(oleObject.Id)) { using (Stream partStream mainPart.GetPartById(oleObject.Id).GetStream()) using (FileStream fileStream File.Create(Path.Combine(outputDir, ${Guid.NewGuid():N}.bin))) { partStream.CopyTo(fileStream); } } } } } }OpenXmlReader的好处是逐个元素加载不会把整个文档树都塞进内存。唯一的注意点是LoadCurrentElement之后读到的对象生命周期只限于当前迭代需要保存数据的话立刻处理别存引用到循环外面。5. 我踩过的坑和排查记录5.1 嵌入后 Word 提示文件损坏这个坑几乎每个写过嵌入功能的人都会遇到。出现这个提示九成原因是document.xml或关系文件结构不对。最典型的错误是只加了EmbeddedPackagePart、写了OleObject但遗漏了关系文件里的关联项或者关系ID写错。另一个典型错误是EmbeddedObject里没有放任何VML图形节点导致渲染器无法确定显示区域。排查思路很简单把生成的docx用SDK Productivity Tool打开如果工具能正常打开通常说明XML结构没有大问题如果工具打不开说明是XML层面的语法错误。对照3.2节的手工样例文档一个个节点比对很快能找到差异。5.2 ProgId 写错导致图标和双击行为不对ProgId就好比给文件贴的标签。填PackageWord就知道这是普通附件包填Excel.Sheet.12Word会尝试按Excel OLE对象处理双击可能唤起Excel COM接口。我遇到过一个案例同事把PDF文件按老式OLE对象嵌入ProgId填了Acrobat.Document结果在没装Adobe的机器上双击直接报错。后来改成ProgIdPackage兼容性问题立刻消失。所以除非有明确的OLE交互需求否则一律用Package。这是最省心的选择。5.3 ContentType 不对导致文件打不开AddNewPart时填的contentType很关键。如果填了application/vnd.openxmlformats-officedocument.oleObject部件会被当成OLE对象处理但你塞进去的可能是一个普通PDF这种不匹配会导致Word打开时无法正确识别内部结构。另外EmbeddedPackagePart和EmbeddedOleObjectPart对应的ContentType不同用的时候要分清楚。我的建议是拿不准就用SDK里给出的常量不要自己去想ContentType字符串。SDK的EmbeddedPackagePart类有对应的默认ContentType查一下文档或者用Productivity Tool生成的代码为准。5.4 嵌入内容太大文档体积暴涨被嵌入文件是整体复制进docx的一个100MB的附件会让整个docx至少变成100MB。这个不是bug是设计如此。如果只是临时传阅可以用链接对象的方式代替嵌入但链接对象在对方电脑上不一定能看到内容。如果一定要嵌入建议在业务层面限制附件大小比如超过20MB就走外部网盘链接。否则生成一批几十个文件的报告磁盘和内存都会吃不消。5.5 更新和删除嵌入文件的思路更新嵌入文件感觉像是直接覆盖原部件数据就行但这里有个隐藏问题docx里可能有多个地方引用同一个嵌入部件也可能有缓存图标文件残留。无脑替换部件流可能导致图标和实际内容不一致。我的做法是先从文档中删除EmbeddedObject节点移除关系再重新插入新的嵌入文件。这样流程统一逻辑简单也不容易留下脏关系。删除逻辑也不复杂定位到EmbeddedObject节点后通过OleObject拿到关系ID先DeletePart再Remove节点var oleObject embeddedObject.GetFirstChildOleObject(); if (oleObject ! null !string.IsNullOrEmpty(oleObject.Id)) { mainPart.DeletePart(oleObject.Id); } embeddedObject.Remove();注意DeletePart的参数可以是关系ID也可以是Part实例。这个操作会把包里的部件实体一并删除而不是只删引用所以能真正控制docx体积。6. 一点个人心得体会做嵌入文件这类功能最忌讳一上来就翻SDK文档背API因为OpenXml的类层级和属性命名有自己的一套逻辑背完不用很快就忘。我的做法是先手动做一份目标效果的文档解剖它再写代码去还原甚至自动化。这个工作流虽然看起来绕远路但能帮你把“Word里看到的效果”和“docx包里的结构”对应起来一旦对应上了后面所有操作都是水到渠成。最后分享一个平时藏着的小技巧如果手头没有Word环境但需要确认一个docx包里的嵌入文件结构用PowerShell直接操作zip也很方便。把docx复制一份改扩展名为zip解压再用文本编辑器打开document.xml和rels文件整个结构一目了然。客户端那边出问题远程排查时这招特别顶用。嵌入文件类在OpenXml里不难但牵扯到部件、关系、VML、ContentType这些概念第一次接触容易被绕进去。希望这篇能把思路理顺你下次在项目里遇到“把附件塞进Word”或者“从Word里拽出附件”的需求时能少折腾几个来回。