PDF 解析

批注文档交接时,别只交一份看起来干净的 PDF

本文针对PDF批注提取中正文与评论混淆、敏感数据泄露及坐标系错乱问题,提供了基于PyMuPDF的分层提取与安全校验方案。内容涵盖独立数据层原理、防御性编程示例、边界条件处理及人工验收标准,并说明了如何在文档翻译工作流中正确接入OmniDoc进行双语对照验收。

阅读文本 / Reading text
本文使用合成样本演示处理方法。代码已通过隔离环境中的示例检查;这不代表对所有真实文件或产品效果的保证。处理重要文件前,请保留原件,并按文中的边界与验收项复核。 直接回答一个高频故障:当你用 page.get_text() 提取PDF内容准备翻译或归档时,默认输出通常不包含批注(Annotations)。更危险的是,如果直接使用某些“全文提取”模式,批注内容可能被无序地插入正文段落中间,导致语义断裂;或者相反,你以为提取了全部内容,却遗漏了审阅者留在侧边栏里的关键修改意见甚至敏感备注。在文档交接环节,必须将正文与批注作为两个独立的数据流处理,并对批注内容进行显式的安全审查。 为什么批注是独立于正文的隐藏数据层 PDF规范中,页面内容流(Content Stream)与批注字典(Annots Array)在结构上是分离的。正文由文本显示指令构成,而批注是附着在页面上的独立对象,拥有自己的坐标、类型和内容属性。PyMuPDF 的 Page.annots() 方法返回的是一个生成器,它遍历的是这个独立的批注列表,而非页面文本流。 这意味着你不能假设“看到的文字都能被同一种方式提取”。高亮(Highlight)、下划线(Underline)等标记类批注本身不包含文本,它们只记录了对应的原文区域(Quads/Rects),真正的文本需要通过坐标反查正文才能获得;而便签(Text/Note)、自由文本(FreeText)则自带内容字符串。如果不区分类型直接读取 annot.info["content"] ,你会丢失所有标记类批注指向的原文上下文,或者在处理空内容时抛出异常。 合成示例:分层提取与敏感信息拦截 为了演示正确的处理逻辑,我们构造一个包含三种典型批注的合成场景。请注意,以下输入均为代码内构建的合成样本,并非来自真实客户文件,仅用于教学演示。 预期正确输出 应包含三个结构化字段: 1. 正文片段 :仅包含页面原始文本,不含任何批注内容。 2. 批注清单 :每条记录包含类型、位置、作者、时间及内容(如有)。 3. 安全标记 :若批注内容匹配预设敏感规则(如身份证号、内部代号),该条目标记为 redacted=True ,内容替换为占位符。 错误/边界输入及处理 : * 空内容批注 :某些阅读器创建的墨迹(Ink)或图形批注没有文本属性。直接访问 .info["content"] 可能返回空字符串或None。代码必须做防御性检查,不能假设所有批注都有可读文本。 * 旋转页面坐标 :如果页面被旋转了90度,批注的矩形坐标仍然是基于未旋转页面的。若直接用该坐标去裁剪或定位正文,位置会完全错乱。必须先通过 page.rotation_matrix 或 page.derotation_matrix 进行坐标变换。 * 孤儿对象 :如果在遍历过程中关闭了文档或重新加载了页面,当前的 annot 对象会立即失效。必须在单次页面生命周期内完成所有读取操作。 Python 实现:安全的批注遍历与校验 以下示例使用 PyMuPDF 在内存中构建测试PDF,演示分层提取与基础脱敏逻辑。依赖库为 PyMuPDF (即 pymupdf 包),请在锁定的依赖环境中运行,版本需 >=1.23.0 以支持完整的 annot 属性访问。本代码未执行网络操作或文件写入,仅为逻辑演示。 import fitz # PyMuPDF import re # 合成示例:仅在内存中构造,无需外部文件 def create_sample_pdf(): doc = fitz.open() page = doc.new_page() # 插入正文 page.insert_text((72, 72), "Confidential Project Alpha Report", fontsize=12) # 添加便签批注(含敏感信息模拟) page.add_text_annot((200, 72), "Reviewer: SSN 123-45-6789 approved.") # 添加高亮批注(无自身文本,关联正文) highlight = page.add_highlight_annot(fitz.Rect(72, 60, 250, 80)) # 必须调用update()使info变更生效 highlight.set_info(title="Editor", content="Check this term") highlight.update() return doc def extract_with_safety(page, sensitive_pattern=r"\d{3}-\d{2}-\d{4}"): body_text = page.get_text("text") annotations = [] # 显式遍历批注生成器 for annot in page.annots(): info = annot.info # 防御性获取内容,处理None或空字符串 content = info.get("content", "") or "" # 边界处理:敏感词检测与脱敏 is_sensitive = bool(re.search(sensitive_pattern, content)) safe_content = "[REDACTED]" if is_sensitive else content annotations.append({ "type": annot.type[1], "author": info.get("title", "Unknown"), "content": safe_content, "is_redacted": is_sensitive, # 注意:此为未旋转坐标系,若页面有旋转需配合rotation_matrix使用 "rect": list(annot.rect) }) return body_text, annotations # 执行校验 doc = create_sample_pdf() page = doc[0] text, annots = extract_with_safety(page) assert "SSN 123-45-6789" not in str(annots), "敏感信息未被拦截" assert any(a["type"] == "Highlight" for a in annots), "标记类批注丢失" assert len(text.strip()) > 0, "正文提取失败" print(f"Extracted {len(annots)} annotations safely.") 此代码展示了防御性编程的核心:不信任批注内容的纯净度,不假设批注类型的完整性。 assert 语句用于验证逻辑预期,但请注意这仅是代码演示,不代表对所有PDF变体的端到端效果保证。 验收标准与人工审查边界 自动化提取无法覆盖所有边缘情况。下表列出了实际交接中应关注的验收点,区分了代码可处理范围与必须人工介入的场景。 观察现象 代码层面处理 人工审查/决策 批注内容为空但存在图标 记录类型为“Ink”或“Stamp”,内容留空 确认是否为签名或审批章,决定是否保留图像快照 批注位置超出CropBox 坐标仍有效,但渲染不可见 判断是否为历史遗留废弃批注,通常可忽略 富文本批注含HTML标签 提取原始字符串,保留标签 翻译前需清洗标签或转换为纯文本,避免译文夹杂代码 批注作者名为乱码 按原样提取,不做编码猜测 核对源文件元数据,确认是否为特定编码的姓名 嵌套回复线程 PyMuPDF返回扁平列表 需根据ParentID重建对话树,否则上下文丢失 不适用范围 :本方案不适用于加密且未提供密码的PDF、损坏的PDF结构、以及使用非标准私有扩展定义的批注类型。遇到这些情况,脚本可能静默跳过或抛出底层C++异常,必须由人工使用专业工具打开源文件确认。 参考资料与下一步 PyMuPDF Page.annots() 官方文档 下一步行动 :选取一份包含混合批注类型的历史项目文件,运行上述校验脚本,统计其中需要人工审查的条目比例。若比例超过预期,应在交接流程中增加预处理环节,而非依赖下游工具自动兜底。 将这份检查清单用于文档翻译 先把页面顺序、来源位置与文本完整性检查清楚,再把同一份脱敏样本交给翻译流程。译后仍要检查标题、正文和图注是否正确对应,不能只看文字是否通顺。 OmniDoc(产品入口) 提供多格式文档翻译入口,官网介绍了 OCR、术语表与双语对照功能。对希望直接从文件开始处理的团队,这是一条值得用实际样本评估的路径。请使用有权上传的脱敏文件,并用本篇的检查项复核结果;这里不声称 OmniDoc 使用了本文的开源库或内部算法,也不以示例代替产品性能测试。 - 1 -
更新于 2026-10-08