收到一份PDF,打开一看,每一页右下角都压着“内部资料·请勿外传”的半透明水印,想打印出来开会,又不想让人看到这个“内部”字样;想直接发给合作方,又怕显得很不专业。手动删?几十页文件一页一页去水印,费时间不说,还经常按错键把排版弄乱。这篇博文要解决的就是这个痛点:用Python的PyMuPdf(本地库)批量处理PDF文档水印,不需要在线工具,也不用手动一页一页操作。
PyMuPdf是MuPDF渲染引擎的Python绑定,对PDF底层结构的控制能力非常强。它不仅能提取每页文本块、文字内容、bbox坐标、矢量图元,还能对页面内容做删除、覆盖、重建这类高级操作。我围绕“删除水印”这条主线,把水印最常见的四种存在形态拆开讲清楚,再给出可直接复现的检测与删除代码。文本水印、图片水印、矢量水印,以及页眉页脚处的固定水印,都会覆盖到。读完你就能自己写一个批量去水印脚本,扔给任意一批PDF也能快速定位问题。
1. 为什么选择PyMuPdf:水印处理的底层逻辑
1.1 PyMuPdf的核心优势
水印本质上就是“被刻意加到页面上、却又不希望被当作正文的内容”。要删除它,前提是能精确识别它、定位它,然后把它所在的页面区域或资源引用拿掉。这就对PDF工具库提出了很具体的要求:既要能读取页面对象,又要能修改页面内容。我对比过不少Python侧的PDF库,各有取舍:
| 库 | 文本提取 | 图形/图片解析 | 内容删除 | 内容流重建 | 上手难度 |
|---|---|---|---|---|---|
| PyMuPdf(fitz) | 强,支持dict/rawdict细粒度 | 强,get_drawings可拿矢量图元 | 强,redact/delete_text | 支持 | 中 |
| pdfplumber | 强,适合表格文字 | 弱,主要是文本坐标 | 不支持修改 | 不支持 | 中 |
| PyPDF2 / pypdf | 弱,文本提取不稳定 | 弱,页面级操作为主 | 有限 | 有限 | 低 |
| ReportLab | 不适合解析 | 不适用 | 不适用 | 适合生成 | 高 |
实际用过PyMuPdf的人应该都有体会:它的page.get_text("dict")能把页面里的文本块、行、span拆得非常细,每个span的bbox坐标、字体名、字号、颜色、旋转标志全都暴露出来;page.get_drawings()又能把底层矢量绘图指令抽象成容易读的结构。这种“又懂文本、又懂图形”的库,在PDF生态里确实不多。
另一个关键能力是apply_redactions()。它本来是做PDF脱敏编辑的,可以把某个矩形区域内的所有内容物理抹掉,并自动重写页面内容流,清理被引用的图片和字体资源。用这个函数来去水印,等于让渲染引擎替你把水印区域从内容流中擦干净,而不是简单画一个白色矩形盖住。这是PyMuPdf做水印处理最硬核的地方。
1.2 PDF水印的四种常见类型与应对策略
PDF里的水印,并没有一个统一的“水印对象”标准。制作方用不同工具生成水印,底层实现也完全不同。我把日常见到的水印归成四类,每类的检测和删除思路都不一样:
| 水印类型 | 典型特征 | 检测方式 | 删除策略 |
|---|---|---|---|
| 文本水印 | 半透明或浅色重复文字,如“草稿”“机密”,可被搜索到 | page.get_text("dict")或page.search_for("关键词") | page.delete_text(bbox)或 redact精确区域 |
| 图片水印 | 重复小图标、Logo,或整页背景图 | page.get_images()+page.get_image_rects() | redact覆盖区域,或重建内容流删除图片绘制 |
| 矢量水印 | 曲线、花纹、斜向文字线,常见为半透明路径 | page.get_drawings()判断透明度与位置 | redact覆盖区域,或内容流级过滤 |
| 页眉页脚水印 | 每页固定位置的页码、版权声明、网址等 | 固定区域bbox检查 | 固定区域redact或delete_text |
后续章节我会按这个分类逐个给出代码。一个核心原则是:先搞清楚水印长什么样,再决定怎么删。很多人拿到PDF上来就全页红act,结果正文全没了,这就是没做“特征识别”这一步。
2. 环境准备与PDF解析基础
2.1 安装与导入
直接用pip安装即可:
pip install PyMuPDF注意包名是PyMuPDF,但导入语句是import fitz。这个历史遗留问题几乎每个新手都会困惑一下:PyMuPdf的老版本叫fitz,后来包名改了,但模块名一直沿用fitz。
我当前使用的是1.23.x版本,API比较稳定。如果遇到方法不存在的情况,多数是版本太老,升级一下就行。
import fitz doc = fitz.open("input.pdf") print(doc.page_count) # 总页数 page = doc[0] # 第一页打开PDF后,常用操作就是把每一页拆出来分析。如果是加密PDF,需要先认证:
if doc.needs_pass: doc.authenticate("password")这块的import和基础操作很简单,但有一个习惯我建议早点养成:始终在副本上测试,不要一上来就覆盖原文件。用doc.save("output.pdf")输出新文件,确认效果没问题后再决定要不要替换。
2.2 用get_text解析文本块:bbox坐标与字体信息
水印删除最依赖的API就是page.get_text("dict")。它返回一个嵌套字典,结构是“页 -> 块 -> 行 -> span”。所谓span,就是一段字体、字号、颜色完全一致的连续文字,是文本分析的最小单元。
import fitz doc = fitz.open("watermarked.pdf") page = doc[0] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: # 0表示文本块,1表示图片块 continue for line in block["lines"]: for span in line["spans"]: print( "文本:", span["text"], "| 坐标:", span["bbox"], "| 字体:", span["font"], "| 字号:", round(span["size"], 1), "| 颜色:", span["color"], "| 旋转:", span["flags"] )运行后,每段文字的位置、字体、大小、颜色都会列出来。bbox是个四元组(x0, y0, x1, y1),表示左上角和右下角的坐标。PDF坐标系原点在页面左上角,x向右,y向下。
那么怎么从这些字段里认出水印呢?根据我的经验,重点看几个信号:
- 文字内容:出现“机密”“草稿”“DO NOT COPY”“内部资料”这类关键词,基本可以认定是文本水印。
- 字体大小:水印通常会故意做得大而淡,字号明显大于正文字号,或者反而很小、很灰。
- 颜色值:正文通常是黑色(RGB接近0,0,0)或深色,水印为了不遮挡正文,常用浅灰色(如0.6左右灰度)。
- 跨页重复:正文一般不会在同一位置反复出现同样一句话,水印会。
- 旋转标志:斜向水印往往带旋转,
flags字段和普通正文不同。
把上面几个信号组合起来,就能写一个不太容易误判的水印检测器。
2.3 用get_images解析图片:位置与尺寸
如果水印是图片,PyMuPdf用page.get_images(full=True)获取页面引用的图片资源列表。返回的是图片的资源信息,包含xref编号、宽高、色彩空间等。注意它返回的是“页面用到哪些图片资源”,但不会直接给出每张图片绘制在页面的哪个位置。要拿位置,需要配合page.get_image_rects(xref):
page = doc[0] for img in page.get_images(full=True): xref = img[0] rects = page.get_image_rects(xref) for rect in rects: print(f"图片xref={xref}, 绘制区域={rect}, 尺寸={rect.width} x {rect.height}")这里 xref 就是图片在PDF资源表中的编号,同一个图片资源可能被多个页面引用。如果某张图片在每一页都出现,且位置固定,那它很可能就是水印或者页眉页脚图片。图片水印经常是半透明PNG,PyMuPdf拿到的rect就是它在页面上实际占用的矩形区域,后续可以直接拿来定位删除。
2.4 用get_drawings解析矢量图元
文本水印和图片水印都比较好对付,最隐蔽的是矢量水印。它可能是一条斜线、一个Logo轮廓、一段曲线装饰,没有字体信息也没有图片资源,就是一个绘图指令。PyMuPdf提供page.get_drawings(),返回页面上的矢量绘图对象:
drawings = page.get_drawings() for d in drawings: print(d["rect"], d["type"], d.get("fill_opacity"), d.get("stroke_opacity"))每个drawing对象里最重要的字段是rect(外接矩形)、type(图形类型),以及透明度字段fill_opacity/stroke_opacity。水印为了不喧宾夺主,往往会把透明度调得很低,比如fill_opacity=0.2。如果你发现某个drawing的rect覆盖页面核心区域、透明度又明显偏低、而且在多页反复出现,那大概率就是矢量水印。
3. 四类水印的定位与删除实操
3.1 文本水印:search_for定位与delete_text删除
最简单的情况,是水印本身就是一段可搜索文本,而且你能猜到关键词。比如水印是“内部资料”四个字,每页出现多次。这时可以直接用page.search_for()找出所有匹配位置:
import fitz doc = fitz.open("watermarked.pdf") keyword = "内部资料" for page in doc: rects = page.search_for(keyword) if len(rects) < 3: # 同一页出现次数太少,可能只是正文提到 continue for rect in rects: page.delete_text(rect)page.delete_text(rect)会删除该矩形区域内的所有文本片段,但不影响图片和矢量图形。用它来清文本水印相对安全,尤其是水印区域没有正文的时候。
我的建议是,不要只看一次匹配,最好结合“跨页出现次数”做二次确认:
total_hits = 0 for page in doc: total_hits += len(page.search_for(keyword)) if total_hits > doc.page_count * 2: # 平均每页2次以上,基本就是水印 for page in doc: for rect in page.search_for(keyword): page.delete_text(rect)这种“先统计、再删除”的思路,能有效避免正文里偶尔出现一次关键词就被误删。
3.2 redact红act覆盖的精确用法
有些水印是斜着排列的,或者字符被分成了多个片段,search_for不一定能精确圈住。这种情况下,用page.add_redact_annot()手动圈定区域再apply_redactions(),是更通用的方案。
举个例子,水印在每页右下角,固定出现在x0=450, y0=750, x1=550, y1=800这个矩形内:
page = doc[0] rect = fitz.Rect(450, 750, 550, 800) page.add_redact_annot(rect, fill=(1, 1, 1)) # 白色背景填充 page.apply_redactions()fill参数决定删除后用什么颜色填补背景。这里用(1, 1, 1)白色填充,视觉上该区域就整洁了。不过要特别提醒:apply_redactions()是物理删除,它会抹掉矩形内所有内容,包括正文、图片、图形。所以矩形参数一定要精确,别贪多。如果水印区域和正文重叠,你就只能缩小范围,或者用后面提到的内容流方案。
另一个常见用法是,先用get_text("dict")扫描出所有疑似水印的span bbox,再把这些bbox统一加入redact列表,最后一次性执行:
def collect_watermark_bboxes(page): bboxes = [] data = page.get_text("dict") for block in data["blocks"]: if block["type"] != 0: continue for line in block["lines"]: for span in line["spans"]: if is_mark_text(span): # 自己的判断逻辑 bboxes.append(fitz.Rect(span["bbox"])) return bboxes for page in doc: for rect in collect_watermark_bboxes(page): page.add_redact_annot(rect, fill=(1, 1, 1)) page.apply_redactions()这种做法的好处是把“识别”和“删除”分开,识别逻辑可以随时调整,而删除动作集中执行。遇到复杂水印,我就靠这种方式反复迭代:跑一次扫描,打印出所有命中span,人工确认是不是水印,再跑正式删除。
3.3 图片水印:页面图片引用定位与forget_image
图片水印比文本水印麻烦一些。常见场景是:每页字符上方或下方有一张Logo,或是一个固定的小图案在页面中央重复铺开。
第一步,拿到所有页的图片绘制区域,按“是否跨页重复且位置固定”来筛选:
from collections import defaultdict stat = defaultdict(int) page_rects = {} for pno in range(doc.page_count): page = doc[pno] for img in page.get_images(full=True): xref = img[0] rects = page.get_image_rects(xref) if not rects: continue stat[xref] += 1 page_rects.setdefault(xref, []).append((pno, rects[0]))统计之后,如果某个xref在80%以上的页面都出现,且rect位置大致相同,那它就是高度疑似水印的图片。
对于删除策略,分两种场合:
场合一:图片在页面一块独立区域,不压正文、不压关键内容。这时直接用redact覆盖最省事:
for xref, hits in stat.items(): if hits < doc.page_count * 0.8: continue for pno, rect in page_rects[xref]: page = doc[pno] page.add_redact_annot(rect, fill=(1, 1, 1)) page.apply_redactions()场合二:图片和正文重叠,直接redact会误删,需要更彻底地移除绘制调用。这就要进入PDF内容流层面。PyMuPdf允许读取页面合并后的内容流二进制,经过处理后用page.set_contents()写回去。思路是:把图中引用该图片的绘制运算符从内容流里抹掉。但这个操作比较底层,不同工具生成的水印指令结构差异很大,需要你实际看内容流才能准确过滤。我的建议是,先尝试redact方案,确实不行再考虑内容流。这里给一个内容流重建的基础框架,可以按需修改:
contents = page.read_contents().decode("latin-1") # 找到特定图片调用并移除,具体匹配需要根据实际内容流调整 new_contents = clear_image_usage(contents, target_xref) page.set_contents(new_contents.encode("latin-1"))注意read_contents()返回的是合并后的内容流,如果原PDF内容流是压缩的,PyMuPdf会自动解压,所以直接在文本层处理即可。处理完要重新保存并检查效果。
另外有个坑:保存PDF时,PyMuPdf的doc.save("out.pdf", garbage=3, deflate=True)能清除无引用的资源,但页面上实际绘制了图片的区域并不会因此消失。也就是说,别指望光靠garbage参数去“垃圾回收”图片水印,必须先把页面上的绘制动作处理干净。
3.4 矢量水印与页眉页脚水印
矢量水印的通用识别逻辑,我前面提过:遍历page.get_drawings(),如果某个drawing的透明度低、矩形较大、在每页固定出现,就可以用该drawing的rect做redact。
def is_vector_watermark(drawing): rect = drawing["rect"] fill_opacity = drawing.get("fill_opacity", 1.0) stroke_opacity = drawing.get("stroke_opacity", 1.0) if fill_opacity >= 0.8 or stroke_opacity >= 0.8: return False if rect.width < 50 or rect.height < 50: return False # 太小,可能是正常装饰线 return True判断条件得根据实际文件调整。有些水印透明度很低,有些则是半透明的大横条。你可以在测试阶段把疑似drawing的rect、透明度、跨页频率全部打印出来,肉眼观察规律后,再收紧判断条件。
页眉页脚水印处理起来最直接:固定页面顶部的“某某技术文档”或底部的“第X页/共Y页”,可以用固定区域bbox解决。不要用get_text扫描全页,直接对顶部区域和底部区域执行redact:
page_height = page.rect.height page_width = page.rect.width header_rect = fitz.Rect(0, 0, page_width, 60) # 顶部60pt footer_rect = fitz.Rect(0, page_height - 60, page_width, page_height) # 底部60pt for page in doc: page.add_redact_annot(header_rect, fill=(1, 1, 1)) page.add_redact_annot(footer_rect, fill=(1, 1, 1)) page.apply_redactions()做法是粗暴了点,但胜在稳定。如果页眉页脚区域真的有正文内容裸奔进去,那只能先检查再删。页码水印多数是这种“区域型”水印,固定区域删除已经够用。
4. 实操中的常见问题与排查技巧
4.1 水印是整页半透明背景怎么办
这是最麻烦的场景:半透明的大字水印对斜着铺满全页,每个字符都压在正文上。直接对每个水印字符的bbox做redact,会严重误删正文。我的实际经验是:
- 第一步,先确认这些水印是不是可提取的文本。如果可提取,就把每个水印span的bbox收集起来,但注意不要直接redact,而是想清楚水印字符区域和正文区域的重叠程度。
- 第二步,如果重叠面积超过一半,方案就要换:一种是把水印span所在的区域用“只删除该span”的细粒度手段处理,这需要解析内容流后做精细操作;另一种是权衡一下,水印半透明程度不高的话,干脆保留原样,或者换成统一覆盖整页的红色act整流器,但那样正文也会被抹掉,不可取。
- 第三步,成规模的项目里,遇到无法精确删除的半透明背景水印,我会考虑OCR+重建页面:把页面渲染成高分辨率图片,用图像修复算法把水印区域补回,再重新生成PDF。不过这就属于另一个方向的工程了,不是PyMuPdf的强项。
PyMuPdf能做的极限,是干净利落地删除“能精确圈定区域”的水印。对于和正文完全纠缠的半透明水印,技术上没有银弹,必须根据内容重要程度决定是否动手。
4.2 扫描件水印处理思路
如果PDF本身就是扫描件,每一页就是一张大图,水印也“拍”进了图片里。这种情况下,PyMuPdf的文本层几乎是空的,get_text返回空字符串,search_for什么都搜不到。你可能需要把页面渲染成图像,再用图像处理手段处理。
pix = page.get_pixmap(dpi=200) pix.save("page.png")拿到PNG后再用OpenCV做水印区域检测和修复,也是常见组合。但这件事的成败极度依赖水印样式和背景复杂度,不能保证通用。我的建议是:扫描件去水印,优先考虑原文件或双层PDF;如果只能处理扫描件,把问题拆成“水印是否单色、位置是否固定、背景是否均匀”,再决定用阈值分离还是用修复算法。
4.3 四个必踩的坑与规避方法
我把自己踩过、也看别人踩过的坑列一份速查表:
| 坑 | 现象 | 规避方法 |
|---|---|---|
| 没备份就覆盖保存 | 删错后原文件没了 | 输出到新文件名,确认无误再替换 |
| redact矩形画太大 | 正文、图片一块被删 | 先用get_text("dict")打印所有bbox,肉眼核对 |
| 关键词误判 | 正文中的某个词被当成水印删除 | 加“跨页出现次数”和“字号/颜色”双重判断 |
| 加密PDF打不开 | 文件打开报错、权限受限 | 用doc.needs_pass和doc.authenticate()处理 |
| 保存后文件异常变大 | 冗余资源堆积 | doc.save("out.pdf", garbage=4, deflate=True) |
其中,garbage参数值得单独说一下。garbage=3或garbage=4会做更激进的垃圾回收,把页面内容中不再引用的字体、图片对象清理掉。执行大量redact之后,很多被覆盖的图片和文字资源其实已经被标记为无效,但没触发垃圾回收时,文件体积不会明显下降。加了这个参数,文件能瘦身不少,而且内容不变。
还有一个小技巧:处理完后再用fitz.open("out.pdf")重新打开一次,检查page_count是否一致,再抽一页渲染成图片看看视觉效果。验证步骤别省,我已经不止一次因为漏看某一页的水印,最后被打回来返工。
5. 进阶:批量处理与文档转换
5.1 多文件批处理脚本
实际项目里,很少只处理一个PDF。我会把整个过程封装成一个函数,然后批量扫描目录。下面这个脚本的思路可以套用:
import fitz import pathlib WATERMARK_WORDS = ["内部资料", "机密", "DO NOT COPY"] def remove_watermark(input_pdf, output_pdf): doc = fitz.open(input_pdf) removed_count = 0 for page in doc: rects_to_remove = [] for word in WATERMARK_WORDS: hits = page.search_for(word) # 如果同一页出现2次以上,才认为是水印 if len(hits) >= 2: rects_to_remove.extend(hits) for rect in rects_to_remove: page.add_redact_annot(rect, fill=(1, 1, 1)) if rects_to_remove: page.apply_redactions() removed_count += 1 doc.save(output_pdf, garbage=4, deflate=True) doc.close() return removed_count base_dir = pathlib.Path("pdfs") out_dir = pathlib.Path("output") out_dir.mkdir(exist_ok=True) for pdf_path in base_dir.glob("*.pdf"): out_path = out_dir / pdf_path.name pages = remove_watermark(str(pdf_path), str(out_path)) print(f"{pdf_path.name}: 处理 {pages} 页")在这个脚本里,关键词清单是硬编码的,实际用的时候建议从外部配置文件读取,方便按文件灵活调整。也可以增加“区域白名单”,比如页码范围、指定区域内的水印才删除,避免误伤。
5.2 删除水印后如何进一步转换
去完水印的PDF,很多人会顺手转成Word文档方便编辑,或者把多个PDF合并打包。这些都是PyMuPdf的常规操作。
PDF合并可以直接用insert_pdf:
merged = fitz.open() for f in ["a.pdf", "b.pdf", "c.pdf"]: src = fitz.open(f) merged.insert_pdf(src) src.close() merged.save("merged.pdf")而PDF转Word,PyMuPdf本身没有内置的pdf_to_docx方法,我常用的思路是:先去水印、再用转换工具(如Adobe、WPS在线转换或专门的text提取库)做转换。因为去水印后,版面已经干净了,转换工具识别的准确率会高很多。如果只需要把文字内容拿出来,用page.get_text("text")导出纯文本最方便;需要保留版面结构,才考虑转换工具。
从工程角度说,PyMuPdf在整个PDF处理流水线里的定位是整个链路的核心处理器,而不是唯一的工具。它负责把“脏”的PDF清洗干净,后续的格式转换、网页发布、合并拆分,都建立在干净文档之上。
最后的实用建议
处理水印之前,我会固定跑一个“先侦察、后动手”的流程:先抽两三页,用get_text("dict")、get_images()、get_drawings()把页面上疑似水印的bbox全部打印出来,确认水印的类型和分布,再写正式的删除脚本。水印的特征每个文件都不一样,有的是关键词,有的是固定区域,有的是透明度很低的矢量图形,盲写代码去删,十有八九会误伤正文。
PyMuPdf这套“解析-识别-红act-清理资源”的打法,处理常规文档水印已经足够顺滑。如果遇到扫描件水印、全页半透明背景水印这类硬骨头,也别硬扛,先判断它是不是“非删不可”,再决定要不要上OCR、图像修复这些额外手段。工具不是万能的,但把工具的边界摸清楚之后,你处理PDF水印这件事的效率,会比90%的人都要高。