Spire.Doc去除水印与C#打印:docx水印定位清理实战
简介:面向.NET/C#开发者的Word文档水印处理资源包,聚焦使用Spire.Doc在程序中识别并移除Word文档里的图片水印、文本框水印等常见干扰内容,适合批量清洗文档、导出干净稿或做文档格式预处理的开发场景,也可用于文档自动化处理链路。压缩包共31个文件,约164.82MB,包含15个dll动态库、13个xml配置/文档说明文件,以及nupkg包、签名文件和txt说明,覆盖net20、netstandard2.0、netcoreapp2.0、net46、net40等多个目标框架,便于按项目环境引用或通过NuGet方式集成。已有1784人学习下载。资源价值在于提供可直接参考的组件依赖与文档处理能力,帮助读者理解水印元素遍历、条件判断、删除与另存等实现思路,并可结合图片尺寸、文本关键字等信息做二次判断,作为C#文档自动化项目的依赖基础,减少自行搭建Word处理链路的时间。
1. 打印前那行灰字:先分清是授权标记还是文档自带水印
Spire.Doc 去除水印的需求,多数人是在 C# 打印的前一刻撞上的:导出、合并、套模板都正常,点下打印,页眉那行浅灰的字就跟着上纸了。这类问题卡人的地方不在代码难写,而在判断——那行字到底是文档里本来就有的水印,还是组件在保存时自己塞进去的授权标记。两者的处理路径几乎相反:前者用 OpenXML SDK 按形状和锚点精确摘除,后者只能靠授权或换库,写多少行正则都白搭。这篇按落地顺序往下推:先定位水印在 docx 包里的三种落点,再给能直接抄的 C# 摘除代码,然后是打印参数怎么设、纸张边距怎么对,最后是五条踩过的坑。适合做文档导出、批量打印、报表归档的 .NET 开发者。
2. 水印藏在 docx 的哪一层:三种落点与四条选型
2.1 把 docx 当 zip 打开:水印根本不在正文流里
.docx本质上是个 zip 包,word/document.xml只放正文,页眉页脚各自是独立的header1.xml、footer1.xml。Word 自带的「设计 → 水印 → 自定义」功能,落到 XML 里是一段 VML:<v:shape type="WordArt">包着TextPath,被塞进页眉段落里,还带style="position:absolute"和旋转角度。所以你在正文里全文搜索水印文字,什么都搜不到——它压根不在这一段 XML 里。
这个认知很关键。很多人第一反应是拿Document.Body.Descendants<Paragraph>()遍历删段落,删完保存打开一看水印还在,就以为是组件缓存。其实是找错楼层了,水印在页眉那层,正文遍历永远碰不到它。
我这几年接触的带水印文档,基本能归成三类,落点和摘除手法完全不同:
| 水印类型 | 常见来源 | XML 落点 | 识别特征 | 摘除方式 |
|---|---|---|---|---|
| WordArt 文字水印 | Word 内置水印功能、模板预设 | header*.xml内v:shape | type="WordArt",内含v:textpath | 删整个v:shape节点 |
| 页眉图片水印 | 企业模板、客户发来的合同 | header*.xml内w:drawing | HeaderPart下的ImagePart引用 | 删 drawing 并清理孤立图片部件 |
| 正文浮层图 | 扫描件、加水印的 PDF 转档 | document.xml内wp:anchor | behindDoc="1",尺寸接近页宽 | 删整个w:drawing容器 |
第一类最容易误判,因为它长得像图片、实际是矢量文字,删的时候只看w:drawing会漏;第三类最容易被当成「页面背景色」,其实是锚定在正文里的一个浮层对象,behindDoc="1"让它压在文字下面,打印时照样出图。
2.2 为什么评估版那行字删了又回来
用未授权的 Spire.Doc 保存文档,页眉或首段会多出一行 "Evaluation Warning : The document was created with Spire.Doc for .NET."。有人试着用代码把这行删掉再另存,打开确实干净了——但下一次导出,它又回来了。
原因不复杂:这行字不是文档自带的,是组件在Save那一刻写进去的。你删的是产物,不是产生它的那一步。只要还在这条导出链路上,每次保存都会重新注入。这也是为什么有人说「删了三次还在,是不是有缓存」,跟缓存没关系。
这里必须说清楚一件事:绕过授权标记不在我能给建议的范围内,也没有可靠写法,因为每次保存都会重新生成。正规路径就三条——买授权;用有功能限制的免费版(段落数、表格数有上限,超出的部分会被截断,具体额度以你下载时的说明为准);或者换掉组件,用 OpenXML SDK、NPOI 这类纯结构库自己拼。第三条路学习曲线陡一点,但打印和导出这条链完全可控,不会有授权标记混进成品。
提示:先确认你遇到的是哪一类。如果水印文字是固定的那句英文、而且每次导出位置一致,基本就是授权标记;如果是客户 Logo 或公司名,才是文档自带水印,可以放心用代码摘。
2.3 三条命令定位水印落点
动手改代码前,我一般先用命令行确认一遍,比在 Visual Studio 里打断点快得多。把.docx复制一份改成.zip解压,或者直接用unzip:
# 1) 列出包内所有页眉页脚部件,看有几个 section 的页眉 unzip -l contract.docx | grep -E 'header|footer' # 2) 在页眉里找 WordArt 水印(Word 内置水印的典型特征) unzip -p contract.docx word/header1.xml | grep -o 'type="WordArt"' | wc -l # 3) 找评估版标记和浮层图锚点 unzip -p contract.docx word/header1.xml | grep -c 'Evaluation Warning' unzip -p contract.docx word/document.xml | grep -o 'behindDoc="1"' | wc -l第一步的unzip -l最关键:如果输出里有header1.xml、header2.xml、header3.xml,说明文档分了多个节,首页页眉和奇偶页页眉是分开存的。很多人只处理了header1.xml,打印出来奇数页干净、偶数页带水印,就是这里漏了。
第二步返回数量大于 0,说明有 WordArt 水印;第三步的behindDoc计数对应正文浮层图的数量。数字对不上就说明还有别的落点没覆盖,别急着改代码。
2.4 选型:结构操作归 OpenXML,内容操作归 NPOI
这四个库我都用过,搭配方式基本固定下来:
| 库 | 擅长 | 短板 | 我拿它干什么 |
|---|---|---|---|
| OpenXML SDK | 直接操作包结构、页眉部件、形状节点 | API 偏底层,写起来啰嗦 | 摘水印、清理孤立图片部件 |
| NPOI | 段落、表格、文本替换,无需装 Office | 页眉形状支持有限 | 正文批量替换、表格填充 |
| Spire.Doc | 转 PDF、套模板、API 友好 | 未授权带标记,免费版有额度限制 | 有授权的项目里做转档 |
| DocX | 语法简洁,适合小项目 | 社区维护节奏慢 | 简单模板填充 |
实战里我的习惯是:页眉层的水印一律交给 OpenXML SDK,因为要精确到v:shape和wp:anchor这一级;正文段落级的清理交给 NPOI,代码短一半。两个库操作同一个文件时不要同时持有文件流,处理完一个关一个,否则保存时会报文件被占用——这个坑后面还会提。
3. 用 C# 摘掉水印:OpenXML 三个过滤条件 + NPOI 兜底
3.1 OpenXML SDK 路线:形状、文本、锚点三重过滤
核心思路是把「删水印」拆成三个独立过滤条件,任意一个命中就删,互不干扰。这样即使文档里同时有 WordArt 文字水印和浮层 Logo,一次也能清干净:
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Drawing.Wordprocessing; using Vml = DocumentFormat.OpenXml.Vml; static (int shape, int para, int pic) CleanWatermark(string path) { int shape = 0, para = 0, pic = 0; // 第二个参数 true 表示以可写方式打开,false 时所有修改都不会落盘 using var doc = WordprocessingDocument.Open(path, true); // 过滤条件一:页眉里的 WordArt 水印,遍历所有 HeaderPart,别只取第一个 foreach (var headerPart in doc.MainDocumentPart!.HeaderParts) { var arts = headerPart.Header.Descendants<Vml.Shape>() .Where(s => s.Type is not null && s.Type.Value == "WordArt") .ToList(); // 先物化,边遍历边删会抛异常 foreach (var s in arts) { s.Remove(); shape++; } headerPart.Header.Save(); } // 过滤条件二:正文里那段评估版标记,按文本内容匹配 var body = doc.MainDocumentPart.Document.Body!; foreach (var p in body.Descendants<Paragraph>().ToList()) { if (p.InnerText.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)) { p.Remove(); para++; } } // 过滤条件三:正文浮层图片,behindDoc=true 的就是压在文字下面的那张 foreach (var anchor in body.Descendants<Anchor>().ToList()) { if (anchor.BehindDoc?.Value == true) { anchor.Parent!.Remove(); pic++; } // 删的是外层 w:drawing,不是 anchor 本身 } doc.MainDocumentPart.Document.Save(); return (shape, para, pic); }几个参数得说明白。WordprocessingDocument.Open(path, true)的第二个参数是可写开关,写成false时Remove()只在内存里生效,Save()也不报错,文件就是不变——我第一次用这个库时在这一行上耗了半小时,以为是权限问题。
Descendants<Vml.Shape>()里的Type属性是StringValue类型,所以要判is not null再取.Value,直接写s.Type == "WordArt"编译不过。
过滤条件三里anchor.Parent!.Remove()删的是Anchor的父节点,也就是w:drawing。只删Anchor会留下一个空的w:drawing容器,Word 打开时可能提示「文档包含无法读取的内容」,这种报错很难查。
最后一行的Save()别漏,HeaderPart和Document是两个独立的部件,只存一个另一个改动就丢了。
3.2 NPOI 兜底:正文段落必须倒序删
正文层面的清理,NPOI 写起来短很多,而且服务端不需要装 Office:
using NPOI.XWPF.UserModel; static int CleanByNpoi(string path) { int removed = 0; using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); var doc = new XWPFDocument(fs); // 倒序删:删掉一个元素后,后面所有元素索引都会前移 for (int i = doc.BodyElements.Count - 1; i >= 0; i--) { if (doc.BodyElements[i] is XWPFParagraph p && p.ParagraphText.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)) { doc.RemoveBodyElement(i); removed++; } } fs.Position = 0; fs.SetLength(0); // 不截断的话,新内容会盖在旧字节前面,文件直接损坏 doc.Write(fs); return removed; }FileShare.None是有意加的,它让文件在处理期间被独占,别的进程想打开会立刻失败,比写到一半被打断强。代价是并发场景要加锁或者排队,下面避坑那章会讲。
倒序删这一条是硬要求。正序删第一个匹配项之后,原来的第二个匹配项索引变成 0,循环变量却已经走到 1,结果就是隔一个漏一个。表现是「明明有五行水印,删完剩两行」,很容易被误判成匹配规则有问题。
SetLength(0)同样别省。NPOI 的Write是从当前位置开始覆写字节,不截断的话新文件比旧文件短时,尾部会残留旧数据,Word 打开直接报文档损坏。
3.3 回读验证:别信「保存成功」
Save()不抛异常只代表写盘成功,不代表水印没了。我一般处理完立刻回读一遍,把统计数字打出来对账:
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using Vml = DocumentFormat.OpenXml.Vml; static void Verify(string path) { using var doc = WordprocessingDocument.Open(path, false); // 只读打开 var mp = doc.MainDocumentPart!; int artLeft = mp.HeaderParts .SelectMany(h => h.Header.Descendants<Vml.Shape>()) .Count(s => s.Type?.Value == "WordArt"); // 期望 0 int warnLeft = mp.HeaderParts .SelectMany(h => h.Header.Descendants<Paragraph>()) .Count(p => p.InnerText.Contains("Evaluation Warning")); // 期望 0 int picLeft = mp.Document.Body! .Descendants<DocumentFormat.OpenXml.Drawing.Wordprocessing.Anchor>() .Count(a => a.BehindDoc?.Value == true); // 期望 0 Console.WriteLine($"WordArt={artLeft} 警告段={warnLeft} 浮层图={picLeft}"); }三个数字都为 0 才继续往下走打印。有一个不为 0,八成是页眉部件没遍历全,或者文档用了「首页不同」的页眉配置——那种情况下HeaderParts里会有多个部件,按HeaderReference的Type分别是First、Even、Default,代码里遍历全部就没这个问题。
4. C# 打印这条链:PrintOut 参数、纸张与边距
4.1 三条打印路线的取舍
水印清干净之后,下一个问题是文档怎么送到打印机。C# 打 docx 没有原生支持,常见的三条路各有明确的适用边界:
| 路线 | 依赖 | 优点 | 主要限制 | 适用场景 |
|---|---|---|---|---|
Word InteropPrintOut | 必须装 Office | 排版还原度最高,参数全 | 服务端 Session 0 下容易失败 | 桌面端、内网有 Office 的机器 |
| 转 PDF 再打印 | 转档组件 + PDF 阅读器 | 版式固定,跨机器一致 | 多一步转档,字体要嵌入 | 归档、对账类必须版式一致的单据 |
PrintDocument手绘 | 无 | 完全可控,能拼多页 | 排版要自己算 | 标签、票据、固定模板 |
我的默认选择是转 PDF:一次转档,之后不管是预览、打印还是归档都用同一份文件,不会出现「这台机器打出来少一行」的玄学问题。只有在必须套 Word 排版又不想引入转档组件时,才走 Interop。
4.2 Word Interop 的 PrintOut 参数清单
using Word = Microsoft.Office.Interop.Word; using System.Runtime.InteropServices; static void PrintDocx(string path, string printerName, short copies) { var app = new Word.Application { Visible = false, DisplayAlerts = Word.WdAlertLevel.wdAlertsNone }; Word.Document? doc = null; try { doc = app.Documents.Open(path, ReadOnly: true, Visible: false); // 打印机名必须和「设备和打印机」里显示的完整名称一致,含网络路径 app.ActivePrinter = printerName; // C# 4 之后用具名参数即可,不必再 new object[] 逐个占位 doc.PrintOut( Background: false, // 前台打印,异常能立刻抛出 Range: Word.WdPrintOutRange.wdPrintAllDocument, // 全部页 Copies: copies, PageType: Word.WdPrintOutPages.wdPrintAllPages, ManualDuplexPrint: false); // 双面交给驱动,别在代码里翻面 doc.Close(SaveChanges: false); } finally { doc?.Close(SaveChanges: false); app.Quit(); Marshal.ReleaseComObject(app); // 不释放会残留 WINWORD.EXE 进程 } }Background: false是关键参数。设成true时PrintOut立刻返回,打印队列里的任务可能因为文档已关闭而丢失,表现是「偶尔打不出来,重试又好了」——这种间歇性故障最难查。改成前台打印,任务提交完成后才往下走。
app.ActivePrinter的赋值写法有讲究。老资料里让你写"\\\\print-srv\\HP-01"这种 UNC 路径再加Ne01:端口前缀,太容易错。直接写「设备和打印机」里看到的完整名称最稳,赋值失败会立刻抛异常,不会静默打到默认打印机上。
Marshal.ReleaseComObject和Quit()两个都要有。只Quit不释放,进程会以僵尸状态留在任务管理器里,跑几百份之后机器就卡了。这是 Interop 的老问题,跟水印没关系,但批量打印场景一定会遇上。
4.3 PrintDocument 管纸张:毫米换百分之一英寸
走PrintDocument或者打印转好的位图时,纸张和边距要自己设。单位是百分之一英寸,A4 的 210×297 毫米换算过来是 827×1169,这个数字建议直接写死成常量,别在代码里现算:
using System.Drawing.Printing; var pd = new PrintDocument(); pd.PrinterSettings.PrinterName = "HP-01"; if (!pd.PrinterSettings.IsValid) throw new InvalidOperationException("打印机名不存在或驱动未安装"); // 从驱动支持的纸张里挑 A4,挑不到就手工造一个 var a4 = pd.PrinterSettings.PaperSizes.Cast<PaperSize>() .FirstOrDefault(p => p.Kind == PaperKind.A4) ?? new PaperSize("A4", 827, 1169); // 210mm x 297mm -> 百分之一英寸 pd.DefaultPageSettings.PaperSize = a4; pd.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); // 边距靠驱动不可打印区 pd.DefaultPageSettings.Landscape = false; pd.PrintPage += (s, e) => { // e.Graphics 已经按 1/100 英寸换算好,直接按纸张尺寸画 using var img = Image.FromFile(@"C:\temp\page1.png"); e.Graphics!.DrawImage(img, 0, 0, a4.Width, a4.Height); }; pd.Print();PrinterSettings.IsValid这一行必须在设打印机名之后立即检查,不然后面Print()会抛一个信息极少的 Win32 异常,排查起来全靠猜。
Margins设成 0 不代表真能打到纸边,物理不可打印区由驱动决定,代码改不动。真要做无边距,只能换支持无边距的机型,或者把内容做小一点内缩。
5. 避坑与排查:五条血泪记录,从「删了还在」到「打印位移」
5.1 现象:删完页眉水印,打印奇数页干净、偶数页还在
原因:文档开了「奇偶页不同」,header1.xml和header2.xml是两个独立部件,只处理了MainDocumentPart.HeaderParts的第一个,或者干脆只 open 了header1.xml。
解决:一律用foreach (var part in doc.MainDocumentPart.HeaderParts)全遍历,别用FirstOrDefault()。改完之后用 3.3 节那段回读代码把三个计数打出来,都是 0 才往下走。
5.2 现象:保存时抛「文件正被另一个进程使用」
原因:前一步的FileStream没关,或者 Word、预览窗格占用着文件。Windows 资源管理器的预览窗格开着 docx 时也会加读锁,很多人栽在这。
解决:所有流用using包住;处理前把文件复制到临时目录再操作,处理完覆盖回原位置;服务端批量处理时加一个「同路径排队」的锁,别让两个线程碰同一个文件。
5.3 现象:打印出来右边被裁掉一截,或者内容整体偏上
原因:PrintOut没指定纸张,用了打印机默认的 Letter;或者代码里设置了PageSettings.Margins就以为生效了——Word 吃的是文档节属性里的w:pgMar,不是打印 API 的参数。
解决:文档侧改节属性,SectionProperties下的PageMargin设Top/Right/Bottom/Left(单位是 twip,1 毫米约 56.7 twip);打印机侧用 4.3 节的PaperSizes匹配 A4。两边都对齐,位置才稳。
5.4 现象:服务器上 Interop 报 0x800A1066,本机跑得好好的
原因:服务跑在 Session 0,没有交互桌面;ActivePrinter是 per-user 配置,服务账号下压根没登记默认打印机,赋值也可能被拒绝。
解决:服务端别碰 Interop。要么改走转 PDF 再打印,要么单独部署一个交互式账号下的打印服务,通过队列接收任务。我一般选前者,省掉一整类无法复现的问题。
5.5 现象:水印删干净了,文件体积没降,客户还是说「打开能看到痕迹」
原因:图片部件被删了引用,但ImagePart还留在包里;或者空白段落和w:drawing外壳还在,Word 会渲染出一个空的浮动框。
解决:删图片时连外层w:drawing一起删(3.1 节的做法);处理完重新用 OpenXML 打开另存一次,未引用的部件不会自动清理,需要在保存后检查MainDocumentPart.ImageParts的引用计数,孤立的直接DeletePart。
6. 进阶:把去水印和打印串成带自检的批处理
单份文档手工跑一遍没问题,几百份报表批量处理时,靠肉眼看是不现实的。我一般把流程压成三段,每段带一个可断言的指标:
| 阶段 | 做什么 | 通过标准 |
|---|---|---|
| 预处理 | 复制到temp/{guid}/,避免锁冲突 | 副本存在且可写 |
| 清理 | OpenXML 摘形状,NPOI 清段落 | 三个计数全为 0 |
| 打印 | 转 PDF 后提交打印队列 | 队列任务数 = 输入文件数 |
中间的清理阶段是唯一需要「失败即中断」的一段,因为水印没清干净就打印,浪费的是纸和人工。指标不达标时不要抛异常就完事,把文件名、剩余计数、命中的页眉部件名一起写进日志,否则第二天面对一堆失败文件还是得逐个手工拆包。
// 批处理骨架:单份失败不影响整批,但必须留证据 foreach (var file in Directory.EnumerateFiles(srcDir, "*.docx")) { var work = Path.Combine(tempDir, Guid.NewGuid().ToString("N") + ".docx"); try { File.Copy(file, work, overwrite: true); var (s, p, g) = CleanWatermark(work); if (s + p + g == 0) throw new InvalidOperationException("未匹配到任何水印节点,疑似判断有误"); Verify(work); // 计数不为 0 时在这里抛 PrintDocx(work, "HP-01", 1); } catch (Exception ex) { File.AppendAllText("failed.log", $"{file}\t{ex.Message}{Environment.NewLine}"); } finally { if (File.Exists(work)) File.Delete(work); // 临时副本一定要清 } }这里有个反直觉的判断:s + p + g == 0时我按失败处理。匹配数为 0 有两种可能——文档本来干净,或者过滤条件写错了。批量场景下后者代价太高,宁可让它进日志人工确认,也别默认放过。
最后说个习惯。那份failed.log我会在每批跑完之后手工扫一遍,重点看「未匹配到任何水印节点」这一条。早期我图省事把它当成功跳过,结果一个模板改版之后,整批 800 份文档都带着页眉 Logo 打出去了,返工重打花了一下午。从那以后我每次上批量之前都强制走一遍小样本验证——抽 3 份不同来源的文档,跑完打开看,确认页眉真的空了再放开量。这个动作花不了两分钟,但它是我唯一信得过的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取