先交代一下背景:我之前做过好几个需要程序生成 Word 文档的项目,有自动生成业务周报的,有批量把数据库记录组装成合同附件的,也有临时帮同事写的小工具。坦白讲,最开始我在“到底用哪个库”这件事上绕了不少路,网上关于 C# 创建 Word 文档的帖子很多,但一半让你用 COM 交互操作,另一半推 OpenXML,还有一些直接引到商业组件上,对只想快速把文档做出来的人来说,信息反而越看越乱。
这篇教程我按自己多次实战下来比较顺手的路线来写:先带你用最省事的 Interop 方式跑通第一个 Word 文档,再做标题、表格、图片这些高频排版,接着讲模板批量替换的做法,最后补上无 Office 环境的 OpenXML 备用方案。无论你是刚入门 C# 的新手,还是要给项目接文档生成功能的朋友,照着步骤都能快速落地。
1. 动手前先选型:Interop、OpenXML SDK、第三方封装库怎么权衡
很多新手上来就想写代码,但 C# 创建 Word 文档最麻烦的往往不是代码本身,而是方案选错。等写到一半发现当前环境跑不起来,或者要部署到服务器上才发现不能装 Office,再回头换库就很痛苦。所以我建议第一步不写字,先搞定选型。
1.1 三套主流方案的原理与区别
先看一张对比表,再逐项解释。
| 维度 | Interop 方式 | OpenXML SDK | 第三方库(Spire.Doc / Aspose.Words) |
|---|---|---|---|
| 实现原理 | 通过 COM 调用本机 Word 进程 | 直接读写 docx 内部的 XML 结构 | 封装底层格式解析,暴露高层 API |
| 是否需要 Office | 必须安装 Word | 不需要 | 不需要 |
| 跨平台 | 仅 Windows,服务端受限 | 支持 Linux / macOS / Windows | 支持多平台 |
| 学习成本 | 低,API 接近用户操作 | 中高,需要理解 XML 层级模型 | 低,API 很友好 |
| 功能覆盖 | 几乎覆盖 Word 所有功能 | 很全,但编码繁琐 | 全,封装度高 |
| 性能 | 慢,每份文档都启动 Word 进程 | 快,无进程开销 | 快 |
| 授权成本 | 免费,但受 COM 使用规则限制 | 免费开源 | 商业授权,部分库有免费版 |
先说 Interop。它本质上是进程外 COM 通信,你写一行new Word.Application(),背后就是 Windows 拉起一个 Word 进程,你的指令通过 COM 管道传给这个进程,再由它操作文档。优势是 API 和用户在 Word 界面里的操作几乎一一对应,“加粗”“居中”“保存成 PDF”都是接近直译级别的写法,入门特别快。缺点也肉眼可见:必须装 Office、进程管理不当会内存泄漏、服务端并发场景几乎不可用。
OpenXML SDK 则是完全另一条路。docx 文件从本质上看就是一个 zip 压缩包,里面装着document.xml、styles.xml这些 XML 文件。OpenXML SDK 就是不启动 Word,直接对这些 XML 结构进行增删改。它不依赖 Office、跨平台、性能稳定,是服务端生成文档的稳妥选择。缺点同样明显:编码量大,连一个段落都要把Paragraph、Run、Text一层层拼起来,改个字体还得手动加RunProperties,新手第一次接触确实容易懵。
第三方库是封装好的“成品”,比如 Spire.Doc 免费版能应对简单文档,Aspose.Words 功能更强但要授权费用。它们的 API 通常很友好,开发效率高,适合复杂格式要求且预算到位的项目。
1.2 什么样的项目选什么方案
我的建议很直白:自己电脑上临时生成文档、做桌面端小工具,选 Interop,这是今天最快能见到成果的路径。如果是要放到 Web 服务或后台任务里,不建议 Interop,直接用 OpenXML SDK,宁可前期多写点代码,后面部署和排障都会省心很多。至于第三方库,公司有预算并且文档格式要求特别复杂再考虑,不要把它当成默认选项。心里有了这个判断,接下来的实战才不会跑偏。
2. 用 Interop 快速跑通第一个 Word 文档
这一节是全文的“最小可用版本”。环境是 Windows + 安装了 Office,开发工具用 Visual Studio 2022 或任意支持 C# 的编辑器。
2.1 环境准备与引用配置
新建一个控制台应用。目标框架方面,.NET Framework 4.7.2兼容性最好,选.NET 6/8也能跑,但有些 PIA 行为会有差异,我建议教程阶段直接用 .NET Framework。
添加引用有两种方式:
- 方式一:右键项目 -> 添加 -> COM 引用,在列表里找 “Microsoft Word 16.0 Object Library”。
- 方式二:在 NuGet 搜索并安装
Microsoft.Office.Interop.Word包,这个更推荐,因为 MSBuild 在纯命令行环境也能找到引用,而且会主动带上 PIA 依赖。
安装完成后,在代码文件顶部加一行别名引用:
using Word = Microsoft.Office.Interop.Word;这样写的好处是代码里看到Word.Application一眼就明白是 Word 的 Application,不会和系统里的System.Application混淆,后续要混写 ASP.NET 命名空间也不打架。
2.2 创建文档、写入内容、保存的完整代码
下面是一段能直接跑通的完整程序,生成一份带标题和正文的 docx 文件。
using System; using System.Runtime.InteropServices; using Word = Microsoft.Office.Interop.Word; class Program { static void Main(string[] args) { Word.Application wordApp = null; Word.Document doc = null; try { // 创建 Word 实例,但不显示界面 wordApp = new Word.Application { Visible = false }; // 新建空白文档 doc = wordApp.Documents.Add(); // 在文档末尾添加段落并写入文字 Word.Paragraph titlePara = doc.Content.Paragraphs.Add(); titlePara.Range.Text = "第一份C#生成的Word文档"; titlePara.Format.Alignment = Word.WdParagraphAlignment.wdAlignParagraphCenter; // 设置标题字号和加粗 titlePara.Range.Font.Size = 18; titlePara.Range.Font.Bold = 1; // 在文末追加一段正文 doc.Content.InsertAfter("这是正文内容,由 Interop 方式写入。"); string savePath = @"D:\Temp\HelloFromCSharp.docx"; doc.SaveAs2(savePath); Console.WriteLine("保存成功: " + savePath); } catch (Exception ex) { Console.WriteLine("出现异常: " + ex.Message); } finally { // 关闭文档并退出 Word,释放 COM 对象 if (doc != null) { doc.Close(Word.WdSaveOptions.wdDoNotSaveChanges); Marshal.ReleaseComObject(doc); } if (wordApp != null) { wordApp.Quit(); Marshal.ReleaseComObject(wordApp); } } } }这段代码有几个关键点需要理解:
new Word.Application { Visible = false }启动 Word 进程但不显示界面,生成文档时屏幕不会闪动。Documents.Add()基于 Normal 模板新建空白文档,等价于你打开 Word 后新建了一篇空白文章。doc.Content是主文档范围,Paragraphs.Add()在末尾加一个段落,给Range.Text赋值就是在那个位置写字。wdAlignParagraphCenter是居中对齐,对应的还有左对齐wdAlignParagraphLeft、右对齐wdAlignParagraphRight、两端对齐wdAlignParagraphJustify。SaveAs2是较新版本推荐使用的保存方法。如果业务需要直接转 PDF,把保存路径后缀改成.pdf并传入FileFormat: Word.WdSaveFormat.wdFormatPDF即可,一行完成 Word 转 PDF,这个技巧我后面还会提到。
2.3 COM 对象释放——没人提醒但迟早踩的问题
如果你只复制上面的 try 部分,把 finally 丢掉,程序跑完以后打开任务管理器,大概率能看到 WinWord.exe 进程残留。一次两次还好,循环生成几十份文档后内存占用会非常吓人。
为什么会这样?因为 Interop 创建的是 COM 对象,它有自己的引用计数。Marshal.ReleaseComObject是在手动减引用计数,wordApp.Quit()是通知 Word 进程退出。两个动作都做了,进程才会干净回收。
我自己的经验是:不需要对期间创建的所有小对象挨个释放。Paragraph、Range、Table 这些对象都寄生于 Document,Document 关闭后它们会随容器一起销毁,逐个释放反而容易触发InvalidComObjectException。可靠做法就是 finally 里关闭文档、退出 Application,再释放 doc 和 wordApp 这两个根对象就够了。
3. 内容排版进阶:标题、表格、图片一网打尽
实际项目需求很少只是插入一行文字,通常还要带标题层级、数据表格和配图。这一节讲高频操作的正确写法。
3.1 标题层级和段落样式
用 Word 内置样式设置标题是效率最高的做法,它会自动维护大纲级别,后续要做目录也能被识别。
Word.Paragraph p = doc.Content.Paragraphs.Add(); p.Range.Text = "一、2024年度销售汇总"; p.Style = Word.WdBuiltinStyle.wdStyleHeading1; p.Format.Alignment = Word.WdParagraphAlignment.wdAlignParagraphCenter;WdBuiltinStyle里有很多内置样式,wdStyleHeading1是一级标题,wdStyleHeading2、wdStyleHeading3依次是二三级标题,wdStyleNormal是正文。设置标题之后,不用手动调整字体大小,样式会自带一套默认排版。如果还想微调,可以像第 2 节那样继续覆盖Range.Font属性,比如给标题文字加个颜色:p.Range.Font.Color = Word.WdColor.wdColorRed;。
这里要注意一个细节:设置Style之后再去改Font.Size时,某些版本的 Word 会显示“这些属性基于样式,手动修改可能会影响其他文本”。这不是报错,只是提示,直接忽略即可。
3.2 表格创建与“列宽就是不听指挥”的解决方法
表格是 Word 生成里最容易出幺蛾子的部分,尤其是列宽。很多人设置完Column.Width发现根本不生效,单元格内容一多,表格自己就撑开了。下面这段是我验证过能稳定控制的写法。
Word.Range endRange = doc.Content; endRange.Collapse(Word.WdCollapseDirection.wdCollapseEnd); Word.Table tbl = doc.Tables.Add(endRange, 3, 3); tbl.Borders.Enable = 1; tbl.Range.Font.Size = 10; tbl.AllowAutoFit = false; for (int i = 1; i <= tbl.Columns.Count; i++) { tbl.Columns[i].SetWidth(80f, Word.WdRulerStyle.wdAdjustNone); } tbl.AutoFitBehavior(Word.WdAutoFitBehavior.wdAutoFitFixed); tbl.Cell(1, 1).Range.Text = "产品"; tbl.Cell(1, 2).Range.Text = "销售额"; tbl.Cell(1, 3).Range.Text = "备注";关键在于两个动作:AllowAutoFit = false关掉表格自动适应内容,然后逐列执行SetWidth(80f, Word.WdRulerStyle.wdAdjustNone)。SetWidth的第二个参数是调整方式,wdAdjustNone表示直接修改当前列宽,不去影响其他列和表格整体宽度。
为什么很多人会遇到“手动拖拽列宽都拖不动”?因为 Word 的表格默认启用了自动调整,内容一旦超过设定宽度就会把列撑开。先关 AutoFit,再把布局固定成wdAutoFitFixed,列宽才能老实听话。这个经验在处理其他语言的文档生成库时也通用,底层逻辑都是同一套。
3.3 插入图片与尺寸控制
插入图片用InlineShapes.AddPicture,返回值是InlineShape对象,可以通过它控制尺寸。
Word.InlineShape shape = doc.InlineShapes.AddPicture(@"D:\pic.png"); shape.Width = 300f; shape.Height = 200f;AddPicture默认在光标所在位置插入。如果前面刚执行过Collapse(wdCollapseEnd),图片就会落在文档末尾。图片格式支持 PNG、JPG、GIF、BMP,SVG 直接插不了。插完图片之后最好额外加一个空段落,否则图片位于文档最末尾时,可能会遇到段落标记缺失导致的目录、页脚错乱问题。
4. 批量内容生成:占位符替换和书签的正确姿势
很多业务场景不是要“从零建全文”,而是有一个模板,需要把里面的变量替换成数据库里的真实值。比如工资单、合同关键条款、批量通知函。这一节的方法可以帮你应付这类需求。
4.1 用 Find.Replace 批量替换占位符
我在模板里写“尊敬的{姓名}女士/先生”,程序一次性把{姓名}替换成“张三”,这样一条数据库记录就能出一份独立文档。
Word.Find find = doc.Content.Find; find.ClearFormatting(); find.Replacement.ClearFormatting(); find.Text = "{姓名}"; find.Replacement.Text = "张三"; find.Execute(Replace: Word.WdReplace.wdReplaceAll);Find.Execute的参数非常多,包括是否区分大小写、是否使用通配符等。日常使用命名参数最稳妥,Replace: Word.WdReplace.wdReplaceAll指定全部替换,避免漏掉文档里其他位置出现的相同占位符。
占位符替换有几个实际坑:
- 占位符不能跨多个文本段。如果模板里
{姓名}这部分文字被批注、超链接或样式切换拆开了,Find可能找不到。做模板时保持占位符文本连续、别夹空格、别做特殊格式。 Find.Text不要超过 255 个字符,超长文本建议拆成多个短占位符。- 替换完成后建议看一眼文档页数变化,防止替换结果把排版撑乱。
如果你只是想简单检查某个占位符是否存在,可以先执行一遍不传 Replacement 的Execute,看返回的 bool 值即可。
4.2 书签替换与方案选型建议
如果模板里用的是 Word 书签(插入轨迹:插入 -> 书签),替换代码会更简单。
doc.Bookmarks["姓名"].Range.Text = "李四";书签的好处是定位精准,不会误伤正文里刚好出现的同名文本。但它有一个反常识的坑:替换文本长度较长时,书签本身会被新内容覆盖掉一部分,书签区域可能损坏。所以在替换前最好先判断书签是否存在,替换后如果还要在同一位置继续用书签,需要重新插入书签。
书签和占位符怎么选?我的经验是:
- 占位符适合程序统一生成的模板。模板里放几个
{}记号,代码无脑替换,自动化程度高。 - 书签适合业务人员手工维护模板、只改挖空字段的场景。它的定位语义更强,但要求业务人员理解“书签”这个概念。
功能上两条路都通,选哪种完全看你模板是谁维护的。
5. 无 Office 环境绕不过去:OpenXML SDK 快速玩法
我踩过一次大坑:一个要部署在 Linux 服务端的生成报告服务,用 Interop 写完代码后在本地跑得好好的,一部署就彻底歇菜——Linux 上根本没有 COM 概念,Word 都装不了。所以这一节讲 OpenXML SDK 的入门玩法,给服务端场景留一条备选路线。
5.1 创建 docx 的基本代码骨架
在 NuGet 安装DocumentFormat.OpenXml,然后看下面的代码。
using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; string path = @"D:\Temp\OpenXMLDemo.docx"; using (WordprocessingDocument wordDoc = WordprocessingDocument.Create( path, WordprocessingDocumentType.Document)) { MainDocumentPart mainPart = wordDoc.AddMainDocumentPart(); Document document = new Document(); Body body = new Body(); Paragraph paragraph = new Paragraph(); Run run = new Run(new Text("用OpenXML创建的第一行")); run.AppendChild(new RunProperties( new Bold(), new FontSize { Val = "28" } )); paragraph.Append(run); body.Append(paragraph); document.Append(body); mainPart.Document = document; mainPart.Document.Save(); }OpenXML 的模型是层层嵌套的:Document在最外层,里面是Body,Body包含Paragraph,Paragraph包含Run,Run包含真正的Text。你可以把Run理解成“一段连续相同格式的文字块”,想另起一段就 new 一个新的Paragraph。
第一个容易踩的坑就是字号单位。FontSize的Val是以“半磅”为单位的数字,28表示 14 磅,36表示 18 磅。很多人第一次写FontSize { Val = "14" },结果是 7 磅的小字,怎么看怎么不对。
5.2 表格和单元格宽度怎么用 OpenXML 控制
OpenXML 控制表格列宽比 Interop 要隐晦一些,概念上需要理解TableWidth和TableGrid。看下面的示例。
Table table = new Table(); TableProperties tableProps = new TableProperties(); tableProps.Append(new TableWidth { Width = "5000" }); table.Append(tableProps); TableGrid grid = new TableGrid(); grid.Append(new GridColumn { Width = "2000" }); grid.Append(new GridColumn { Width = "2000" }); table.Append(grid); for (int i = 0; i < 2; i++) { TableRow row = new TableRow(); for (int j = 0; j < 2; j++) { TableCell cell = new TableCell(); cell.Append(new Paragraph(new Run(new Text("单元格")))); row.Append(cell); } table.Append(row); } body.Append(table);列宽的单位是 twips,1 英寸等于 1440 twips,1 厘米大约等于 567 twips。上面例子两个GridColumn各 2000 twips,整体大约 7 厘米。注意TableGrid中的列数必须和实际TableRow里的单元格数量一致,否则 Word 打开时会提示表格损坏或自动修复。
5.3 OpenXML 适合的场景和坑
OpenXML 适合这样几个场景:Linux 服务器上跑文档生成服务、高并发批量生成、需要对 XML 结构做精确控制。它的缺点是代码冗长,逻辑稍微复杂一点就要面对大量嵌套对象;另一个坑是需要对“Word 内部 XML 结构”有一定了解,概念模型和 Interop 那种“操作 Word 进程”的直觉完全不同。
但好处也很实际:不依赖 Office 安装,没有 COM 权限问题,在容器里特别好部署。我现在的做法是:本机工具用 Interop,服务端高并发用 OpenXML,两者并不冲突。
6. 实战踩坑清单:从进程残留到列宽失效
最后把我在实战里遇到过的常见问题集中列出来,附带排查思路。
6.1 进程残留问题
现象是程序跑完后任务管理器里 WinWord.exe 数量越来越多,甚至想删掉刚生成的临时文档时提示文件被占用。大多数情况是 COM 对象没有正确释放,异常中断导致 Application 没有执行Quit()。
排查顺序按照下面几步走:
- 检查代码里所有
return、throw的路径,确认 finally 块是否真的会执行。 - 确认 finally 里调用顺序是:先
doc.Close(),再wordApp.Quit(),最后Marshal.ReleaseComObject。 - 如果程序是多线程并发生成文档,要给生成动作加锁或使用信号量,确保同一时间只有一个 Word 实例在工作。
- 实在清不掉残留进程,可以写一个诊断方法,用
Process.GetProcessesByName("WINWORD")枚举并强杀。这只适合测试环境,生产环境还要从源头解决。
我见过一个极端案例:循环生成 200 份文档,代码没释放 COM 对象,跑完后服务器上挂了 40 多个 Word 进程,最后整台机器内存告警。所以说释放这件事不是小细节,是必须项。
6.2 服务端 Interop 权限与安全
用 Windows 服务或 IIS 调用 Interop 时,经常会遇到两类报错:一类是 COM 类工厂访问被拒绝,另一类是“远程过程调用失败”或“由于安全设置被禁用”。这其实是 Word 的 DCOM 配置不允许服务账户直接调用。
临时解法是在运行窗口输入dcomcnfg,打开组件服务,找到 “Microsoft Word 97-2003 Documents” 的属性,在安全选项卡中给运行服务的账户授权。但这套配置在每台部署机器上都要手动搞一遍,自动化部署时特别容易遗漏。而且 Word 本身是交互式程序,在后台服务里高频创建、销毁进程,很容易出现并发和稳定性问题。
微软官方也不建议在服务端用 Office 自动化。所以如果项目是服务端场景,强烈建议直接上 OpenXML 或者第三方库,别在 Interop 权限问题上死磕。
6.3 表格列宽失效和保存文件被占用
表格列宽失效的排查顺序:
- 确认
AllowAutoFit已经设为 false。 - 确认使用了
SetWidth并传入wdAdjustNone,而不是简单的Column.Width = 80f。 - 检查表格里是否有合并单元格。合并过的列结构在 Word 内部是“网格跨度”逻辑,某些列宽会由合并单元格控制。
- 如果模板是既有文档,里面可能残留原来的网格定义。最省事的方法是把原表格整体删除重新插入,而不是在原表上改宽度。
保存文件被占用的报错也很常见:目标文件正好被用户用 Word 打开,程序再写同一个路径就会抛共享冲突。稳妥做法是保存前先尝试删除旧文件,删除失败就弹提示让用户关闭文件。另外,SaveAs2保存成功后 doc 对象仍然持有旧文件状态,如果还要继续修改并二次保存,先执行刷新或重新打开,避免出现覆盖混乱。
就我个人来说,现在最顺手的搭配是:本机工具用 Interop 做模板替换,追求效率;服务端生成服务用 OpenXML,避免 COM 权限和进程残留。这两条路各自都有学习成本,但把它们的边界分开之后,绝大多数文档生成需求都能稳稳落地。希望这篇能帮你少走一点我当初选型时走过的弯路。