Python 为无目录 PDF 文件添加目录(书签)教程

Python 为无目录 PDF 文件添加目录(书签)教程

本教程将指导您如何利用 Python 自动分析一个没有内置书签的 PDF 文件,识别其中的章节标题,并最终为其添加一个可点击的目录(书签),从而生成一个新的 PDF 文件。这对于处理旧文档、扫描件或那些生成时未包含目录的 PDF 文件非常有用。

前置知识: 本教程建立在您已熟悉 Python 基础语法以及我们之前讨论的 PDF 文本提取和正则表达式应用的基础上。

第一部分:回顾与目标

1. 从无目录到有目录:核心思路

将一个“无目录”的 PDF 转化为“有目录”的 PDF,其核心流程可以概括为以下几个步骤:

  1. 文本提取: 从 PDF 的每一页中提取出文本内容。
  2. 标题识别: 使用正则表达式(或更复杂的文本分析技术)识别出文本中符合预设模式的标题,并记录其所在的页码。
  3. 结构化: 将识别到的标题组织成一个具有层级关系的目录结构(例如,第一章、1.1节、1.1.1小节)。
  4. 写入书签: 利用 PyPDF2 库,将这个结构化的目录作为书签(Outlines)写入到一个新的 PDF 文件中。
graph TD A[无目录PDF文件] --> B{文本提取
(PyPDF2.extract_text)} B --> C{标题识别
(正则表达式)} C --> D{构建树状目录结构
(层级判断)} D --> E{PyPDF2写入书签
(add_outline_item)} E --> F[生成带目录的新PDF文件] style A fill:#f9f,stroke:#333,stroke-width:2px; style F fill:#bbf,stroke:#333,stroke-width:2px;

图 1: 为无目录 PDF 添加目录的核心流程

2. 工具链:PyPDF2 和正则表达式

第二部分:准备工作

1. 确认所需库已安装

请确保您的 Python 环境中已经安装了 PyPDF2 库。如果尚未安装,请运行以下命令:

pip install PyPDF2

2. 准备测试 PDF 文件

为了成功运行本教程中的代码,您需要一个用于测试的 PDF 文件。这个文件应满足以下条件:

如果您手头没有这样的文件,可以尝试创建一个简单的 Word 文档,写入几页内容和标题,然后将其导出为 PDF。例如:

# Word 文档内容示例
第一章 绪论
这是第一章的开篇内容...

1.1 背景
进一步的背景信息...

1.2 目的
我们研究的目的...

第二章 方法
这是第二章的开篇内容...

2.1 数据收集
如何收集数据...

2.2 数据分析
数据分析方法...

将上述内容保存为 PDF,并命名为 no_bookmark_document.pdf,放在您的 Python 脚本同级目录下。

第三部分:识别并结构化标题

这部分是整个流程的核心,因为它决定了目录的准确性和完整性。

1. 核心:遍历页面,提取文本

我们将使用 PyPDF2 逐页读取 PDF 内容。

from PyPDF2 import PdfReader
import re

def extract_all_text_from_pdf(pdf_path):
    """从 PDF 中提取所有页面的文本,返回一个列表,每项是(页码, 文本)"""
    texts_with_pages = []
    try:
        reader = PdfReader(pdf_path)
        for i, page in enumerate(reader.pages):
            text = page.extract_text()
            if text:
                texts_with_pages.append((i + 1, text)) # 页码从1开始
    except Exception as e:
        print(f"提取 PDF 文本时发生错误: {e}")
    return texts_with_pages

# pdf_path = 'no_bookmark_document.pdf' # 替换为你的测试PDF文件路径
# all_page_texts = extract_all_text_from_pdf(pdf_path)
# print(f"总共提取了 {len(all_page_texts)} 页文本。")

2. 关键:通过正则表达式识别标题与页码

我们将定义一组正则表达式模式来匹配不同层级的标题。这是最需要根据您的 PDF 内容进行调整的部分。

# 承接上一步的 extract_all_text_from_pdf 函数

def identify_titles_from_text(page_texts_list, title_patterns=None):
    """
    从提取的文本中识别标题。
    :param page_texts_list: 列表,每项为 (页码, 页面文本)。
    :param title_patterns: 自定义标题识别的正则表达式列表,按优先级从高到低。
                           例如:[level1_regex, level2_regex, ...]
    :return: 识别到的标题列表,每项包含 {'title': '...', 'page': N, 'level': M}
    """
    if title_patterns is None:
        # 默认的标题模式:从最通用到最具体,匹配标题前可能存在的空格
        # level 0: 只有章,例如 "第一章 引言"
        # level 1: 章节号,例如 "1.1 背景"
        # level 2: 更多层级,例如 "1.1.1 细节"
        title_patterns = [
            (re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+章\s+.*)', re.M), 0), # Level 0: "第一章 XXX"
            (re.compile(r'^\s*(\d+\.\d+\.\d+\s+.*)', re.M), 2), # Level 2: "1.1.1 XXX"
            (re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1), # Level 1: "1.1 XXX"
            (re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+节\s+.*)', re.M), 1), # Level 1: "第一节 XXX"
            # 您可以根据需要添加更多模式,如 "附件 A" 或 "结论" 等
        ]

    identified_titles = []
    seen_titles_on_page = set() # 用于当前页去重

    for page_num, page_text in page_texts_list:
        lines = page_text.split('\n')
        for line in lines:
            line_stripped = line.strip()
            if not line_stripped:
                continue

            # 使用集合进行页内去重
            if line_stripped in seen_titles_on_page:
                continue

            for pattern, level in title_patterns:
                match = pattern.match(line_stripped)
                if match:
                    title_text = match.group(1).strip()
                    # 再次进行简单的去重,避免重复识别,并确保标题的唯一性
                    # 比较当前识别到的标题和上一个标题,如果相同且页码相同则跳过
                    if identified_titles and \
                       identified_titles[-1]['title'] == title_text and \
                       identified_titles[-1]['page'] == page_num:
                        continue

                    identified_titles.append({
                        'title': title_text,
                        'page': page_num,
                        'level': level
                    })
                    seen_titles_on_page.add(line_stripped) # 添加到本页已识别集合
                    break # 匹配到一个模式就跳出,避免重复匹配

        seen_titles_on_page.clear() # 清除本页的去重记录,准备下一页

    return identified_titles

# --- 示例运行 ---
# pdf_path = 'no_bookmark_document.pdf' # 替换为你的PDF文件
# page_texts = extract_all_text_from_pdf(pdf_path)
# identified_titles = identify_titles_from_text(page_texts)
# if identified_titles:
#     print("\n--- 识别到的标题列表 ---")
#     for t in identified_titles:
#         print(f"Level {t['level']}: {t['title']} (Page: {t['page']})")
# else:
#     print("未识别到任何标题。请检查 PDF 内容或调整正则表达式。")

正则表达式的重要性: 上述 title_patterns 是基于常见文档结构的示例。实际使用时,您**必须**根据您的 PDF 文件中标题的实际格式(例如,是否有前导空格、是否包含特定关键词、数字格式等)来调整和优化这些正则表达式。这是整个流程中成功率的关键。

精确度问题: 纯文本提取和正则表达式匹配可能不够精确。例如,正文中与标题格式相似的句子可能会被误识别。更高级的方案可能需要结合字体大小、加粗等视觉信息(这通常需要 pdfplumber 等库,我们会在后面提及)。

3. 难点:构建标题的层级结构

识别出的标题是一个扁平的列表。为了在 PDF 中创建嵌套的书签,我们需要将其转化为树状结构。我们沿用之前的栈方法来构建层级关系。

# 承接 identify_titles_from_text 函数

def build_tree_from_flat_titles(flat_titles):
    """
    将扁平化的标题列表(包含层级信息)构建成树状结构。
    每个节点格式:{'title': '...', 'page': N, 'level': M, 'children': [...]}
    """
    # 根节点,level设置为-1,确保所有顶级标题都是其子节点
    root = {'title': 'Root', 'page': 0, 'level': -1, 'children': []}
    
    # 使用一个栈来追踪当前层级的父节点。栈中存储的是目录节点对象的引用。
    # 栈的最后一个元素始终是当前正在处理的节点的直接父节点。
    stack = [root] 

    for item in flat_titles:
        current_level = item['level']
        current_node = {
            'title': item['title'], 
            'page': item['page'], 
            'level': current_level, 
            'children': []
        }

        # 根据当前标题的层级,调整栈。
        # 1. 如果当前标题的层级等于或高于栈顶元素的层级,说明当前标题是栈顶元素(或其父级)的兄弟节点。
        #    需要不断弹出栈顶元素,直到栈顶元素的层级低于当前标题的层级(找到合适的父节点)
        #    或者栈只剩下根节点。
        while len(stack) > 1 and stack[-1]['level'] >= current_level:
            stack.pop() 

        # 2. 将当前节点添加到栈顶元素的子节点列表中。
        #    栈顶元素现在是当前节点的直接父节点。
        stack[-1]['children'].append(current_node)
        
        # 3. 将当前节点推入栈中,使其成为下一个潜在的父节点。
        stack.append(current_node) 

    return root

# --- 示例运行 ---
# pdf_path = 'no_bookmark_document.pdf' # 替换为你的PDF文件
# page_texts = extract_all_text_from_pdf(pdf_path)
# identified_titles = identify_titles_from_text(page_texts)
# 
# if identified_titles:
#     toc_tree_root = build_tree_from_flat_titles(identified_titles)
#     print("\n--- 构建的树状目录结构预览 ---")
#     def print_tree(node, indent=0):
#         if node['title'] != 'Root': # 不打印虚拟根节点
#             print(f"{'  ' * indent}- {node['title']} (Page: {node['page']})")
#         for child in node['children']:
#             print_tree(child, indent + 1)
#     print_tree(toc_tree_root)
# else:
#     print("没有标题可用于构建树状结构。")

代码解释:

第四部分:使用 PyPDF2 添加书签到 PDF

现在我们有了结构化的目录数据,接下来就是将其写入到 PDF 文件中。

1. 加载现有 PDF

我们首先需要读取原始的 PDF 文件,因为我们将在其基础上添加书签并保存为新文件。

from PyPDF2 import PdfReader, PdfWriter
import os # 用于文件路径操作

# original_pdf_path = 'no_bookmark_document.pdf' # 替换为你的原始PDF文件路径
# output_pdf_path = 'document_with_bookmarks.pdf' # 新的带书签的PDF文件路径
# 
# reader = PdfReader(original_pdf_path)
# writer = PdfWriter()
# 
# # 将原PDF的所有页面添加到新的writer对象中
# for page in reader.pages:
#     writer.add_page(page)
# print(f"已加载 {len(reader.pages)} 页面到写入器。")

PdfWriter 对象: PdfWriter 用于构建新的 PDF 文件。它包含了可以添加页面、元数据和书签等的方法。通过 writer.add_page(page) 将原 PDF 的所有页面复制过来是必要的,因为我们是在原内容上添加书签。

2. 理解 PyPDF2 添加书签的 API (`add_outline_item`)

PdfWriter 对象有一个 add_outline_item(title, page_number, parent=None) 方法,用于添加书签:

add_outline_item 方法会返回一个新创建的书签对象 (Destination)。这个返回的对象可以作为后续子书签的 parent 参数。

3. 递归地将结构化目录添加到 PDF

由于我们的目录是树状结构,我们需要一个递归函数来遍历这个树并逐个添加书签。

# 承接 PdfReader 和 PdfWriter 的创建

def add_outline_from_tree(writer_obj, node, parent_bookmark=None):
    """
    递归地将树状目录结构添加到 PDFWriter。
    :param writer_obj: PyPDF2.PdfWriter 实例。
    :param node: 当前的目录节点(字典,包含title, page, children)。
    :param parent_bookmark: 当前节点的父书签对象(如果存在)。
    """
    # 根节点是虚拟的,不作为实际书签添加,只遍历其子节点
    if node['title'] != 'Root':
        # PyPDF2 的页码是0-indexed,我们识别的页码是1-indexed,所以需要减1
        page_index = node['page'] - 1 
        
        # 确保页码在有效范围内
        if 0 <= page_index < len(writer_obj.pages):
            current_bookmark = writer_obj.add_outline_item(
                title=node['title'], 
                page_number=page_index, 
                parent=parent_bookmark
            )
            print(f"  {'  ' * node['level']}- Added: {node['title']} (Page: {node['page']})")
        else:
            print(f"警告: 标题 '{node['title']}' 指向的页码 {node['page']} 超出 PDF 范围,未添加书签。")
            current_bookmark = None # 无法添加书签,子书签也将无法链接
    else:
        current_bookmark = parent_bookmark # 根节点本身不添加书签,但其子节点需要父书签

    # 递归处理子节点
    for child_node in node['children']:
        # 只有当父书签被成功创建时,才将子书签链接到它
        if current_bookmark is not None or node['title'] == 'Root':
            add_outline_from_tree(writer_obj, child_node, current_bookmark)

# --- 完整示例:将所有部分整合起来 ---
# (这部分将在“实战案例”中给出完整的可运行代码)

4. 保存新的 PDF 文件

将所有书签添加完成后,就可以将 PdfWriter 对象的内容写入到一个新的 PDF 文件中。

# writer_obj.write(output_pdf_file_object)
# with open(output_pdf_path, 'wb') as fp:
#     writer.write(fp)
# print(f"带书签的 PDF 已保存到: {output_pdf_path}")

第五部分:实战案例与注意事项

1. 完整代码示例:生成一个带目录的新 PDF

现在,我们将把所有功能模块整合到一个完整的脚本中。

import re
import os
from PyPDF2 import PdfReader, PdfWriter
from PyPDF2.generic import Destination # 确保导入 Destination 类

# --- 辅助函数:从 PDF 提取文本 ---
def extract_all_text_from_pdf(pdf_path):
    texts_with_pages = []
    try:
        reader = PdfReader(pdf_path)
        for i, page in enumerate(reader.pages):
            text = page.extract_text()
            if text:
                texts_with_pages.append((i + 1, text)) # 页码从1开始
    except Exception as e:
        print(f"错误: 提取 PDF 文本时发生错误: {e}")
    return texts_with_pages

# --- 辅助函数:识别标题 ---
def identify_titles_from_text(page_texts_list, title_patterns=None):
    if title_patterns is None:
        # 默认标题模式:
        # level 0: "第一章 XXX"
        # level 1: "1.1 XXX", "第一节 XXX"
        # level 2: "1.1.1 XXX"
        title_patterns = [
            (re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+章\s+.*)', re.M), 0),
            (re.compile(r'^\s*(\d+\.\d+\.\d+\s+.*)', re.M), 2),
            (re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1),
            (re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+节\s+.*)', re.M), 1),
        ]

    identified_titles = []
    seen_titles_on_page = set()

    for page_num, page_text in page_texts_list:
        lines = page_text.split('\n')
        for line in lines:
            line_stripped = line.strip()
            if not line_stripped:
                continue

            # 页内去重
            if line_stripped in seen_titles_on_page:
                continue

            for pattern, level in title_patterns:
                match = pattern.match(line_stripped)
                if match:
                    title_text = match.group(1).strip()
                    # 简单去重:如果上一个标题和当前标题完全相同且在同一页,则跳过
                    if identified_titles and \
                       identified_titles[-1]['title'] == title_text and \
                       identified_titles[-1]['page'] == page_num:
                        continue

                    identified_titles.append({
                        'title': title_text,
                        'page': page_num,
                        'level': level
                    })
                    seen_titles_on_page.add(line_stripped)
                    break
        seen_titles_on_page.clear()
    return identified_titles

# --- 辅助函数:构建树状目录结构 ---
def build_tree_from_flat_titles(flat_titles):
    root = {'title': 'Root', 'page': 0, 'level': -1, 'children': []}
    stack = [root] 

    for item in flat_titles:
        current_level = item['level']
        current_node = {
            'title': item['title'], 
            'page': item['page'], 
            'level': current_level, 
            'children': []
        }

        while len(stack) > 1 and stack[-1]['level'] >= current_level:
            stack.pop() 

        stack[-1]['children'].append(current_node)
        stack.append(current_node) 

    return root

# --- 辅助函数:递归添加书签到 PDFWriter ---
def add_outline_from_tree(writer_obj, node, parent_bookmark=None):
    if node['title'] != 'Root':
        page_index = node['page'] - 1 # PyPDF2 页码是0-indexed
        
        if 0 <= page_index < len(writer_obj.pages):
            current_bookmark = writer_obj.add_outline_item(
                title=node['title'], 
                page_number=page_index, 
                parent=parent_bookmark
            )
            print(f"  {'  ' * node['level']}- Added: {node['title']} (Page: {node['page']})")
        else:
            print(f"警告: 标题 '{node['title']}' 指向的页码 {node['page']} 超出 PDF 范围 ({len(writer_obj.pages)}页),未添加书签。")
            current_bookmark = None 
    else:
        current_bookmark = parent_bookmark 

    for child_node in node['children']:
        if current_bookmark is not None or node['title'] == 'Root': # 根节点子项可以直接添加
            add_outline_from_tree(writer_obj, child_node, current_bookmark)

# --- 主函数:整合所有步骤 ---
def add_bookmarks_to_pdf(input_pdf_path, output_pdf_path, custom_title_patterns=None):
    if not os.path.exists(input_pdf_path):
        print(f"错误: 输入 PDF 文件 '{input_pdf_path}' 未找到。")
        return

    try:
        reader = PdfReader(input_pdf_path)
        writer = PdfWriter()

        # 1. 将原 PDF 的所有页面复制到新的 writer 中
        for page in reader.pages:
            writer.add_page(page)
        print(f"已从 '{input_pdf_path}' 加载 {len(reader.pages)} 页面。")

        # 2. 提取文本
        print("正在提取 PDF 文本...")
        page_texts = extract_all_text_from_pdf(input_pdf_path)
        if not page_texts:
            print("未能提取任何文本,无法识别标题。请检查 PDF 是否为扫描件或内容为空。")
            return

        # 3. 识别标题
        print("正在识别标题...")
        identified_titles = identify_titles_from_text(page_texts, custom_title_patterns)
        if not identified_titles:
            print("未识别到任何标题。请检查 PDF 内容或调整自定义标题模式 (custom_title_patterns)。")
            return

        # 4. 构建树状目录结构
        print("正在构建目录树...")
        toc_tree_root = build_tree_from_flat_titles(identified_titles)
        
        # 5. 添加书签到 PDFWriter
        print("正在向 PDF 添加书签...")
        add_outline_from_tree(writer, toc_tree_root)
        
        # 6. 保存新的 PDF 文件
        with open(output_pdf_path, 'wb') as fp:
            writer.write(fp)
        print(f"\n成功生成带书签的 PDF 文件: '{output_pdf_path}'")

    except Exception as e:
        print(f"处理 PDF 时发生意外错误: {e}")

# --- 运行示例 ---
if __name__ == "__main__":
    # --- 请替换为你的实际文件路径 ---
    input_pdf = 'no_bookmark_document.pdf' # 准备一个没有内置书签的PDF文件
    output_pdf = 'document_with_auto_bookmarks.pdf'

    # 如果您的PDF标题格式特殊,可以自定义正则表达式:
    # 例如:如果您的标题是 "Section A: Introduction" 这种形式
    my_custom_patterns = [
        (re.compile(r'^\s*(第一章\s+.*)', re.M), 0),
        (re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1),
        (re.compile(r'^\s*([A-Za-z]\.\s+.*)', re.M), 1) # 例如 "A. Introduction" 作为第一节
    ]

    print(f"尝试为 '{input_pdf}' 添加书签,保存为 '{output_pdf}'...")
    add_bookmarks_to_pdf(input_pdf, output_pdf, custom_title_patterns=None) # 使用默认模式

    # 如果需要,可以尝试使用自定义模式
    # add_bookmarks_to_pdf(input_pdf, output_pdf, custom_title_patterns=my_custom_patterns)

    # 提示:请务必确保 'no_bookmark_document.pdf' 存在且内容包含清晰的标题。

2. 标题识别精度与优化

关于 pdfplumber 如果您发现 PyPDF2.extract_text() 提取的文本质量不佳(例如,丢失了换行符、文本顺序混乱),强烈建议尝试使用 pdfplumber。虽然 pdfplumber 自身不直接支持写入书签,但它可以提供更准确的文本和布局信息,您可以用它来识别标题,然后将识别结果传递给 PyPDF2.PdfWriter 来添加书签。

pdfplumber 示例(仅供参考,不集成到主函数中):

# import pdfplumber
# 
# with pdfplumber.open(input_pdf_path) as pdf:
#     for i, page in enumerate(pdf.pages):
#         text = page.extract_text(x_tolerance=2, y_tolerance=2) # 调整容忍度以获取更好的文本
#         print(f"Page {i+1} text (from pdfplumber):\n{text[:200]}...")
# 
#         # 更高级:获取带坐标的文本对象,分析字体大小等
#         # for obj in page.extract_words(): # 或 extract_text(x_tolerance=None)
#         #     if obj['x0'] < 100 and obj['fontname'].startswith('Bold'): # 简单示例:左侧且加粗
#         #         print(f"Potential Title: {obj['text']}")
            

3. 添加书签的页码偏移问题

再次强调,PyPDF2 的页码是基于 0 的索引,而我们在代码中处理的页码通常是用户友好的从 1 开始的。因此,在调用 add_outline_item 时,务必将您识别到的页码减 1:

$$ \text{PyPDF2 页码} = \text{识别到的页码} - 1 $$

4. 性能与大文件考虑

结语

通过本教程,您应该已经掌握了如何利用 Python (PyPDF2) 和正则表达式为没有内置书签的 PDF 文件添加可点击目录(书签)的核心技术。这是一个非常实用的技能,可以帮助您自动化文档管理和提升阅读体验。请记住,精确的标题识别是成功的关键,可能需要您根据具体的 PDF 文件格式进行定制和优化。

祝您实践愉快!如果您在实施过程中遇到任何问题,不要犹豫,尽管提出。

互动区域

登录后可以点赞此内容

参与互动

登录后可以点赞和评论此内容,与作者互动交流