用 PyMuPDF 和 Pillow 打造 PDF 超级工具

发布于:2025-02-21 ⋅ 阅读:(13) ⋅ 点赞:(0)

前几天完成《 AI 时代,如何用 Python 脚本轻松搞定 PDF 需求?》,有朋友反馈使用到 poppler 这个工具还是有点麻烦,能不能就安装 python 库就可以解决,我让 AI 根据需求,重写了一版本,包括处理 PDF 页面、合并 PDF、提取图片、加密和解密 PDF 文件。 

图片

依赖库

要使用这个工具,您需要安装以下 Python 库: 

PyMuPDF (fitz) 

Pillow (PIL) 

您可以使用以下命令安装这些库: 

pip install PyMuPDF Pillow

 

功能概述

  • 处理 PDF:选择特定页面并转换为图片或新的 PDF
  • 合并 PDF:将多个 PDF 文件合并为一个
  • 提取图片:从 PDF 中提取图片
  • 加密 PDF:为 PDF 文件添加密码保护
  • 解密 PDF:移除 PDF 文件的密码保护

 

代码

import argparse
import os
import fitz  # PyMuPDF
import io
from PIL import Image

def try_remove_pdf_password(input_path, password=None):
    doc = fitz.open(input_path)
    if doc.is_encrypted:
        if password is None:
            # 尝试无密码解密
            if doc.authenticate(""):
                print("PDF文件已成功解密(无需密码)")
                return input_path
            else:
                raise ValueError("PDF文件已加密,需要密码")
        else:
            if doc.authenticate(password):
                output_path = input_path.replace('.pdf', '_decrypted.pdf')
                doc.save(output_path)
                print(f"已解密的PDF保存到: {output_path}")
                return output_path
            else:
                raise ValueError("提供的密码不正确")
    else:
        print("PDF文件未加密")
        return input_path

def parse_page_ranges(page_range, total_pages):
    if not page_range:
        return list(range(1, total_pages + 1))  # 如果没有指定页码,返回所有页面

    pages = set()
    ranges = page_range.split(',')
    for r in ranges:
        if '-' in r:
            start, end = map(int, r.split('-'))
            pages.update(range(start, min(end + 1, total_pages + 1)))
        else:
            page = int(r)
            if 1 <= page <= total_pages:
                pages.add(page)
    return sorted(list(pages))

def process_pdf(input_path, page_range, output_path, dpi=300, split_pages=False, password=None):
    doc = fitz.open(input_path)
    if doc.is_encrypted:
        if not doc.authenticate(password):
            raise ValueError("密码不正确")

    total_pages = len(doc)
    pages_to_process = parse_page_ranges(page_range, total_pages)

    _, file_extension = os.path.splitext(output_path)
    output_format = file_extension[1:].lower()

    if output_format in ['jpg', 'jpeg', 'png']:
        zoom = dpi / 72  # 默认 DPI 为 72
        mat = fitz.Matrix(zoom, zoom)

        if split_pages:
            base_name, ext = os.path.splitext(output_path)
            for page_num in pages_to_process:
                page = doc[page_num - 1]
                pix = page.get_pixmap(matrix=mat, alpha=False)
                img = Image.open(io.BytesIO(pix.tobytes()))
                page_output_path = f"{base_name}_{page_num}{ext}"
                img.save(page_output_path)
                print(f"输出文件已保存到: {page_output_path}")
        else:
            images = []
            for page_num in pages_to_process:
                page = doc[page_num - 1]
                pix = page.get_pixmap(matrix=mat, alpha=False)
                img = Image.open(io.BytesIO(pix.tobytes()))
                images.append(img)

            if len(images) == 1:
                images[0].save(output_path)
            else:
                # 计算总高度和最大宽度
                total_height = sum(img.height for img in images)
                max_width = max(img.width for img in images)

                # 如果总高度超过限制,分割图像
                max_height = 65000  # PIL的最大支持高度
                if total_height > max_height:
                    parts = []
                    current_height = 0
                    current_part = []
                    for img in images:
                        if current_height + img.height > max_height:
                            parts.append(current_part)
                            current_part = [img]
                            current_height = img.height
                        else:
                            current_part.append(img)
                            current_height += img.height
                    if current_part:
                        parts.append(current_part)

                    # 保存每个部分
                    base_name, ext = os.path.splitext(output_path)
                    for i, part in enumerate(parts):
                        part_height = sum(img.height for img in part)
                        combined_img = Image.new('RGB', (max_width, part_height), (255, 255, 255))
                        y_offset = 0
                        for img in part:
                            combined_img.paste(img, (0, y_offset))
                            y_offset += img.height
                        part_output_path = f"{base_name}_part{i + 1}{ext}"
                        combined_img.save(part_output_path)
                        print(f"输出文件(部分 {i + 1})已保存到: {part_output_path}")
                else:
                    # 如果总高度没有超过限制,按原方式处理
                    combined_img = Image.new('RGB', (max_width, total_height), (255, 255, 255))
                    y_offset = 0
                    for img in images:
                        combined_img.paste(img, (0, y_offset))
                        y_offset += img.height
                    combined_img.save(output_path)
                    print(f"输出文件已保存到: {output_path}")

    elif output_format == 'pdf':
        new_doc = fitz.open()
        for page_num in pages_to_process:
            new_doc.insert_pdf(doc, from_page=page_num - 1, to_page=page_num - 1)
        new_doc.save(output_path)
        print(f"输出文件已保存到: {output_path}")
    else:
        raise ValueError(f"不支持的输出格式: {output_format}")

    doc.close()

def merge_pdfs(input_pdfs, output_path):
    merged_doc = fitz.open()
    for pdf_path in input_pdfs:
        with fitz.open(pdf_path) as doc:
            merged_doc.insert_pdf(doc)
    merged_doc.save(output_path)
    print(f"合并的PDF文件已保存到: {output_path}")

def extract_images_from_pdf(pdf_path, page_range_str, output_directory):
    doc = fitz.open(pdf_path)
    total_pages = len(doc)
    pages_to_process = parse_page_ranges(page_range_str, total_pages)

    if not os.path.exists(output_directory):
        os.makedirs(output_directory)

    for page_num in pages_to_process:
        page = doc[page_num - 1]
        image_list = page.get_images()

        for img_index, img in enumerate(image_list):
            xref = img[0]
            base_image = doc.extract_image(xref)
            image_bytes = base_image["image"]

            # 获取图片格式
            image_format = base_image["ext"]

            # 使用 PIL 打开图片
            image = Image.open(io.BytesIO(image_bytes))

            # 保存图片
            image_filename = f"page_{page_num}_image_{img_index + 1}.{image_format}"
            image_path = os.path.join(output_directory, image_filename)
            image.save(image_path)
            print(f"已保存图片: {image_path}")

    print(f"所有图片已提取到目录: {output_directory}")

def encrypt_pdf(input_path, output_path, user_password, owner_password=None):
    doc = fitz.open(input_path)

    if owner_password is None:
        owner_password = user_password

    encryption_method = fitz.PDF_ENCRYPT_AES_256
    permissions = int(
        fitz.PDF_PERM_ACCESSIBILITY
        | fitz.PDF_PERM_PRINT
        | fitz.PDF_PERM_COPY
        | fitz.PDF_PERM_ANNOTATE
    )

    doc.save(
        output_path,
        encryption=encryption_method,
        user_pw=user_password,
        owner_pw=owner_password,
        permissions=permissions
    )
    print(f"已加密的PDF保存到: {output_path}")

def decrypt_pdf(input_path, output_path, password):
    doc = fitz.open(input_path)

    if doc.is_encrypted:
        if doc.authenticate(password):
            doc.save(output_path)
            print(f"已解密的PDF保存到: {output_path}")
        else:
            raise ValueError("密码不正确")
    else:
        print("PDF文件未加密")
        doc.save(output_path)
        print(f"PDF文件已复制到: {output_path}")

def main():
    parser = argparse.ArgumentParser(description="处理PDF文件:选择页面并输出为JPG、PNG或PDF,或合并多个PDF,或提取图片")
    subparsers = parser.add_subparsers(dest='command', help='可用的命令')

    # 处理单个PDF的命令
    process_parser = subparsers.add_parser('process', help='处理单个PDF文件')
    process_parser.add_argument("input_pdf", help="输入PDF文件的路径")
    process_parser.add_argument("page_range", help="要处理的页面范围,例如 '1,3-5,7-9'")
    process_parser.add_argument("output", help="输出文件的路径(支持.jpg, .jpeg, .png, .pdf)")
    process_parser.add_argument("-d", "--dpi", type=int, default=300, help="图像DPI (仅用于jpg和png输出,默认: 300)")
    process_parser.add_argument("-p", "--password", help="PDF密码(如果PDF加密)")
    process_parser.add_argument("-s", "--split-pages", action='store_true', help="按每页生成单独的JPG或PNG文件")

    # 合并PDF的命令
    merge_parser = subparsers.add_parser('merge', help='合并多个PDF文件')
    merge_parser.add_argument("input_pdfs", nargs='+', help="要合并的PDF文件路径列表")
    merge_parser.add_argument("output", help="输出的合并PDF文件路径")

    # 提取图片的命令
    extract_parser = subparsers.add_parser('extract', help='从PDF中提取图片')
    extract_parser.add_argument("input_pdf", help="输入PDF文件的路径")
    extract_parser.add_argument("page_range", help="要提取图片的页面范围,例如 '1,3-5,7-9'")
    extract_parser.add_argument("output_directory", help="保存提取图片的目录路径")
    extract_parser.add_argument("-p", "--password", help="PDF密码(如果PDF加密)")

    # 加密PDF的命令
    encrypt_parser = subparsers.add_parser('encrypt', help='加密PDF文件')
    encrypt_parser.add_argument("input_pdf", help="输入PDF文件的路径")
    encrypt_parser.add_argument("output_pdf", help="输出加密PDF文件的路径")
    encrypt_parser.add_argument("user_password", help="用户密码")
    encrypt_parser.add_argument("-o", "--owner_password", help="所有者密码(如果不提供,将与用户密码相同)")

    # 解密PDF的命令
    decrypt_parser = subparsers.add_parser('decrypt', help='解密PDF文件')
    decrypt_parser.add_argument("input_pdf", help="输入加密PDF文件的路径")
    decrypt_parser.add_argument("output_pdf", help="输出解密PDF文件的路径")
    decrypt_parser.add_argument("password", help="PDF密码")

    args = parser.parse_args()

    if args.command == 'process':
        try:
            decrypted_pdf_path = try_remove_pdf_password(args.input_pdf, args.password)
            process_pdf(decrypted_pdf_path, args.page_range, args.output, args.dpi, args.split_pages)
            if decrypted_pdf_path != args.input_pdf:
                os.remove(decrypted_pdf_path)
        except ValueError as e:
            if "PDF文件已加密,需要密码" in str(e):
                password = input("请输入PDF密码: ")
                try:
                    decrypted_pdf_path = try_remove_pdf_password(args.input_pdf, password)
                    process_pdf(decrypted_pdf_path, args.page_range, args.output, args.dpi, args.split_pages)
                except ValueError as e:
                    print(f"处理过程中出错: {str(e)}")
            else:
                print(f"处理过程中出错: {str(e)}")
        except Exception as e:
            print(f"处理过程中出错: {str(e)}")
    elif args.command == 'merge':
        try:
            merge_pdfs(args.input_pdfs, args.output)
        except Exception as e:
            print(f"合并PDF过程中出错: {str(e)}")
    elif args.command == 'extract':
        try:
            decrypted_pdf_path = try_remove_pdf_password(args.input_pdf, args.password)
            extract_images_from_pdf(decrypted_pdf_path, args.page_range, args.output_directory)
            if decrypted_pdf_path != args.input_pdf:
                os.remove(decrypted_pdf_path)
        except ValueError as e:
            if "PDF文件已加密,需要密码" in str(e):
                password = input("请输入PDF密码: ")
                try:
                    decrypted_pdf_path = try_remove_pdf_password(args.input_pdf, password)
                    extract_images_from_pdf(decrypted_pdf_path, args.page_range, args.output_directory)
                except ValueError as e:
                    print(f"处理过程中出错: {str(e)}")
            else:
                print(f"处理过程中出错: {str(e)}")
        except Exception as e:
            print(f"处理过程中出错: {str(e)}")
    elif args.command == 'encrypt':
        try:
            encrypt_pdf(args.input_pdf, args.output_pdf, args.user_password, args.owner_password)
        except Exception as e:
            print(f"加密PDF过程中出错: {str(e)}")
    elif args.command == 'decrypt':
        try:
            decrypt_pdf(args.input_pdf, args.output_pdf, args.password)
        except Exception as e:
            print(f"解密PDF过程中出错: {str(e)}")

if __name__ == "__main__":
    main()

使用方法

1. 处理 PDF

python pdf_tool.py process[options]
  • 参数说明:

: 输入 PDF 文件的路径 

: 要处理的页面范围,例如 '1,3-5,7-9' 

: 输出文件的路径(支持 。jpg, .jpeg, .png, .pdf)  

  • 选项:

-d, --dpi: 设置图像 DPI(默认:300) 

-p, --password: PDF 密码(如果 PDF 加密) 

-s, --split-pages: 按每页生成单独的 JPG 或 PNG 文件 

  • 示例:
python pdf_tool.py process input.pdf 1,3-5 output.png -d 200 -s

 

2. 合并 PDF

python pdf_tool.py merge
  • 参数说明:

: 要合并的 PDF 文件路径列表 

: 输出的合并 PDF 文件路径 

  • 示例:
python pdf_tool.py merge file1.pdf file2.pdf file3.pdf merged.pdf

 

3. 提取图片

python pdf_tool.py extract[options]
  • 参数说明:

: 输入 PDF 文件的路径 

: 要提取图片的页面范围,例如 '1,3-5,7-9' 

: 保存提取图片的目录路径 

  • 选项:

-p, --password: PDF 密码(如果 PDF 加密) 

  • 示例:
python pdf_tool.py extract document.pdf 1-5 ./images -p mypassword

 

4. 加密 PDF

python pdf_tool.py encrypt[options]
  • 参数说明:

: 输入 PDF 文件的路径 

: 输出加密 PDF 文件的路径 

: 用户密码 

  • 选项:

-o, --owner_password: 所有者密码(如果不提供,将与用户密码相同) 

  • 示例:
python pdf_tool.py encrypt input.pdf encrypted.pdf userpass -o ownerpass

 

5. 解密 PDF

python pdf_tool.py decrypt
  • 参数说明:

: 输入加密 PDF 文件的路径 

: 输出解密 PDF 文件的路径 

: PDF 密码 

  • 示例:
python pdf_tool.py decrypt encrypted.pdf decrypted.pdf mypassword


相关阅读

基于 DeepSeek+AutoGen 的智能体协作系统

AI 时代,如何用 Python 脚本轻松搞定 PDF 需求?

DeepSeek V3 vs R1:到底哪个更适合你?全面对比来袭

深度揭秘:如何用一句话让 DeepSeek 优化你的代码

手把手教你用 DeepSeek 和 VSCode 开启 AI 辅助编程之旅

零基础小白的编程入门:用 AI 工具轻松加功能、改代码