简介:面向需要在 .NET 平台下处理 Word 与 PDF 文档的开发者,主要解决引用 Spire.Doc 类库时经常遇到的水印和授权问题。压缩包内共 6 个文件,以 3 个 DLL 程序集为主,包含 Word 与 PDF 两个核心程序集,同时提供 2 个 XML 文件用于许可配置和接口说明,另有 1 个说明文本辅助阅读,整体仅 7.04MB,适合作为轻量级工具包直接集成到项目中。开发者只需添加对应 DLL 引用,即可调用 API 完成 Word 文档的创建、读取、编辑、格式转换以及 PDF 的基础操作,无需额外注册组件;XML 文件可协助加载许可并查看程序集结构,避免运行时出现水印或授权提示,减少踩坑时间。资源特意整理为无水印版本,省去自行搜索试用版或反复申请评估许可的麻烦,对刚接触 Spire 组件、希望快速搭建文档处理功能的初级到中级开发者尤为实用。目前已有 3911 人学习下载,验证了内容在同类场景中的参考价值。
1. Spire.DOC 的无水印版本:为什么大家都在找这个 DLL
做 .NET 服务端开发的,几乎没有没被“生成的 Word 文件带水印”坑过的。Spire.DOC 免费版会在文档开头强插一行 “Evaluation Warning” 的英文提示,还有段落数量上限;于是在搜索框里,Spire.DOC 和“无水印”天然绑在一起。可等你真的去下载一个非官方重打包的 DLL 回来,引用成功、水印消失的那一刻,往往也是把它带进生产环境第一颗雷的开始。这篇笔记就围绕“引用 Spire.DOC.dll 搞定无水印输出”这个诉求展开,先把评估版水印的机制讲清楚,再给你一套能落到工程上的项目配置、代码写法和故障排错步骤。目标只有一个:你自己判断这条路值不值得走,以及真要走时,怎么走得稳。
2. 水印从哪里来:评估版 DLL 的限制到底卡在哪
2.1 那行 "Evaluation Warning" 是用什么逻辑写进文档的
第一次被坑的场景是这样的:用 NuGet 装了官方包,跑了十行代码,生成的 docx 一打开,最顶上赫然一行英文:
Evaluation Warning: The document was created with Spire.DOC for .NET.这不是水印图片,不是页眉设置,而是被当作正文第一段真实写进了文档。这也解释了为什么你去删除它,会连带把文档结构一起弄乱。
Spire.DOC 的底层判断很简单:每个 API 入口在操作前检查一次许可证状态。没有加载合法许可证文件、也没有购买商业授权的,内部就进入评估模式,评估模式会在文档内容提交前注入这行警告文本,并且把文档内容量限制在一个很低的上限。这不是某个开关能绕过的,是一个贯穿全部公共 API 的全局状态。
常见的做法是,你在项目的Program.cs或Global.asax启动代码里加载许可证文件,把许可证状态从Evaluation切到Licensed。下面这段是典型写法:
using Spire.License; // 从指定路径读取商业授权文件 LicenseProvider provider = new LicenseProvider(); provider.SetLicenseFile(@"D:\licenses\spire.doc.license"); // 验证是否生效,返回的 LicenseType 会变成 Commercial LicenseType type = provider.GetLicenseType(); Console.WriteLine($"当前授权类型: {type}");注意SetLicenseFile的参数是绝对路径,而且必须在第一次调用任何 Document 操作之前执行。很多项目把这句话写在了某个静态构造函数里,结果却被延迟初始化打乱了顺序,等真正生成文档时 LicenseProvider 压根没跑起来,这不是代码问题,是初始化时机问题。
2.2 免费版不只是有水印:段落、表格、SaveToFile 的限制
很多人以为“无水印版本”解决的是水印这一个点,忽略了免费版还卡着其他边界。这套库的免费授权(也就是不加载许可证时的默认状态)有硬限制:文档段落数上限大约 500 段、表格数上限大约 25 个,数据量稍微上来一点就直接抛异常,而且这个异常不是“提示你购买”那种温和报错,是直接中断。
我做过一个帮某办公系统导出合同台账的需求,条目本身才两百条,每条转成一行文本还没到 500 段,但每条合同下面挂一个两行三列的信息表格,一个合同就是 1 段 + 1 表。两百个合同跑完前五十个,SaveToFile就抛了。查日志时看到Evaluation limitations相关的异常信息,才意识到不是代码写错了,是免费版本的上限机制在起作用。
这里有个隐性坑:不同文件格式的上限算法不一样。DOCX、DOC、RTF 三种格式对段落和表格的计算方式有差异,同样的内容存成 RTF 可能能跑完,存成 DOCX 就炸。这很容易让人误判成“格式兼容性问题”,浪费大量时间去调序列化设置。
2.3 网传“无水印版本”为什么我不建议生产环境用
标题里的 Spire.zip 指向的通常是二次打包的 DLL 文件,原理听起来简单:把官方评估版 DLL 替换成某个声称“去掉了水印逻辑”的版本,引用即可。但你要想清楚,这等于把一个来路不明的二进制文件直接拉进你的构建产物,后果有三层。
第一层是程序集签名丢失。官方 DLL 有强名称签名,替换后签名失效,如果你的项目启用了程序集签名校验,加载阶段直接报错,连跑都跑不起来。第二层是升级隔离,Spire.DOC 每个版本都会修旧 bug、加新 API,你拿到一个旧版本凑合能用,三个月后行业内卷要加新功能,对方不更新你就只能干瞪眼,想自己修还没源码。第三层才是最现实的——没有人能对这个 DLL 负责。你线上文档生成乱码了、数字证书没签上、内存泄漏拖垮了服务,你连一份有效的升级日志都拿不到,只能靠自己在生产环境堆日志逆向排查,这就是典型的捡了芝麻丢西瓜。
我的个人底线是:开源库无这风险可以随便用,商用闭源库要么买授权、要么换方案,唯独不要拿来路不明的二进制进生产。
3. 引用 Spire.DOC.dll 的最小工程:从零搭一个能跑的环境
3.1 拿到 DLL 之后:项目引用与许可证确认
先明确一个结论:Spire.DOC 的 DLL 拿到手之后,所谓“引用即可”只是完成了 30% 的工作,剩下 70% 是正确配置许可证和初始化时机。
以 Visual Studio 里的 .NET 项目为例,把 DLL 拿进来有三种常见途径:
- 项目右键 → 添加引用 → 浏览 → 选中 Spire.Doc.dll,这种方式最直接,DLL 会被复制到输出目录;
- 通过 NuGet 搜索 Spire.Doc 安装官方包,依赖统一管理,升级换版本方便;
- 把 DLL 放进项目根目录的
libs文件夹,在.csproj里用<Reference>节点显式声明。
第三种方式在团队协作里最稳,因为路径固定,大家拉代码后不会出现“你机器上有,我机器上引用无效”的问题。一个典型配置:
<ItemGroup> <Reference Include="Spire.Doc"> <HintPath>libs\Spire.Doc.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>Private设为true的含义是:编译时把 DLL 复制到输出目录,避免运行时报 “未能加载文件或程序集”。如果你在公有云函数、容器镜像里发布,千万不能省这一步——运行目录里没有 DLL,引用配置写得再漂亮也白搭。
加完引用之后,立刻做一个最小验证:打印一下LicenseProvider能拿到的许可证状态。
using Spire.License; LicenseProvider provider = new LicenseProvider(); LicenseType type = provider.GetLicenseType(); Console.WriteLine(type == LicenseType.Evaluation ? "仍未授权" : "已授权");如果输出是Evaluation,后面所有操作都会被限制;如果输出是Commercial,说明授权文件已经生效,可以继续往下做功能开发。
3.2 最小 C# 控制台工程:生成一个带内容的 Word 文档
引用了 DLL、确认了授权状态之后,先别急着叠功能,跑一个最小闭环。创建一个控制台应用,写下面的代码,目标是生成一个包含标题和一段正文的 docx 文件:
using Spire.Doc; using Spire.Doc.Documents; // 第一步:创建空白文档 Document doc = new Document(); // 第二步:追加一个标题段落并设置样式 Paragraph title = doc.AddParagraph(); title.AppendText("项目交付说明"); title.ApplyStyle(BuiltinStyle.Heading1); // 第三步:追加一个正文段落 Paragraph body = doc.AddParagraph(); body.AppendText("这是由 Spire.DOC 程序化生成的文档内容,用于验证 DLL 引用与文件输出链路。"); // 第四步:保存为 DOCX 格式 doc.SaveToFile("output.docx", FileFormat.Docx);这个最小例子有四个关键点。Document是文档根对象,所有段落、表格、图片都挂在它下面;AddParagraph()每次调用都会在文档末尾追加一个段落;ApplyStyle(BuiltinStyle.Heading1)是套用内置标题样式,不套样式的话输出就是默认正文格式;SaveToFile的第二个参数FileFormat.Docx指定输出格式,支持 Doc、Docx、Rtf、Html、Txt 等常见类型。
运行结束后,到 exe 同目录下找output.docx,用任意 Word 工具打开。如果文档里干干净净没有 “Evaluation Warning”,说明链路通了;如果还有水印,直接跳到第 5 章对应故障去查。
3.3 .NET Framework 与 .NET Core(含 .NET 5+) 的差异
这块是最容易翻车的。Spire.DOC 的 DLL 分 Framework 版和 .NET Core 版,混用会出“同代码不同结果”的诡异现象。
.NET Framework 4.6.1 及以下版本使用传统的System.Web模型,配置简单,复制 DLL 即可;从 .NET Core 3.1 开始,官方把库拆成了多个目標框架包,你引用的 NuGet 包会自动根据项目目标框架选中对应版本,没有太大问题。真正麻烦的是手动引用老版 DLL 到 .NET Core 项目的场景。
典型报错是:
System.BadImageFormatException: 未能加载正确的程序集原因就是项目以 AnyCPU 编译,但实际运行的宿主进程是 64 位,而手头的 DLL 是 32 位编译的,或者反过来。解决方式是把项目平台目标改为 x64(现代服务器几乎都是 64 位),确保 DLL 与出进程位数一致。另一个很现实的差异是 Linux 环境,.NET Core 应用部署在容器里跑,LicenseProvider的初始化行为在不同平台上表现不完全相同,我踩过的是 Linux 容器里找不到授权文件路径。排查思路是:把授权文件路径改成环境变量注入,避免硬编码绝对路径。
4. 用 Spire.DOC 写出可交付的 Word 文档:三步落地
4.1 从空白文档到结构化段落
拿到最小闭环之后,下一步是做出能交付给人看的文档结构:标题、副标题、多个正文段落、列表。代码骨架:
using Spire.Doc; using Spire.Doc.Documents; using Spire.Doc.Fields; Document doc = new Document(); // 文档标题 Paragraph title = doc.AddParagraph(); title.AppendText("2024 年度质量分析报告"); title.ApplyStyle(BuiltinStyle.Heading1); // 副标题 Paragraph subtitle = doc.AddParagraph(); subtitle.AppendText("报告编号:QC-2024-001"); subtitle.ApplyStyle(BuiltinStyle.Heading2); // 正文第一段 Paragraph p1 = doc.AddParagraph(); p1.AppendText("本报告基于三个月的生产数据编制,重点分析缺陷率趋势。"); p1.Format.LineSpacing = 20; // 设置行距 // 有序列表 List list = doc.AddList(ListType.Ordered); doc.AddParagraph().AppendList("新建检测流程"); doc.AddParagraph().AppendList("收集生产线质检结果"); doc.AddParagraph().AppendList("汇总并计算缺陷率"); doc.SaveToFile("report.docx", FileFormat.Docx);这里用到了两个新的知识点。ListType.Ordered创建有序列表,AppendList把当前段落绑定到该列表上,比手敲编号要可靠得多,后续插入新条目时编号会自动调整,不会出现“1、2、3、5”这种人工编号翻车的局面。另外LineSpacing是直接控制段内行距,数值单位是磅,20 磅对应约 1.5 倍行距,出版级排版会用它把文档版面撑开。
注意一个细节:AddParagraph().AppendList(...)不能写成AddParagraph().AppendText("1. xxx"),后者生成的是纯文本,样式上不像列表,而且一旦增删条目就得手动全部重新编号。这是个看着无关紧要、实际很影响交付质量的选择点。
4.2 表格、图片与页眉页脚操作
表格是业务文档里最重的模块。Spire.DOC 里表格操作是AddTable系列方法,以下代码创建了一个三行五列的表,填充表头并写入单元格数据:
using Spire.Doc; using Spire.Doc.Documents; Document doc = new Document(); Section section = doc.AddSection(); // 创建 3 行 5 列的表格 Table table = section.AddTable(); table.ResetCells(3, 5); // 填表头 string[] headers = { "产品线", "三月销量", "四月销量", "环比增幅", "备注" }; for (int i = 0; i < headers.Length; i++) { table[0, i].AddParagraph().AppendText(headers[i]); } // 填第二行数据 table[1, 0].AddParagraph().AppendText("标准件"); table[1, 1].AddParagraph().AppendText("1200"); table[1, 2].AddParagraph().AppendText("1350"); table[1, 3].AddParagraph().AppendText("+12.5%"); table[1, 4].AddParagraph().AppendText(""); doc.SaveToFile("table_demo.docx", FileFormat.Docx);ResetCells是必须的,它提前划分好行列结构,之后用table[row, col]索引访问单元格时才不会越界。这个索引是 0 基,编码时要注意和视觉上第几行第几列的换算。这里最容易出的 bug 是索引写反,table[1, 0]是第二行第一列,不是第一行第二列,多测几次就明白了。
图片呢,常见做法是调用段落对象上的AppendPicture。插入时有两个关键参数:图片宽度和高度。如果不显式设置,Spire.DOC 会按原图物理尺寸放进文档,一张 4000x3000 的手机拍摄图直接爆版。所以一般会统一缩到业务要求的宽度:
using Spire.Doc; using Spire.Doc.Documents; using Spire.Doc.Fields; Document doc = new Document(); Paragraph p = doc.AddParagraph(); p.AppendText("现场环境照片:"); DocPicture picture = p.AppendPicture(Image.FromFile(@"D:\photos\site.jpg")); picture.Width = 400; // 单位:磅 picture.Height = 300; doc.SaveToFile("with_image.docx", FileFormat.Docx);Width和Height的单位是磅,1 英寸约等于 72 磅,A4 页面正文宽度通常取 420 磅左右,所以 400 磅的图刚好能占满一行。如果原图比例不对,只锁宽度不锁高度会导致图片拉伸变形,我一般在业务侧先算好等比高度再赋值,宁可在服务端多一步Image计算,也不让 Word 里出现一张扁脸的照片。
页眉页脚是用Section对象操作的:
Section section = doc.Sections[0]; HeaderFooter header = section.HeadersFooters.Header; Paragraph hp = header.AddParagraph(); hp.AppendText("内部资料,注意保密");页眉内容会出现在该节每一页的顶部,常用于企业文档的统一规范。要注意Sections[0]是文档第一个节,如果文档本身就一节,这句没问题;如果已经分了多节,只设置Sections[0]会出现只有第一页有页眉、其他页没有的怪象。多节文档要遍历所有Section统一赋值。
4.3 读取已有文档并修改
生成新文档是高频操作,但真实场景里更多的任务是“打开一份同事传过来的 Word,替换掉其中的特定内容,另存为新文件”。核心 API 是查找替换和段落遍历:
using Spire.Doc; using Spire.Doc.Documents; Document doc = new Document(); doc.LoadFromFile(@"D:\templates\contract_template.docx"); // 查找替换:把占位符替换成真实数据 doc.Find("{{甲方名称}}").GetAsOneString(); doc.Replace("{{甲方名称}}", "某科技有限公司"); doc.Replace("{{合同金额}}", "¥128,000.00"); // 遍历段落查找特定文本,再做精细操作 foreach (Paragraph para in doc.Sections[0].Paragraphs) { if (para.Text.Contains("签约日期")) { para.AppendText("2024-06-30"); break; } } doc.SaveToFile("contract_filled.docx", FileFormat.Docx);Find方法是简单文本查找,GetAsOneString()用于确认找到内容。Replace做的是全文替换,效率比循环遍历高。只要模板里的占位符命名规范,这条链路几乎零成本。经验是不要在模板里用中文括号或全角空格做占位符,比如{{甲方名称}}用半角大括号,全角字符在保存时容易变成另一个编码序列,替换匹配会莫名其妙失败。
遍历段落的方式适合处理“替换之余还要追加说明”的场景,比如找到签约日期段落后顺手追加一句补充说明。要注意的是para.Text拿到的文本是拼接后的结果,如果段内有多个 TextRange 且格式不同,直接对Text做判断没问题,但要改格式就得深入TextRange层级。
5. 避坑:水印未消除 / DLL 加载失败的五类高频故障
5.1 现象:文档里仍然出现 Evaluation Warning
现象很好认:代码写完了、DLL 也引了,生成的 docx 打开第一行还是 “Evaluation Warning”。
原因排查顺序很重要。第一查许可证是否加载成功,打印LicenseProvider.GetLicenseType()看返回;第二查加载时机,很多人把许可证加载写在静态字段初始化里,结果静态字段被 JIT 延迟初始化到第一次调用时才执行,而第一次调用恰恰就是生成文档的代码,顺序反了;第三查授权文件路径,相对路径在服务环境下经常解析到不对劲的目录,日志里看不出来。
解决方式有两个:一是把所有SetLicenseFile统一提到程序入口最前面,比如Main()方法的第一行;二是用环境变量或者配置文件存授权文件的绝对路径,而不是写死在代码里。我的习惯是服务一启动就在入口处调用一个LicenseBootstrap.Ensure()方法,里面加载授权并抛异常,宁可启动失败也不允许带病运行。
5.2 现象:ASP.NET 或 Windows 服务里生成文档闪退或没响应
本地控制台跑得好好的,一放到服务里就崩或者无响应,这是最常见的环境问题。典型的日志是进程非正常退出但没有抛出任何业务异常。
原因多在于COM 初始化模式:控制台应用默认是 STA 线程模式,而 ASP.NET 线程默认 MTA。Spire.DOC 底层依赖某些 COM 组件做格式转换时,MTA 线程下 COM 调用会失败或挂起。解决方式是在服务启动代码里显式把线程模式切到 STA,或者把文档生成任务丢到专用 STA 线程执行:
Thread worker = new Thread(() => { // 这里执行文档生成逻辑 GenerateDocument(); }); worker.SetApartmentState(ApartmentState.STA); worker.Start(); worker.Join();另一个非常常见的服务端坑是输出目录权限。SaveToFile要写磁盘,服务账号经常没有目标目录的写权限。别想当然以为“反正 exe 在那能跑就肯定能写”,IIS 应用池账号默认权限极其有限,把文件写到Path.GetTempPath()然后转移到正式目录,或者直接用完全限定的绝对路径并给目录加写权限,两种方式都行。
5.3 现象:调用 SaveToFile 抛 FileFormat 相关异常
异常文案一般长这样:
Spire.Doc.Exceptions.FileFormatException: 无法保存文档,请检查路径或格式。大多数情况下是路径目录不存在。SaveToFile不会自动创建目录,D:\out\2024\06\report.docx如果2024\06目录不存在,异常就抛出来了。解决方式是保存前先确保目录存在:
string fileName = @"D:\out\2024\06\report.docx"; string dir = Path.GetDirectoryName(fileName); if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); } doc.SaveToFile(fileName, FileFormat.Docx);还有一种是格式与扩展名不匹配,比如文件名叫.docx却传了FileFormat.Rtf,某些版本不会自动辨认扩展名,按传入格式写文件,最后拿到一个扩展名是 docx 实质是 RTF 内容的东西。交付时对方双击打开会弹格式兼容性提示,非常掉链子。所以我规范成:文件路径和FileFormat枚举永远用一个常量对象来定义,不允许在业务代码里散落魔数。
5.4 现象:CPU 和内存持续飙升,GC 压不住
大批量生成文档时,如果循环里反复创建Document对象从不释放,内存会迅速涨上去。Spire.DOC 每个Document实例内部会加载字体、样式表、默认模板等资源,一个实例体量不小。循环一万次就有一万个实例堆积。
解决方式是每轮循环用完就释放:
for (int i = 0; i < 10000; i++) { Document doc = new Document(); // 填充内容 doc.SaveToFile(..., FileFormat.Docx); doc.Dispose(); // 释放非托管资源 }Dispose()是关键,但也有人释放了之后仍然内存增长,那是另一个问题:静态缓存。如果LicenseProvider在内部缓存了文档模板,释放 Document 不会清缓存。这种情况建议把批量任务拆成分批执行,每批之间主动GC.Collect()一次。虽然工程上不建议频繁手动 GC,但在这种“大量创建同构文档对象”的高压场景,手动收集一次的效果非常明显。
5.5 现象:引用网传 DLL 之后,连技术支持都没有
这条没有跑在代码层面,但它是最大的坑。网上流传的“无水印版本”是一个二进制黑匣子,你无法对它做代码审查,无法确认它是否在背后执行了额外行为,更无法确认是否存在供应链投毒风险。出问题时,你的系统要么自己啃日志,要么把责任抛给一个根本没有厂商识别码的匿名 DLL。团队开发不只是一台机器的问题,你一个人的本地验证通过,不代表它能在同事的机器、CI 服务器、生产容器里行为一致地运行。这也是我坚持只用官方分发渠道、正式申请商业授权的直接原因:买的是不仅一个 API,而是一个“可追溯、可升级、出了问题有地方问”的交付承诺。
6. 上线前最后一步:验证生成的文档真的能过审
文档功能写完、水印也“感觉”没了,别急着上生产,用一组自动化检查把住最后一道门。
第一层检查是纯净性。生成完成后直接把 docx 解压,查找文档正文 XML 里是否含有 “Evaluation Warning” 字样。docx 本质是一个 zip 包,所以不依赖 Word 也能判断:
using System.IO.Compression; string docxPath = @"D:\out\final.docx"; using (ZipArchive archive = ZipArchive.OpenRead(docxPath)) { ZipArchiveEntry entry = archive.GetEntry("word/document.xml"); using (StreamReader reader = new StreamReader(entry.Open())) { string xml = reader.ReadToEnd(); bool hasWatermark = xml.Contains("Evaluation Warning"); if (hasWatermark) { throw new Exception("文档仍存在评估水印,禁止发布"); } } }这段检查脚本不需要安装任何 Office 组件,CI 环境也能直接跑,把它挂在构建流程的后置验证里,比每次人工打开 Word 检查要可靠得多。
第二层检查是完整性。打开生成的文件,确认标题、正文、表格、图片数量符合预期。这个通过引用计数判断,比如生成前记录预期表格数为 N,生成后用 Spire.DOC 打开并统计实际表格数,对不上就失败。
第三层检查是格式兼容性。用 Word 或 LibreOffice 打开一次再另存为,看是否有格式报错。这一步比较主观,但确实能发现一些程序化生成时留下的怪癖,比如页面边距被重置、字体缺失导致替换。
这套流程走完之后,我对“引用 DLL 就能无水印”这句话的理解已经变成:无水印是结果,不是手段。只要许可证正确加载、初始化顺序正确、资源合理释放,这个库生成的文档完全值得上生产;反之,追求捷径拿到的那份 DLL 带来的都是隐性债务。希望这些实测经验能帮到你,至少让你的文档生成链路少走几轮我们当年走过的弯路。
本文还有配套的精品资源,点击获取