news 2026/9/11 3:15:34

Python+PyMuPDF批量删除PDF水印:文本、图片、矢量一次搞定

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python+PyMuPDF批量删除PDF水印:文本、图片、矢量一次搞定

收到一份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_passdoc.authenticate()处理
保存后文件异常变大冗余资源堆积doc.save("out.pdf", garbage=4, deflate=True)

其中,garbage参数值得单独说一下。garbage=3garbage=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%的人都要高。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 3:15:17

ProcessHacker 硬件监控完全指南

ProcessHacker 硬件监控完全指南 【免费下载链接】systeminformer A free, powerful, multi-purpose tool that helps you monitor system resources, debug software and detect malware. Brought to you by Winsider Seminars & Solutions, Inc. https://windows-intern…

作者头像 李华
网站建设 2026/9/11 3:15:08

港口车辆双模定位与智能调度系统实践

1. 港口车辆管理现状与痛点分析 港口作为物流枢纽&#xff0c;每天有大量集装箱卡车、叉车、牵引车等特种车辆在有限区域内高频作业。传统管理方式主要依赖人工调度和纸质记录&#xff0c;存在三大核心痛点&#xff1a; 实时定位缺失 &#xff1a;调度中心无法掌握车辆精确位…

作者头像 李华
网站建设 2026/9/11 3:15:01

系统仿真体系化:直流调速模型从开环到单闭环的复用之路

做过系统仿真的人&#xff0c;多多少少都有过一种无力感&#xff1a;模型搭了不少&#xff0c;仿真报告写了一大摞&#xff0c;可真到下一次产品改型时&#xff0c;能直接拿来用的东西没几样。新来的同事接项目&#xff0c;头三个月基本在“考古”&#xff0c;翻历史模型、猜参…

作者头像 李华
网站建设 2026/9/11 3:11:44

智能体工作流引擎的设计与实现:从DAG调度到节点编排实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 3:03:49

大功率电机控制器PCB设计:解析回路布局与共模辐射抑制关键点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华