简介:这份资源面向 .NET 开发者,尤其是需要在服务端或客户端程序中处理 Word 文档、又不想依赖 Microsoft Office 环境的技术人员。它基于开源项目 C#DocX,核心是 Xceed.Document.NET.dll 与 Xceed.Words.NET.dll 两个类库,可完成 Word 文档的创建、编辑、读取以及转换为 PDF 等操作,覆盖段落、页眉页脚、列表、图片、表格、样式格式等常见元素,也支持模板生成、内容替换、表格尺寸自动调整等进阶用法,适用于自动化报告生成、在线文档编辑器、数据导出、合同与成绩单批量生产等场景。压缩包共 244 个文件,约 4.93MB,内含 83 个 cs 源码文件、86 个 docx 示例文档,以及 dll、csproj、sln、config、png 等配套资源,源码与演示项目注释较完整,便于对照理解 API 调用方式。目前已有 276 人学习下载,适合希望快速上手 Word 编程、少走弯路的中初级开发者参考。
1. 从一份能跑起来的 DocX 源码说起:为什么我劝你别再手写 Word 报表
做过 C# 后台导出的人大概都经历过这个场景:业务方要一份带页眉页脚、表格边框、单元格底纹、目录页码的 Word 报表,你打开 Visual Studio,第一反应是引用Microsoft.Office.Interop.Word,然后服务器上装 Office,跑起来发现进程残留、并发一高就卡死,最后运维找上门。这套路我踩过不止一次,后来彻底换成纯托管库,也就是标题里这套Xceed.Document.NET.dll+Xceed.Words.NET.dll组合的 DocX 方案。
它解决的核心问题很直接:不依赖 Office、不依赖 COM、不依赖服务器装任何东西,纯 .NET 程序集,直接生成和读取.docx。适合谁?适合做后台批量导出、合同模板填充、报表生成、文档批处理这类活的人。标题里说「源码 + demo 亲测有效」,我理解的重点不是那份 demo 本身,而是这套库的 API 到底怎么用、参数怎么设、哪些地方会翻车。下面我按自己实际落地的顺序,把选型、环境、代码、坑和进阶技巧讲一遍,你照着能复现。
2. 选型先立住:DocX 这套库到底解决什么,和 Interop 差在哪
2.1 为什么是纯托管库,而不是 Interop 或 OpenXML SDK
先说结论:如果你的场景是「服务端批量生成 Word」,Interop 基本可以直接排除。Interop 的本质是进程外调用 Office,每个Application实例都是重量级对象,并发下要么排队要么崩,服务器还得装 Office 授权。OpenXML SDK 是另一条路,它直接操作 OOXML 的 XML 结构,能力最全、最底层,但代价是你要自己拼Paragraph、Run、Table这些节点,写一个带样式的表格能写到你怀疑人生。
DocX(也就是Xceed.Words.NET)的定位在两者之间:它是对 OpenXML 的一层封装,把「段落、表格、图片、页眉页脚、样式」这些概念做成了接近自然语言的 API。你写document.AddTable()、table.Rows[0].Cells[0].Paragraphs[0].Append("内容"),可读性比裸 XML 高一个量级。代价是它不覆盖 OOXML 的全部能力,遇到极冷门的特性还是得回退到 OpenXML 或直接改 XML。
我一般的判断标准是这样的:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 服务端批量导出、模板填充 | DocX | 纯托管、API 友好、并发安全 |
| 需要精确控制 OOXML 冷门特性 | OpenXML SDK | 能力最全,但要自己拼节点 |
| 桌面端少量交互式编辑 | Interop | 有 Office 环境时最省事 |
| 只读解析、提取文本 | DocX 或 OpenXML | DocX 读段落表格更快 |
标题里提到的两个 dll 分工也值得说清楚:Xceed.Document.NET.dll是底层文档对象模型,负责段落、表格、图片这些基础结构;Xceed.Words.NET.dll是面向 Word 的高层封装,负责文档级操作、页眉页脚、样式、保存。实际引用时两个都要带上,缺一个会在运行时抛类型加载异常,这个坑后面会细说。
2.2 环境准备与依赖引用的最小步骤
这套库是商业库,有社区版和商业授权之分,具体授权条款以官方为准,我这里只讲技术接入。假设你已经拿到了程序集文件,接入步骤不复杂,但有几个细节容易漏。
第一步,把两个 dll 放到项目的libs目录,不要直接丢进bin,否则清理重建就没了。第二步,在.csproj里用Reference显式引用,并设置Private为true,保证复制到输出目录。第三步,确认目标框架。这套库对 .NET Framework 和 .NET Core/.NET 5+ 都有支持,但不同版本对应的程序集不一样,别拿 Framework 版的 dll 往 .NET 6 项目里塞。
<!-- 在 .csproj 中显式引用,HintPath 指向你的 libs 目录 --> <ItemGroup> <Reference Include="Xceed.Document.NET"> <HintPath>libs\Xceed.Document.NET.dll</HintPath> <!-- Private=true 确保生成时复制到输出目录,避免运行时找不到 --> <Private>true</Private> </Reference> <Reference Include="Xceed.Words.NET"> <HintPath>libs\Xceed.Words.NET.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>这段配置的逻辑是:HintPath告诉编译器去哪找程序集,Private=true保证它跟着你的输出走。很多人只加了引用没设Private,本地调试没问题,一发布到服务器就报Could not load file or assembly,这就是典型的「本地能跑线上翻车」。
参数上要注意的是版本一致性:Xceed.Document.NET.dll和Xceed.Words.NET.dll必须是同一版本批次,混用不同版本会出现方法签名不匹配。判断方法很简单,看两个文件的文件版本号是否一致,不一致就换。
2.3 一个能跑通的最小生成示例
环境通了之后,先跑一个最小闭环,确认库能用,再往上堆业务逻辑。下面这段代码生成一个带标题、表格和页脚的文档,覆盖了最常用的几个 API。
using Xceed.Words.NET; // 高层封装:文档级操作 using Xceed.Document.NET; // 底层模型:表格、段落、图片 // 创建文档,false 表示不加载默认模板,从空白开始 using (var doc = DocX.Create("report.docx")) { // 插入标题段落,第二个参数是样式名,对应 Word 内置样式 doc.InsertParagraph("月度数据报表", false, FontStyles.Heading1); // 创建 3 行 2 列的表格 var table = doc.AddTable(3, 2); table.Design = TableDesign.TableGrid; // 套用内置网格样式,省去手动画边框 table.Alignment = Alignment.center; // 填充表头 table.Rows[0].Cells[0].Paragraphs[0].Append("指标"); table.Rows[0].Cells[1].Paragraphs[0].Append("数值"); // 填充数据行 table.Rows[1].Cells[0].Paragraphs[0].Append("订单量"); table.Rows[1].Cells[1].Paragraphs[0].Append("1280"); table.Rows[2].Cells[0].Paragraphs[0].Append("转化率"); table.Rows[2].Cells[1].Paragraphs[0].Append("3.7%"); doc.InsertTable(table); // 页脚插入页码,PageNumber 是内置字段 doc.AddFooters(); doc.Footers.Odd.InsertParagraph("第 ").AppendPageNumber(PageNumberFormat.normal); doc.Save(); // 保存到磁盘 }逻辑说明:DocX.Create创建文档对象,InsertParagraph的第三个参数用内置样式而不是手动设字号,这样文档在 Word 里打开时样式是「真样式」,能被导航窗格识别。AddTable后必须InsertTable才会真正插入到文档流里,这是新手最容易漏的一步,只AddTable不InsertTable,保存出来是空文档。
参数说明:TableDesign.TableGrid是内置表格样式枚举,直接给表格加网格线,比手动设每个单元格边框省事得多。AppendPageNumber的PageNumberFormat.normal表示阿拉伯数字,需要罗马数字或字母可以换枚举值。页脚分Odd、Even、First三种,分别对应奇数页、偶数页、首页,做奇偶页不同页脚时用得上。
3. 模板填充与批量导出:把业务数据塞进 Word 的正确姿势
3.1 用书签和占位符做模板填充
实际业务里很少从零生成文档,更多是「拿一份模板,把数据填进去」。DocX 支持两种主流做法:书签替换和占位符替换。书签更稳,占位符更灵活,我一般优先用书签。
书签的做法是:在 Word 里给要替换的位置插入书签,命名比如Bookmark_Name,代码里通过document.Bookmarks找到它,替换内容。占位符的做法是模板里写{{Name}},代码里遍历段落做字符串替换。前者定位精确,后者对格式不敏感但容易误伤。
using (var doc = DocX.Load("template.docx")) { // 方式一:书签替换,定位精确,推荐 if (doc.Bookmarks.Contains("Bookmark_Name")) { // 书签替换会保留原位置的格式 doc.Bookmarks["Bookmark_Name"].SetText("张三"); } // 方式二:占位符替换,遍历所有段落 foreach (var p in doc.Paragraphs) { // ReplaceText 支持正则,这里用普通字符串 p.ReplaceText("{{Company}}", "某科技有限公司"); p.ReplaceText("{{Date}}", DateTime.Now.ToString("yyyy-MM-dd")); } // 表格里的占位符要单独遍历,段落遍历覆盖不到单元格 foreach (var table in doc.Tables) { foreach (var row in table.Rows) { foreach (var cell in row.Cells) { foreach (var p in cell.Paragraphs) { p.ReplaceText("{{Amount}}", "12800.00"); } } } } doc.SaveAs("output.docx"); }逻辑说明:书签替换用SetText,它会保留书签所在位置的原有格式,这是它比占位符稳的地方。占位符替换用ReplaceText,注意它只作用于当前段落对象,表格单元格里的段落不在doc.Paragraphs里,必须单独遍历doc.Tables,这是最常见的漏替换原因。
参数说明:ReplaceText的第一个参数是查找串,第二个是替换串,它内部支持正则,如果你的占位符里有正则特殊字符(比如{{里的花括号在某些实现里会被当量词),要么转义要么用普通字符串重载。SaveAs和Save的区别是前者另存为新文件,后者覆盖原文件,模板场景一定用SaveAs,别把模板覆盖了。
3.2 批量导出时的并发与内存控制
批量导出是这套库真正体现价值的地方,因为纯托管,你可以放心用Parallel或Task并发。但并发不是无脑开线程,文档对象不是线程安全的,每个任务必须持有独立的DocX实例。
// 每个任务独立创建文档实例,不要共享 var results = new ConcurrentBag<string>(); Parallel.ForEach(dataList, new ParallelOptions { MaxDegreeOfParallelism = 4 }, item => { // 在任务内部创建,保证实例隔离 using (var doc = DocX.Create($"out_{item.Id}.docx")) { doc.InsertParagraph(item.Title, false, FontStyles.Heading2); var t = doc.AddTable(2, 2); t.Rows[0].Cells[0].Paragraphs[0].Append("编号"); t.Rows[0].Cells[1].Paragraphs[0].Append(item.Id.ToString()); t.Rows[1].Cells[0].Paragraphs[0].Append("金额"); t.Rows[1].Cells[1].Paragraphs[0].Append(item.Amount.ToString("F2")); doc.InsertTable(t); doc.Save(); results.Add($"out_{item.Id}.docx"); } });逻辑说明:MaxDegreeOfParallelism控制并发度,不是越大越好。文档生成是 IO 和 CPU 混合型,并发太高反而因为磁盘争用变慢,我一般设成 CPU 核数或 4,取较小值。每个任务内部using保证文档对象及时释放,避免内存堆积。
参数说明:MaxDegreeOfParallelism建议从 4 开始压测,观察 CPU 和磁盘 IO,找到拐点。如果导出的是超大文档(几十 MB),并发度要降到 2 甚至 1,否则内存峰值会很难看。ConcurrentBag用来收集结果,普通List在并发下会抛异常。
3.3 图片、页眉页脚和目录的插入要点
报表里图片和目录是高频需求,这两个地方各有各的坑。图片插入用doc.AddImage,然后InsertParagraph时把图片作为Picture传进去。目录用InsertTableOfContents,但它依赖 Word 打开时更新域,代码里生成的是域代码,不是最终页码。
// 插入图片,先创建 Image 对象再放进段落 var image = doc.AddImage("logo.png"); var pic = image.CreatePicture(60, 160); // 高 60px,宽 160px var p = doc.InsertParagraph(); p.AppendPicture(pic); // 插入目录,依赖 Word 打开时按 F9 更新域 doc.InsertTableOfContents("目录", TableOfContentsSwitches.O | TableOfContentsSwitches.H | TableOfContentsSwitches.Z); // 页眉插入公司名,注意页眉段落默认居中 doc.AddHeaders(); doc.Headers.Odd.InsertParagraph("某公司内部文档");逻辑说明:CreatePicture的两个参数是高度和宽度,单位是像素,不是厘米,很多人按厘米填结果图片巨大或极小。目录的TableOfContentsSwitches是开关组合,O表示包含大纲级别,H表示超链接,Z表示隐藏页码时制表符,具体组合按需拼。
参数说明:图片尺寸建议按目标文档的页面宽度换算,A4 正文宽度约 600px 左右,图片宽度别超过这个值。目录生成后,用代码打开文档是看不到页码的,必须在 Word 里更新域,这是 OOXML 的机制,不是库的问题,别在这上面浪费时间排查。
4. 避坑与排查:这套库最容易翻车的五个地方
4.1 运行时找不到程序集或类型加载失败
现象:本地调试正常,发布到服务器或换台机器就报Could not load file or assembly 'Xceed.Document.NET'或TypeLoadException。
原因:一是Private没设成true,dll 没复制到输出目录;二是两个 dll 版本不一致,高层封装引用了底层某个不存在的方法;三是目标框架不匹配,Framework 版 dll 被 .NET Core 项目引用。
解决:先检查输出目录里两个 dll 是否都在,再看文件版本号是否一致,最后确认 dll 的目标框架和项目一致。三步走完基本能定位。
4.2 表格只 AddTable 没 InsertTable,保存出来是空的
现象:代码里明明创建了表格、填了数据,保存后打开文档什么都没有。
原因:AddTable只是创建了表格对象,它还没进入文档的块级元素流,必须调用InsertTable或InsertTableAfter才会真正插入。
解决:养成习惯,AddTable之后立刻跟InsertTable。如果要在指定段落后插入,用InsertTableAfter并传入段落对象。
4.3 占位符替换漏掉表格和页眉页脚
现象:正文里的{{Name}}替换成功了,表格里和页眉里的没换。
原因:doc.Paragraphs只包含正文段落,表格单元格、页眉、页脚里的段落是独立集合,不在其中。
解决:替换逻辑要覆盖doc.Tables、doc.Headers、doc.Footers三个集合,每个集合内部再遍历段落。写一个统一的替换方法,把这几处都过一遍。
4.4 并发导出时内存暴涨或文件损坏
现象:单线程导出正常,一开并发内存飙升,偶尔生成的文件打不开。
原因:共享了DocX实例,或者并发度过高导致磁盘写入争用,文件写了一半被另一个任务干扰。
解决:每个任务独立创建实例,用using及时释放;并发度从 4 起步压测;输出文件名带唯一标识,避免多任务写同一文件。
4.5 目录页码不更新、图片尺寸失控
现象:生成的目录没有页码,或者图片大得离谱。
原因:目录是域代码,需要 Word 打开时更新;图片尺寸单位是像素,按厘米填就错了。
解决:目录问题在文档打开后按 F9 更新,或在代码里提示用户;图片尺寸按页面宽度换算,A4 正文宽度约 600px,别超。
5. 进阶技巧:把 DocX 用成一套可维护的文档生成管线
走到这一步,单次生成已经没问题了,真正拉开差距的是怎么把它做成可维护的管线。我自己的习惯是三层结构:模板层、数据层、渲染层。模板层放.docx模板文件,数据层是业务对象,渲染层是一组封装好的方法,把「填书签、填表格、插图片」这些动作标准化。
一个具体技巧是给表格做动态行。业务数据行数不固定,模板里先放一行样例,代码里根据数据量克隆行。DocX 没有直接的「克隆行」API,常见做法是按数据量重建表格,或者用InsertRow在指定位置插行再填值。
// 按数据量动态建表,比克隆行更可控 int dataCount = items.Count; var table = doc.AddTable(dataCount + 1, 3); // +1 是表头 table.Design = TableDesign.TableGrid; // 表头 table.Rows[0].Cells[0].Paragraphs[0].Append("名称"); table.Rows[0].Cells[1].Paragraphs[0].Append("数量"); table.Rows[0].Cells[2].Paragraphs[0].Append("金额"); // 数据行,索引从 1 开始 for (int i = 0; i < dataCount; i++) { table.Rows[i + 1].Cells[0].Paragraphs[0].Append(items[i].Name); table.Rows[i + 1].Cells[1].Paragraphs[0].Append(items[i].Qty.ToString()); table.Rows[i + 1].Cells[2].Paragraphs[0].Append(items[i].Amount.ToString("F2")); } doc.InsertTable(table);逻辑说明:动态建表的关键是行数在创建时就确定,AddTable(rows, cols)的第一个参数是总行数,包含表头。填充时数据行索引从 1 开始,因为 0 是表头。这样比先建一行再克隆更直观,也不容易出错。
参数说明:TableDesign.TableGrid给表格加网格线,如果要做斑马纹,需要手动设奇偶行的底纹颜色,row.Cells[j].FillColor = Color.LightGray,注意FillColor是单元格级属性,要逐格设。
另一个技巧是样式复用。与其在每个文档里重复设字体字号,不如在模板里定义好样式,代码里只用样式名。这样业务方改样式不用改代码,改模板就行。我一般会在模板里预置Heading1、Heading2、TableText这几个自定义样式,代码里InsertParagraph(text, false, "TableText")直接引用。
验证方法上,我习惯写一个小的校验函数,生成文档后重新用DocX.Load读一遍,检查关键书签和表格行数是否符合预期。这相当于给自己留了个后悔药,批量导出时能提前发现模板被改坏的情况。
// 生成后回读校验,确认关键内容存在 using (var check = DocX.Load("output.docx")) { bool hasBookmark = check.Bookmarks.Contains("Bookmark_Name"); int tableRows = check.Tables.Count > 0 ? check.Tables[0].RowCount : 0; if (!hasBookmark || tableRows < 2) { throw new InvalidOperationException("文档校验失败,模板可能被改动"); } }逻辑说明:回读校验是低成本高收益的习惯,尤其在模板由业务方维护的场景,能挡住大部分「模板被改坏」的问题。RowCount是表格行数属性,直接读即可。
参数说明:校验点按业务重要性选,书签、表格行数、关键段落文本都是好选择。抛异常还是记日志看场景,批量任务里记日志更合适,别让一个文档失败拖垮整批。
最后说个我自己的教训:早期我图省事,把模板路径写死在代码里,结果换环境就找不到文件。后来统一改成配置项,模板目录、输出目录、并发度全部外置,代码只负责渲染逻辑。这套习惯坚持下来,同一套渲染代码在几个不同项目里复用,改的只是模板和配置。希望帮到你。
本文还有配套的精品资源,点击获取