使用PyMuPDF基于对PDF文档内容的分析自动识别并删除PDF文件中的水印

PyMuPDF可以以各种格式检索页面内容,通过对解析出的内容的特征的分析,可以比较准确地找到疑似水印,从而予以删除。其对页面内容的解析具体通过下面的方法进行:

Page.get_text(option , * , clip=None , flags=None , textpage=None , sort=False , delimiters=None)

关于此方法参数的描述可以参看官方文档。当option参数设定为"dict"时,返回值是如下形式的字典:

复制代码
{
    "width": 595.0,                    # 页面宽度
    "height": 842.0,                   # 页面高度
    "blocks": [
        {                                      # 文本block示例
            "number": 0,
            "type": 0,                         # 0=文本,1=图像
            "bbox": [x0, y0, x1, y1],
            "lines": [
                {
                    "bbox": [x0, y0, x1, y1],  # 边界框 (x0, y0, x1, y1)
                    "dir": (cosθ, sinθ),       # 文本方向
                    "spans": [
                        {
                            "text": "实际的文本内容",
                            "font": "字体名称",
                            "size": 字体大小,
                            "color": 颜色值,
                            "flags": 字体标志,
                            "bbox": [x0, y0, x1, y1]
                        },
                        {
                            更多 span...
                        },
                    ]
                },
                {
                    更多 line...
                },
            ]
        },
        {                             # 图像block示例
            "type": 1,                 # 类型:0=文本块,1=图像块
            "bbox": (50.0, 100.0, 150.0, 200.0),  # 边界框 (x0, y0, x1, y1)
            "image": b'...',           # 图像二进制数据(bytes)
            "ext": "png",              # 图像扩展名
            "width": 100,              # 图像宽度(像素)
            "height": 100,             # 图像高度(像素)
            "colorspace": 3,           # 颜色空间:1=灰度,3=RGB,4=CMYK
            "xres": 72,                # 水平分辨率
            "yres": 72,                # 垂直分辨率
            "cs-name": "DeviceRGB",    # 颜色空间名称
            "transform": (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)  # 变换矩阵
        },
        {
            更多 block...
        },
    ]
}

到目前为止,返回的blocks数组中的成员的"type"只有两种情况:0和1,分别代表文本内容和图片内容。一般说来,水印都会在多个页面重复出现。通过对每个页面中的每个block的分析,可以很容易找出许多页面都有的内容相同的图片和文本,然后就可以删除这种图片和文本。

但是,在删除图像时,如果用如下方式:

复制代码
Page.apply_redactions(images=0)

或者给images参数赋其他值(默认值为2, 会涂黑重叠的像素。PDF_REDACT_IMAGE_NONE | 0 表示忽略,PDF_REDACT_IMAGE_REMOVE | 1 表示完全移除与任何密文标注重叠的图像。选项 PDF_REDACT_IMAGE_REMOVE_UNLESS_INVISIBLE | 3 仅移除实际可见的图像。),都会对页面上的其他内容造成影响,要想完全删除图片,应该用:

Page.replace_image(xref , filename=None , pixmap=None , stream=None)

或它的简化形式:

Page.delete_image(xref)

其中,参数xref是交叉引用号的缩写,这是 PDF 中对象的唯一整数标识符。每个 PDF 中都存在一个交叉引用表(其物理上可能由几个独立的段组成),用于存储每个对象的相对位置以便快速查找。交叉引用表比现有对象的数量长一项:第零项是保留的,不得以任何方式使用。许多 PyMuPDF 类都有一个 xref(xref)属性(对于非 PDF 文件,该属性为零),可以通过 Document.xref_length() - 1 获取 PDF 中对象的总数。

但是,Page.get_text("dict",flags=pymupdf.TEXT_PRESERVE_IMAGES) 方法返回的字典中没有记录图片对象的xref。**Page.get_images(full=True)**方法返回的列表中有xref,但并没有直接包含图片的二进制数据,其返回结果的详细说明如下:

复制代码
[(xref, smask, width, height, bpc, colorspace, alt.colorspace, name, filter, ...)]
其中没有图像数据,需要通过doc.extract_image(xref)来获取图像数据和属性
    - xref: 图像资源的xref编号
    - smask: 是否为遮罩图像
    - width: 图像宽度(像素)
    - height: 图像高度(像素)
    - bpc: 每个颜色分量的位数
    - colorspace: 颜色空间类型(1=灰度,3=RGB,4=CMYK)
    - alt.colorspace: 替代颜色空间(如果存在)
    - name: 图像资源名称(如果存在)
    - filter: 图像使用的过滤器(如DCTDecode、FlateDecode等)
例如:
image_list = page.get_images(full=True)
for image in image_list:
    xref = image[0]
    image_info = self.doc.extract_image(xref)
    img_data = image_info["image"]  # 图像二进制数据
    img_width = image_info["width"]  # 图像宽度
    img_height = image_info["height"]  # 图像高度  

正如上面的说明中指出的,取得xref后,可以通过

Document.extract_image(xref)["image"]

获取图片的二进制数据,将这个二进制数据与Page.get_text("dict",flags=pymupdf.TEXT_PRESERVE_IMAGES) 方法返回的字典中的二进制数据比较,可以查到该方法返回的图片的xref,从而顺利通过**Page.delete_image(xref)**删除图片而不对页面的排版和阅读造成影响。

下面就是按上述思路完成的自动检测并通过与用户的交互删除水印的完整程序:

python 复制代码
import os
import sys
import hashlib
import json
from collections import defaultdict
import tkinter as tk
from tkinter import filedialog

import pymupdf


class PDFWatermarkRemover:
    def __init__(self, pdf_path):
        self.pdf_path = pdf_path
        self.doc = pymupdf.open(pdf_path)
        self.image_hashes = defaultdict(list)
        self.text_hashes = defaultdict(list)
        self.watermark_threshold = 2

    def extract_content_hashes(self):
        """提取所有页面内容的哈希值"""
        total_pages = len(self.doc)
        for page_num, page in enumerate(self.doc):
            # 图像处理
            image_list = page.get_images(full=True)
            image_xref_map = {}
            for image in image_list:
                xref = image[0]
                try:
                    image_info = self.doc.extract_image(xref)
                    if image_info:
                        img_data = image_info["image"]
                        img_hash = hashlib.md5(img_data).hexdigest()
                        image_xref_map[xref] = {
                            "hash": img_hash,
                            "width": image_info["width"],
                            "height": image_info["height"]
                        }
                except:
                    continue

            # 文本+图像字典
            text_dict = page.get_text("dict", flags=pymupdf.TEXT_PRESERVE_IMAGES | pymupdf.TEXTFLAGS_TEXT)

            for block in text_dict.get("blocks", []):
                if block.get("type") == 1:  # 图像块
                    img_data = block.get("image")
                    if img_data:
                        img_hash = hashlib.md5(img_data).hexdigest()
                        matched_xref = None
                        width, height = (0, 0)
                        for xref, img_info in image_xref_map.items():
                            if img_info["hash"] == img_hash:
                                matched_xref = xref
                                width = img_info["width"]
                                height = img_info["height"]
                                break

                        self.image_hashes[img_hash].append({
                            "page": page_num + 1,  # 按人类习惯页码从1开始
                            "block": block,
                            "bbox": block.get("bbox"),
                            "xref": matched_xref,
                            "size": len(img_data),
                            "width": width,
                            "height": height
                        })

                elif block.get("type") == 0:  # 文本块
                    block_text = self._extract_text_from_block(block)
                    if block_text.strip():
                        text_hash = hashlib.md5(block_text.encode('utf-8')).hexdigest()
                        self.text_hashes[text_hash].append({
                            "page": page_num + 1,  # 按人类习惯页码从1开始
                            "block": block,
                            "bbox": block.get("bbox"),
                            "text": block_text,
                            "length": len(block_text)
                        })

                # 添加进度条打印
                progress = (page_num + 1) / total_pages * 100
                bar_length = 50  # 进度条长度
                filled_length = int(bar_length * (page_num + 1) // total_pages)
                bar = '#' * filled_length + '-' * (bar_length - filled_length)
                sys.stdout.write(f'\r进度: [{bar}] {progress:.1f}% ({page_num + 1}/{total_pages})')
                sys.stdout.flush()
        print(f"水印分析完成...")

    def _extract_text_from_block(self, block):
        text_lines = []
        for line in block.get("lines", []):
            line_text = "".join(span.get("text", "") for span in line.get("spans", []))
            text_lines.append(line_text)
        return "\n".join(text_lines)

    def identify_watermarks(self):
        """识别水印内容"""

        watermark_images = {}
        watermark_texts = {}

        for img_hash, instances in self.image_hashes.items():
            page_set = set(inst["page"] for inst in instances)
            if len(page_set) >= self.watermark_threshold:
                watermark_images[img_hash] = {
                    "count": len(instances),
                    "pages": sorted(list(page_set)),
                    "instances": instances
                }

        for text_hash, instances in self.text_hashes.items():
            page_set = set(inst["page"] for inst in instances)
            if len(page_set) >= self.watermark_threshold:
                watermark_texts[text_hash] = {
                    "count": len(instances),
                    "pages": sorted(list(page_set)),
                    "instances": instances
                }

        print(f"水印识别结果: 图像 {len(watermark_images)} 个,文本 {len(watermark_texts)} 个")
        return watermark_images, watermark_texts

    def remove_watermarks(self):
        """交互式逐条删除水印"""
        watermark_images, watermark_texts = self.identify_watermarks()
        if len(watermark_images) == 0 and len(watermark_texts) == 0:
            print("❌未发现疑似水印,程序退出。如果您确认文件中有水印,可以尝试降低水印识别阈值后重新分析或使用其他软件删除水印。")
            sys.exit(0)

        print("\n" + "=" * 60)
        print("⚒️开始交互式水印删除")
        print("=" * 60)

        # 处理图像水印
        if watermark_images:
            print(f"\n发现 {len(watermark_images)} 个图像水印:")
            for img_hash in list(watermark_images.keys()):
                info = watermark_images[img_hash]
                print('-' * 60)
                print(f"\n图像水印 - 哈希: {img_hash[:16]}...")
                print(f"  出现次数: {info['count']} 次")
                print(f"  出现页面: {info['pages']}")
                print(f"  尺寸: {info['instances'][0]['width']}×{info['instances'][0]['height']}")

                print('-' * 60)
                choice = input("是否删除此图像水印?(y/n): ").strip().lower()
                if choice == 'y':
                    self._remove_single_image_watermark(info)
                    print("✓ 已删除")
                else:
                    print("跳过")
                # 从字典中移除(无论是否删除)
                del watermark_images[img_hash]

        # 处理文本水印
        if watermark_texts:
            print(f"\n发现 {len(watermark_texts)} 个文本水印:")
            for text_hash in list(watermark_texts.keys()):
                info = watermark_texts[text_hash]
                sample = info['instances'][0]['text'][:60] + "..." if len(info['instances'][0]['text']) > 60 else \
                info['instances'][0]['text']
                print('-' * 60)
                print(f"\n文本水印 - 哈希: {text_hash[:16]}...")
                print(f"  文本内容: {sample}")
                print(f"  出现次数: {info['count']} 次")
                print(f"  出现页面: {info['pages']}")

                print('-' * 60)
                choice = input("是否删除此文本水印?(y/n): ").strip().lower()
                if choice == 'y':
                    self._remove_single_text_watermark(info)
                    print("✓ 已删除")
                else:
                    print("跳过")
                del watermark_texts[text_hash]

        choice = input("水印处理完成!是否保存处理后的文件?(y/n): ").strip().lower()
        if choice == 'y':
            self.save_modifications()

    def _remove_single_image_watermark(self, info):
        """删除单个图像水印"""
        xrefs_to_remove = {inst.get("xref") for inst in info["instances"] if inst.get("xref") }
        for page in self.doc:
            for xref in xrefs_to_remove:
                try:
                    page.delete_image(xref)
                except Exception:
                    pass  # 忽略删除失败(可能已被删除)

    def _remove_single_text_watermark(self, info):
        """删除单个文本水印"""
        text_bboxes = defaultdict(list)
        for instance in info["instances"]:
            page_num = instance["page"] - 1  # instance["page"] 是从1开始的页码,需要转换为0开始的索引
            bbox = instance["bbox"]
            text_bboxes[page_num].append(bbox)

        for page_num, bboxes in text_bboxes.items():
            if page_num < len(self.doc):
                page = self.doc[page_num]
                for bbox in bboxes:
                    try:
                        rect = pymupdf.Rect(bbox)
                        page.add_redact_annot(rect)
                        page.apply_redactions(images=0)  # 仅删除文本,不影响图像
                    except Exception:
                        pass

    def save_modifications(self, i=0) -> None:
        try:
            print('⚒️ 请选择保存路径...')
            # 1. 初始化 Tkinter
            root = tk.Tk()
            # 2. 隐藏主窗口(否则会有一个空白小框显示在屏幕上)
            root.withdraw()
            # 3. 确保窗口置顶,防止文件对话框被其他窗口遮挡
            root.attributes('-topmost', True)
            # 4. 弹出另存为对话框
            output_file = filedialog.asksaveasfilename(
                title="请选择保存位置",
                initialfile="output.pdf",  # 默认文件名
                defaultextension=".pdf",  # 注意:这里用 ".pdf" 而不是 "*.pdf"
                filetypes=[("PDF files", "*.pdf")]
            )
            # 5. 销毁实例,释放资源
            root.destroy()
            if len(output_file) > 0:
                if os.path.exists(output_file):
                    os.remove(output_file)

                self.replace_broken_obj_with_empty()
                self.doc.save(output_file, garbage=4, deflate=True, clean=True)
                print(f"✅ 已保存处理后的文件: {output_file}")
                os.startfile(output_file)
            else:
                print('❌ 没有选择有效的保存路径,无法保存!')
                self.close()
        except Exception as e:
            if i < 3:  # 如果3次异常还没有成功保存文件,结束保存尝试,返回主菜单。
                i += 1
                input(f'❌ 保存失败: {e}。请先检查是否有打开的输出文件,如有则先关闭文件后按回车键再次尝试保存...')
                self.save_modifications(i)
            else:
                print(f"❌ 保存失败: {e}")
                self.close()
                return

    def replace_broken_obj_with_empty(self):
        """
           保存前手动遍历交叉引用表,以识别并更新损坏的对象,避免发生:
           MuPDF error: format error: cannot find object in xref (......)
        """
        for xref in range(1, self.doc.xref_length()):
            try:
                _ = self.doc.xref_object(xref)
            except:
                self.doc.update_object(xref, "<<>>")

    def generate_report(self, report_path=None):
        """生成水印分析报告(保持不变)"""
        watermark_images, watermark_texts = self.identify_watermarks()
        # ...(报告生成代码保持原样)
        report = {
            "pdf_file": self.pdf_path,
            "total_pages": len(self.doc),
            "watermark_threshold": self.watermark_threshold,
            "image_watermarks": [],
            "text_watermarks": [],
            "summary": {
                "total_image_watermarks": len(watermark_images),
                "total_text_watermarks": len(watermark_texts),
                "total_watermark_instances": (
                        sum(len(info["instances"]) for info in watermark_images.values()) +
                        sum(len(info["instances"]) for info in watermark_texts.values())
                )
            }
        }

        for img_hash, img_info in watermark_images.items():
            report["image_watermarks"].append({
                "hash": img_hash[:16] + "...",
                "count": img_info["count"],
                "pages": img_info["pages"],
                "size_bytes": img_info["instances"][0]["size"] if img_info["instances"] else 0,
                "width": img_info["instances"][0]["width"],
                "height": img_info["instances"][0]["height"],
            })

        for text_hash, text_info in watermark_texts.items():
            sample_text = text_info["instances"][0]["text"][:50] + "..." if text_info["instances"] else ""
            report["text_watermarks"].append({
                "hash": text_hash[:16] + "...",
                "count": text_info["count"],
                "pages": text_info["pages"],
                "sample_text": sample_text,
                "text_length": text_info["instances"][0]["length"] if text_info["instances"] else 0
            })

        if report_path:
            with open(report_path, 'w', encoding='utf-8') as f:
                json.dump(report, f, ensure_ascii=False, indent=2)

        return report

    def close(self):
        try:
            if self.doc:
                self.doc.close()
        except (ValueError, AttributeError):  # 文档已关闭或 self.doc 不存在
            pass


# 使用示例
if __name__ == "__main__":
    if len(sys.argv) > 1:
        pdf_file = sys.argv[1]
        output_path = sys.argv[2] if len(sys.argv) > 2 else None
    else:
        print('⚒️ 请选择待处理的PDF文件...')  # 使用tkinter调用打开文件对话框
        root = tk.Tk()
        root.withdraw()
        # 隐藏主窗口时强烈建议配合对话框窗口置顶使用,否则有一定概率看/找不到打开文件对话框,导致程序无法继续
        root.attributes('-topmost', True)
        pdf_file = filedialog.askopenfilename(filetypes=[("PDF files", "*.pdf")])
        root.destroy()

    if len(pdf_file) > 0:
        print(f'已打开文件:{pdf_file}')
        remover = None
        try:
            print("=" * 60)
            print("PDF水印检测与交互式删除工具")
            print("=" * 60)

            remover = PDFWatermarkRemover(pdf_file)
            print('-' * 60)
            try:
                threshold = int(
                    input(
                        '希望将出现在多少页面的相同内容判定为水印?请输入正整数(输入值超过页数或不是正整数视为所有页面): ').strip()
                )
                remover.watermark_threshold = min(max(0, threshold), len(remover.doc))
            except Exception as e:
                print(f"输入无效,视为全部页面。")
                remover.watermark_threshold = len(remover.doc)
            print('-' * 60)
            remover.extract_content_hashes()

            # 执行交互式删除
            remover.remove_watermarks()

            # 生成报告
            remover.generate_report("watermark_report.json")

        except Exception as e:
            print(f"处理出错: {e}")
            import traceback

            traceback.print_exc()
        finally:
            if 'remover' in locals():
                remover.close()
    else:
        print('❌ 没有选择有效的PDF文件,程序结束!')

上述代码为提高效率,比较图片和文本时都是通过计算其哈希值来比较的,将超过一定阈值比例的页面都有的相同内容标记为疑似水印,并根据用户的选择决定是否作为水印最终删除。当然,判定疑似水印的标准还可以采取位置和尺寸相同、透明度较高、旋转角度较大等等方式,这都可以通过从blocks中获取相关数据来实现,这些都可以留待日后完善。以下是上述程序一次试运行的过程:

⚒️ 请选择待处理的PDF文件...

已打开文件:E:/projects/pdf_watermark_remover/example.pdf

============================================================

PDF水印检测与交互式删除工具

============================================================


希望将出现在多少页面的相同内容判定为水印?请输入正整数(输入值超过页数或不是正整数视为所有页面): 5


进度: [##################################################] 100.0% (32/32)水印分析完成...

水印识别结果: 图像 1 个,文本 6 个

============================================================

⚒️开始交互式水印删除

============================================================

发现 1 个图像水印:


图像水印 - 哈希: c7dff325ea962d2a...

出现次数: 32 次

出现页面: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]

尺寸: 640×480


是否删除此图像水印?(y/n): y

✓ 已删除

发现 6 个文本水印:


文本水印 - 哈希: 3737a7c65c63ade7...

文本内容: 2022-7-21

出现次数: 32 次

出现页面: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]


是否删除此文本水印?(y/n): y

✓ 已删除


文本水印 - 哈希: c4ca4238a0b92382...

文本内容: 1

出现次数: 14 次

出现页面: [0, 12, 14, 15, 16]


是否删除此文本水印?(y/n): n

跳过


文本水印 - 哈希: ca636bd2df9b9c78...

文本内容: 慧嘉森教育 ...

出现次数: 32 次

出现页面: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]


是否删除此文本水印?(y/n): y

✓ 已删除


文本水印 - 哈希: 5106958e8f88395e...

文本内容: (备注:内部资料,版权属于慧嘉森教育,未经许可不得复制外传)

出现次数: 32 次

出现页面: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]


是否删除此文本水印?(y/n): y

✓ 已删除


文本水印 - 哈希: c81e728d9d4c2f63...

文本内容: 2

出现次数: 14 次

出现页面: [1, 12, 14, 15, 16]


是否删除此文本水印?(y/n): n

跳过


文本水印 - 哈希: eccbc87e4b5ce2fe...

文本内容: 3

出现次数: 14 次

出现页面: [2, 12, 14, 15, 16]


是否删除此文本水印?(y/n): n

跳过

水印处理完成!是否保存处理后的文件?(y/n): y

⚒️ 请选择保存路径...

❌ 保存失败: [WinError 32] 另一个程序正在使用此文件,进程无法访问。: 'E:/projects/python/pdftool/output.pdf'。请先检查是否有打开的输出文件,如有则先关闭文件后按回车键再次尝试保存...

⚒️ 请选择保存路径...

✅ 已保存处理后的文件: E:/projects/python/pdftool/input_clean.pdf

进程已结束,退出代码为 0

相关推荐
Allen_LVyingbo1 小时前
面向医疗群体智能的协同诊疗与群体决策支持系统(下)
开发语言·数据结构·windows·python·动态规划
于先生吖1 小时前
家政派单小程序源头开发厂家
python
SunnyDays10111 小时前
如何使用 Python 删除 Word 文档空白行(含批量处理)
python·删除word文档空白行
众生皆苦,我是红豆奶茶味1 小时前
【工具】Codex 配置文件速查笔记(截至 2026 年 05 月 09 日)
人工智能·笔记·python·深度学习·神经网络
仅此,1 小时前
vscode 启动项目时,设置 PYTHONPATH 导包路径
ide·vscode·python·编辑器
tanis_20771 小时前
PDF 解析后输出什么格式?MinerU 五类下游场景的选型指南
人工智能·pdf·csdn开发云
于先生吖2 小时前
家政派单小程序哪家好
python
HappyAcmen2 小时前
15.json文件读取与写入
开发语言·python
测试员周周10 小时前
【AI测试智能体】为什么传统测试方法对智能体失效?
开发语言·人工智能·python·功能测试·测试工具·单元测试·测试用例