完美的PyMuPDF删除pdf页面文字水印

Spire.PDF for Python在处理文字水印时非常强大,可以用正则表达式将页面上的文字任意修改为其他文字,但是如果不出钱,那就最多只能处理10页,而且处理过的文件保存时还会添加它自己的水印。我通常处理PDF文件不过是删除一些水印而已,水印也通常要么是图片水印,要么是文字水印,不需要Spire.PDF那么强大的功能,所以还是考虑免费的开源库。

到今天为止,所有免费开源库,只有PyMuPDF能够实现接近于Spire.PDF的删除文字水印效果。其它常见免费PDF处理库,pdfminer.six能够完成TJ之类PDF文件指令中文本的解析,却没法修改其中的文本,pikepdf能够从底层修改PDF页面流,但是不能解码TJ之类文本处理指令中的字符串,pdfplumber和pypdf2只能读取PDF文件,均不能满足需求。下面的程序在打开时要求选择待处理的PDF文件,然后显示菜单:

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

✅ 成功打开 PDF文件:E:/projects/python/pdftool/input.pdf,共 32 页

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5):

输入1,则会自动检测在1/3以上页面都出现过的位置和尺寸相同的疑似水印的图片并列表显示,供用户输入相应的编号予以删除:

🔢 请输入功能数字序号 (1-5): 1

✅ 发现以下位置和尺寸相同的疑似水印的图片在超过 1/3 的页面出现,请确认并选择项目删除:

👉 1: 图片左上角坐标(x,y):(24.1,0.0);图片宽度×高度:793.7 × 595.3(出现在 32 页)

🔢️ 请根据上面列表中的序号输入希望删除的图片对象 (请输入:1,无效输入将被跳过): 1

✅ 共删除 32 个图片水印

输入2,则会要求输入文字水印的正则表达式(输入的无通配符字符串会被当做正则表达式"^输入字符串\",如本例,输入"jimi",相当于输入了正则表达式"\^jimi\\"),并将PDF文件中匹配用户输入的正则表达式的字符串进行删除。如果包含文字水印的文本块较多,可以多次在显示主菜单时输入2,每次删除一处:

🔢 请输入功能数字序号 (1-5): 2

⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: jimi

✅ 删除16个文字水印。

输入3,则生成一份临时文件并用系统默认软件打开该临时文件:

️ 🔢 请输入功能数字序号 (1-5): 3

✅ 已打开文件temp.pdf,请预览修改效果,检查完毕请关闭预览文件,以便下次预览以及清理临时文件。

输入4,则打开保存文件对话框进行保存:

输入5,则删除临时文件,退出程序。

其中,达到类似Spie.PDF删除文字水印效果的要点在于给apply_redactions()传入参数images=fitz.PDF_REDACT_IMAGE_NONE:

page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)

全部代码如下:

python 复制代码
import keyword
import os
import string
import sys
import time
import tkinter as tk
from tkinter import filedialog
import fitz  # PyMuPDF
import re, random
from collections import defaultdict

temp_pdf = ''  # 用于预览的临时文件
doc = fitz.open() # 全局文档对象,初始为空

def main():
    global temp_pdf, doc
    if len(sys.argv) > 1:  # 运行程序时可以在参数中给出待处理文档路径
        pdf_path = sys.argv[1]
        try:
            doc = fitz.open(pdf_path)
            temp_pdf = os.path.join(os.path.dirname(pdf_path),
                                    f'{gen_temp_filename(8)}.pdf')
            print(f"✅ 成功打开 PDF,共 {len(doc)} 页\n")
        except Exception as e:
            print(f"❌ 打开 PDF 失败: {e},请重新选择有效文件。")
            load_src_file()
    else:   # 选择待处理文件并载入
        load_src_file()
    if not doc is None and len(doc) > 0:
        # 显示功能菜单并根据用户的选择作相应处理
        while True:
            print("\n🎗️ 请选择功能:")
            print("▶️ 1. 删除图片水印")
            print("▶️ 2. 删除文字水印")
            print("▶️ 3. 预览修改")
            print("▶️ 4. 保存修改")
            print("▶️ 5. 退出")

            choice = input("🔢 请输入功能数字序号 (1-5): ").strip()

            if choice == "1":   # 删图片水印
                remove_images_watermark()

            elif choice == "2":  # 删文字水印
                regex_str = input("⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: ").strip()
                remove_text_by_regex(regex_str)

            elif choice == "3":  # 预览
                preview_modifications()

            elif choice == "4":  # 保存处理结果
                save_modifications()

            elif choice == "5":  # 退出程序
                exit_program()
                break
            else:  # 用户选择功能时的输入非预期,重新显示功能菜单并要求输入
                print("❌ 无效选择,返回主菜单")

def gen_temp_filename(length : int) -> str:
    """
    使用字母、数字、下划线随机生成临时文件的文件名,不以数字开头并且避免使用Python关键字
    @param length: 主文件名长度
    """
    while True:
        filename = random.choice(string.ascii_letters + '_')  # 使用字母和下划线作为首字母
        for i in range(length - 1):  # 随机生成剩余字符补足指定长度,选择范围为字母、数字及下划线
            filename = filename + random.choice(string.ascii_letters + string.digits + '_')
        if not keyword.iskeyword(filename):  # 避免与关键字相同,否则重新生成
            break
    return filename  # 返回临时文件文件名

def flush_input() -> None:
    """
    清空输入缓冲区,适用于 Windows 和 Unix-like 系统
    如果用户在输入时,用复制多行内容粘贴的方式输入,会导致input函数一次不能消耗完全部输入
    可以考虑在input函数调用后调用这个函数清空输入缓冲区,避免多余输入干扰后续的input调用
    """
    time.sleep(0.5)  # 等待操作系统将输入装入缓冲区
    try:
        import msvcrt
        while msvcrt.kbhit():
            msvcrt.getch()
    except ImportError:
        import sys, termios
        termios.tcflush(sys.stdin, termios.TCIFLUSH)

def load_src_file() -> None:
    global doc, temp_pdf
    while True:
        # pdf_path = input("请输入 PDF 文件路径: ").strip()  # 使用input函数接收待处理文件路径
        # flush_input()
        print('⚒️ 请选择待处理的PDF文件...')  # 使用tkinter调用打开文件对话框
        root = tk.Tk()
        root.withdraw()
        # 隐藏主窗口时强烈建议配合对话框窗口置顶使用,否则有一定概率看/找不到打开文件对话框,导致程序无法继续
        root.attributes('-topmost', True)
        pdf_path = filedialog.askopenfilename(filetypes=[("PDF files", "*.pdf")])
        root.destroy()
        if len(pdf_path) > 0:
            try:
                doc = fitz.open(pdf_path)
                temp_pdf = os.path.join(os.path.dirname(pdf_path),
                                        f'{gen_temp_filename(8)}.pdf')
                print(f"✅ 成功打开 PDF文件:{pdf_path},共 {len(doc)} 页\n")
                break
            except Exception as e:
                print(f"❌ 打开 PDF 失败: {e},请重新选择有效文件。")
        else:
            print('❌ 看来你不想选择文件了,或者选择的文件没有有效页面,程序退出。')
            exit_program()
            break


def remove_images_watermark() -> None:
    global doc
    """
    自动检测每页中的图片,将在1/3以上页面中出现的尺寸相同的图片列表显示,
    将用户选择的列表中的项目予以删除
    """
    # 保存包含相同图片信息的页数的字典,图片信息为键,页数为值
    have_same_info_pages = defaultdict(int)
    total_pages = len(doc)

    for p in range(total_pages):
        page = doc[p]
        images_info = set()
        for img in page.get_images(full=True):
            xref = img[0]
            # 获取每页中的全部图片信息并去重
            for rect in page.get_image_rects(xref):
                left = round(rect.x0, 1)   # 左上角坐标x,保留一位小数
                top = round(rect.y0, 1)    # 左上角坐标y,保留一位小数
                w = round(rect.width, 1)   # 图片宽度,保留一位小数
                h = round(rect.height, 1)  # 图片高度,保留一位小数
                images_info.add((left, top, w, h))
        # 遍历图片信息集合,更新包含相同图片信息的页面的数量
        for info in images_info:
            have_same_info_pages[info] += 1

    # 将出现次数超过 1/3 的页面共有的位置和尺寸相同的图片视为疑似水印图片添加到疑似水印图片信息列表
    threshold = 0.33 * total_pages
    image_watermark_info = [info for info, cnt in have_same_info_pages.items() if cnt > threshold]

    if not image_watermark_info:
        print("✅ 没有找到超过 1/3 的页面共有的位置和尺寸相同的图片")
        return

    # 列出疑似水印图片列表
    print(f"✅ 发现以下位置和尺寸相同的疑似水印的图片在超过 1/3 的页面出现,请确认并选择项目删除:")
    i = 0
    for info in image_watermark_info:
        print(f"👉 {i + 1}: 图片左上角坐标(x,y):({info[0]},{info[1]});图片宽度×高度:"
                f"{info[2]} × {info[3]}(出现在 {have_same_info_pages[info]} 页)")
        i += 1
    # 要求用户从列表中选择要删除的图片
    choice = input(f"🔢️ 请根据上面列表中的序号输入希望删除的图片对象 ("
                    f"{'请输入:1' if i == 1 else '可输入序号范围:最小为1,最大为' + 
                        str(i) + ',多个选择用英文逗号分隔'},无效输入将被跳过): ").strip()
    choices = choice.split(",")
    # 根据用户的选择执行图片删除并记录删除的图片总数量
    total_removed = 0
    for ch in choices:
        try:
            idx = int(ch) - 1
            if 0 <= idx < i :
                info = image_watermark_info[idx]
                total_removed += remove_images_by_position_size(info[0], info[1], info[2], info[3], silent=True)
            else:
                print(f"❌ 输入的序号超出范围 '{ch}',跳过。")
        except Exception as e:
            print(f"❌ 无效输入 '{ch}',跳过。错误详情: {e}")

    if total_removed > 0:
        print(f"✅ 共删除 {total_removed} 个图片水印")
    else:
        print('❌ 未选择有效图片,没有删除任何对象。')


def remove_images_by_position_size(left : float, top : float,
            width : float, height : float, silent : bool = False) -> int:
    global doc
    """
    将指定位置和尺寸的图片作为水印删除
    """
    removed = 0
    for page in doc:
        for img in page.get_images(full=True):
            xref = img[0]
            for rect in page.get_image_rects(xref):
                # 位置和尺寸与给定位置与尺寸的误差小于0.1(主要考虑浮点数表示误差)的图片进行删除
                if (
                        abs(round(rect.x0, 1) - left) < 0.1
                        and abs(round(rect.y0, 1) - top) < 0.1
                        and abs(round(rect.width, 1) - width) < 0.1
                        and abs(round(rect.height, 1) - height) < 0.1
                ):# 删除相同位置和尺寸的图片。(rect.x0,rect.y0)为左上角坐标,(rect.x1,rect.y1)为右下角坐标。
                    page.delete_image(xref)  # 将图片数据直接删除
                    removed += 1

    if not silent:
        print(f"✅ 已删除 {removed} 个尺寸为 {width}×{height} 的水印")
    return removed


def remove_text_by_regex(regex_str : str) -> None:
    global doc
    """
    删除文本水印
    """
    removed = 0
    # 1. 编译正则表达式
    pattern = re.compile(regex_str)
    for page in doc:
        # 2. 获取页面上所有的词及其坐标
        # 每个 item 的格式: (x0, y0, x1, y1, "word", block_no, line_no, word_no)
        words = page.get_text("words")
        for w in words:
            if pattern.fullmatch(w[4]):  # 如果正则表达式完全匹配这个词则删除。一定程度上可以防止误删与水印文字相同的正文中的文字。
                # 获取这个词所占用的矩形
                text_instances = [fitz.Rect(w[0], w[1], w[2], w[3])]
                if text_instances:
                    # 3. 使用 add_redact_annot将词所占用的矩形标记为red action。
                    for rect in text_instances:
                        page.add_redact_annot(rect)
                        removed += 1

            # 4. apply_redactions 默认会抹除矩形内所有东西(包括路径、图片)
            # 传递参数images=fitz.PDF_REDACT_IMAGE_NONE后,
            # 如果水印是作为独立文本层存在的(几乎所有文本水印都是这样),
            # 它会尝试移除对应的文本序列,仅剔除文本层而不影响下层内容。
            page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)
    if removed > 0:
        print(f'✅ 删除{removed}个文字水印。')
    else:
        print('❌ 未找到匹配的文字水印,未删除任何对象。')


def preview_modifications(i : int = 0) -> None:
    global temp_pdf, doc
    try:
        if os.path.exists(temp_pdf):
            try:
                os.remove(temp_pdf)
            except Exception as e:
                if i < 3:  # 如果3次异常还没有成功预览,结束预览尝试,返回主菜单。
                    i += 1
                    input(f'❌ 预览失败: {e}。请先检查是否有打开的预览文件,如有则先关闭文件后按回车键再次尝试预览...')
                    preview_modifications(i)
                else:
                    print(f"❌ 预览失败: {e}")
                    return
        # 将中间结果保存为临时文件
        doc.save(temp_pdf, garbage=4, deflate=True, clean=True)
        # 使用系统默认软件打开临时文件
        os.startfile(temp_pdf)
        print(f"✅ 已打开文件{temp_pdf},请预览修改效果,预览完毕请关闭预览文件,以便下次预览以及清理临时文件。")
    except Exception as e:
        print(f"❌ 预览失败: {e}")
        return

def save_modifications(i : int = 0) -> None:
    global doc
    try:
        print('⚒️ 请选择保存路径...')
        # 1. 初始化 Tkinter
        root = tk.Tk()
        # 2. 隐藏主窗口(否则会有一个空白小框显示在屏幕上)
        root.withdraw()
        # 3. 确保窗口置顶,防止文件对话框被其他窗口遮挡
        root.attributes('-topmost', True)
        # 4. 弹出另存为对话框
        output_path = filedialog.asksaveasfilename(
            title="请选择保存位置",
            initialfile="output.pdf",  # 默认文件名
            defaultextension=".pdf",  # 注意:这里用 ".pdf" 而不是 "*.pdf"
            filetypes=[("PDF files", "*.pdf")]
        )
        # 5. 销毁实例,释放资源
        root.destroy()
        if len(output_path) > 0:
            if os.path.exists(output_path):
                os.remove(output_path)
            doc.save(output_path, garbage=4, deflate=True, clean=True)
            print(f"✅ 已保存处理后的文件: {output_path}")
        else:
            print('❌ 看来你不想保存文件了,或者没有选择有效的保存路径,返回主菜单。')
    except Exception as e:
        if i < 3:  # 如果3次异常还没有成功保存文件,结束保存尝试,返回主菜单。
            i += 1
            input(f'❌ 保存失败: {e}。请先检查是否有打开的输出文件,如有则先关闭文件后按回车键再次尝试保存...')
            save_modifications(output_path, i)
        else:
            print(f"❌ 保存失败: {e}")
            return

def exit_program(i : int = 0) -> None:
    global temp_pdf, doc
    # 清理资源并退出程序
    try:
        if os.path.exists(temp_pdf):
            os.remove(temp_pdf)
        if not doc is None:
            doc.close()
            doc = None
        print('😁 Bye.')
    except Exception as e:
        if i < 3:  # 如果3次异常还没有成功退出程序,结束尝试。
            i += 1
            input(f'❌ 退出程序时发生错误: {e}。请先检查是否有打开的预览文件,如有则先关闭文件后按回车键再次尝试退出...')
            exit_program(i)
        else:
            print(f"❌ 退出程序时发生错误: {e}")
            return

if __name__ == "__main__":
    main()

打开PowerShell,按下面的流程运行一次上面的程序,整个过程在PowerShell中的全部内容如下(仅将用户名替换为笑脸,包括输入错误都没改):

PS C:\Users\😁> E:\projects\python\pdftool\venv\Scripts\python.exe E:\projects\python\pdftool\pdf_cleaner.py 1

❌ 打开 PDF 失败: no such file: '1',请重新选择有效文件。

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

✅ 成功打开 PDF文件:E:/projects/python/pdftool/input.pdf,共 32 页

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 1

✅ 发现以下位置和尺寸相同的疑似水印的图片在超过 1/3 的页面出现,请确认并选择项目删除:

👉 1: 图片左上角坐标(x,y):(24.1,0.0);图片宽度×高度:793.7 × 595.3(出现在 32 页)

🔢️ 请根据上面列表中的序号输入希望删除的图片对象 (请输入:1,无效输入将被跳过): 1

✅ 共删除 32 个图片水印

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 2

⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: jimi

✅ 删除16个文字水印。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): .*教育.*

❌ 无效选择,返回主菜单

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 2

⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: .*教育.*

✅ 删除64个文字水印。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 2

⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: 通关热线.*

✅ 删除32个文字水印。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 3

✅ 已打开文件E:/projects/python/pdftool\msGxUgll.pdf,请预览修改效果,预览完毕请关闭预览文件,以便下次预览以及清理临时文件。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 2

⌨️ 请输入要删除的文字或用于匹配要删除的文字的正则表达式: 13716166208

✅ 删除32个文字水印。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 3

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

✅ 已打开文件E:/projects/python/pdftool\msGxUgll.pdf,请预览修改效果,预览完毕请关闭预览文件,以便下次预览以及清理临时文件。

✅ 已打开文件E:/projects/python/pdftool\msGxUgll.pdf,请预览修改效果,预览完毕请关闭预览文件,以便下次预览以及清理临时文件。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 4

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

❌ 看来你不想保存文件了,或者没有选择有效的保存路径,返回主菜单。

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 4

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

✅ 已保存处理后的文件: E:/projects/python/pdftool/一建公共案例.pdf

🎗️ 请选择功能:

▶️ 1. 删除图片水印

▶️ 2. 删除文字水印

▶️ 3. 预览修改

▶️ 4. 保存修改

▶️ 5. 退出

🔢 请输入功能数字序号 (1-5): 5

😁 Bye.

"input.pdf"和"一建公共案例.pdf"的对比(仅显示前两页):

input.pdf:

一建公共案例.pdf:

可以看到,第一个图片中的水印已经全部删除了。

真要给PDF文件加水印,看来只能在加完水印后将PDF文件页面导出为图片,然后再用图片重新组合成PDF文件才会比较难消除了。😁

相关推荐
weixin_433179332 小时前
python - 读写文件
开发语言·python
Astro_ChaoXu2 小时前
GAMSE使用日志与教程(高分辨率光谱数据缩减)
linux·数据库·python
人工智能培训2 小时前
基于知识图谱的故障推理方法与算法
人工智能·python·深度学习·机器学习·知识图谱·故障诊断
ID_180079054733 小时前
超详细:Python 调用淘宝商品详情 API 完整教程
开发语言·python
平常心cyk3 小时前
Python基础快速复习——函数的多种传参方式
python
lanboAI3 小时前
基于卷积神经网络的舌苔诊断系统,resnet50,alexnet, shufflenet模型【pytorch框架+python源码】
pytorch·python·cnn
QWsin3 小时前
【Pydantic】Pydantic 是什么?
python
WeeJot嵌入式3 小时前
爬虫对抗:ZLibrary反爬机制实战分析
爬虫·python·网络安全·playwright·反爬机制
Bert.Cai3 小时前
Python input函数作用
开发语言·python