尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

Python+PyMuPDF批量删除PDF水印:文本、图片、矢量一次搞定 收到一份PDF打开一看每一页右下角都压着“内部资料·请勿外传”的半透明水印想打印出来开会又不想让人看到这个“内部”字样想直接发给合作方又怕显得很不专业。手动删几十页文件一页一页去水印费时间不说还经常按错键把排版弄乱。这篇博文要解决的就是这个痛点用Python的PyMuPdf本地库批量处理PDF文档水印不需要在线工具也不用手动一页一页操作。PyMuPdf是MuPDF渲染引擎的Python绑定对PDF底层结构的控制能力非常强。它不仅能提取每页文本块、文字内容、bbox坐标、矢量图元还能对页面内容做删除、覆盖、重建这类高级操作。我围绕“删除水印”这条主线把水印最常见的四种存在形态拆开讲清楚再给出可直接复现的检测与删除代码。文本水印、图片水印、矢量水印以及页眉页脚处的固定水印都会覆盖到。读完你就能自己写一个批量去水印脚本扔给任意一批PDF也能快速定位问题。1. 为什么选择PyMuPdf水印处理的底层逻辑1.1 PyMuPdf的核心优势水印本质上就是“被刻意加到页面上、却又不希望被当作正文的内容”。要删除它前提是能精确识别它、定位它然后把它所在的页面区域或资源引用拿掉。这就对PDF工具库提出了很具体的要求既要能读取页面对象又要能修改页面内容。我对比过不少Python侧的PDF库各有取舍库文本提取图形/图片解析内容删除内容流重建上手难度PyMuPdffitz强支持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(fullTrue)获取页面引用的图片资源列表。返回的是图片的资源信息包含xref编号、宽高、色彩空间等。注意它返回的是“页面用到哪些图片资源”但不会直接给出每张图片绘制在页面的哪个位置。要拿位置需要配合page.get_image_rects(xref)page doc[0] for img in page.get_images(fullTrue): 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资源表中的编号同一个图片资源可能被多个页面引用。如果某张图片在每一页都出现且位置固定那它很可能就是水印或者页眉页脚图片。图片水印经常是半透明PNGPyMuPdf拿到的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_opacity0.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()是更通用的方案。举个例子水印在每页右下角固定出现在x0450, y0750, x1550, y1800这个矩形内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(fullTrue): 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, garbage3, deflateTrue)能清除无引用的资源但页面上实际绘制了图片的区域并不会因此消失。也就是说别指望光靠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扫描全页直接对顶部区域和底部区域执行redactpage_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(dpi200) pix.save(page.png)拿到PNG后再用OpenCV做水印区域检测和修复也是常见组合。但这件事的成败极度依赖水印样式和背景复杂度不能保证通用。我的建议是扫描件去水印优先考虑原文件或双层PDF如果只能处理扫描件把问题拆成“水印是否单色、位置是否固定、背景是否均匀”再决定用阈值分离还是用修复算法。4.3 四个必踩的坑与规避方法我把自己踩过、也看别人踩过的坑列一份速查表坑现象规避方法没备份就覆盖保存删错后原文件没了输出到新文件名确认无误再替换redact矩形画太大正文、图片一块被删先用get_text(dict)打印所有bbox肉眼核对关键词误判正文中的某个词被当成水印删除加“跨页出现次数”和“字号/颜色”双重判断加密PDF打不开文件打开报错、权限受限用doc.needs_pass和doc.authenticate()处理保存后文件异常变大冗余资源堆积doc.save(out.pdf, garbage4, deflateTrue)其中garbage参数值得单独说一下。garbage3或garbage4会做更激进的垃圾回收把页面内容中不再引用的字体、图片对象清理掉。执行大量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, garbage4, deflateTrue) doc.close() return removed_count base_dir pathlib.Path(pdfs) out_dir pathlib.Path(output) out_dir.mkdir(exist_okTrue) 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_pdfmerged 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转WordPyMuPdf本身没有内置的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%的人都要高。
返回列表