阅读文本 / Reading text
一份 300 页的 PDF,可能前 280 页有文本层,最后 20 页才是扫描附件。如果在入口把它归为“扫描 PDF”,整份 OCR 会增加等待时间,也可能把本来正确的数字识别错。另一个极端同样危险:第一页能够复制文字,就让全部页面跳过 OCR。
更实用的设计,是让每一页带着预检结果进入下一步。本文实现一个保守的三路分流:直接提取、送入 OCR 候选队列、人工检查。它是可解释的工程启发式,不是判断文本质量的万能分类器。
图中来自下面代码生成的合成 PDF,不是客户文件,也不是 OmniDoc 界面。图片页里的文字是栅格像素;文本页的文字则有真实文本层。
先决定要检查什么
这里用两个信号:去掉空白后的文本字符数,以及页面上最大一幅图片占页面面积的比例。前者回答“是否提取到了足够多的字符”,后者帮助区分整页扫描和空白页面。图片面积先与裁切框求交,避免超出页面的图片放大比例。
观测
本文分流
仍然需要注意
至少 40 个非空白字符
TEXT_CANDIDATE
隐藏的坏 OCR 层也可能有很多字
少于 40 字且单幅图覆盖至少 70%
OCR_CANDIDATE
纯照片也会满足条件,OCR 不一定有意义
其余情况
REVIEW
空白、矢量字、稀疏标题和复杂拼图都可能在这里
阈值 40 和 0.70 是为了让这组测试容易审计,不是跨语言标准。中文、公式、封面和短表单应该分别标定。这里取最大图片面积,不把重叠图片面积简单相加;因此多块图拼接的扫描页可能落入 REVIEW,这是有意保留的边界。
完整可运行样例
样例使用
PyMuPDF==1.28.0
,不依赖 Tesseract,不会调用任何在线 OCR 或翻译服务。保存为
example.py
,在空目录中运行。它创建的文件名固定为
routing-fixture.pdf
,真实项目请不要覆盖输入文件。
import json
import pymupdf
def inspect_page(page):
chars = len("".join(page.get_text().split()))
bounds = page.cropbox
# 图片坐标与文本坐标都是未旋转坐标;不要混用旋转后的 page.rect。
bounds = pymupdf.Rect(0, 0, bounds.width, bounds.height)
ratios = []
for image in page.get_image_info():
rect = pymupdf.Rect(image["bbox"]) & bounds
ratios.append(max(0, rect.get_area()) / bounds.get_area())
largest = max(ratios, default=0.0)
route = ("TEXT_CANDIDATE" if chars >= 40 else
"OCR_CANDIDATE" if largest >= 0.70 else "REVIEW")
return {"page": page.number + 1, "chars": chars,
"largest_image_ratio": round(largest, 3), "route": route}
with pymupdf.open() as source:
page = source.new_page(width=360, height=480)
page.insert_text((28, 60), "Sample invoice 2026-09-26", fontsize=16)
page.insert_text((28, 100), "Quantity 12 Unit price USD 15.00", fontsize=12)
page.insert_text((28, 132), "Amount USD 180.00 Checked manually", fontsize=12)
scan = page.get_pixmap(matrix=pymupdf.Matrix(2, 2)).tobytes("png")
page = source.new_page(width=360, height=480)
page.insert_image(page.rect, stream=scan)
source.new_page(width=360, height=480)
source.save("routing-fixture.pdf")
with pymupdf.open("routing-fixture.pdf") as pdf:
result = [inspect_page(page) for page in pdf]
assert [r["route"] for r in result] == [
"TEXT_CANDIDATE", "OCR_CANDIDATE", "REVIEW"]
assert result[1]["chars"] == 0
assert result[1]["largest_image_ratio"] == 1.0
pdf[1].set_rotation(90)
assert inspect_page(pdf[1])["route"] == "OCR_CANDIDATE"
print(json.dumps(result, indent=2))
接入 OCR 时,缓存的是 TextPage,不是一个成功标记
PyMuPDF 的 OCR 接口是
page.get_textpage_ocr(...)
。Tesseract 和对应语言数据需要单独准备;拿到 TextPage 后,后续提取应把它作为
textpage
参数传入,避免同一页重复识别。具体安装与参数以
PyMuPDF OCR 文档
为准。
本文没有运行 OCR 引擎,所以测试结果只能证明分流逻辑和坐标使用,不代表中文识别率、表格恢复率或翻译质量。生产中应记录 OCR 引擎版本、语言包、页号、输入文件摘要和人工抽检结果。不要只保留最终字符串,否则很难解释某个数字来自哪一页。
三类最容易漏掉的故障
第一,隐藏文字层可能是乱码。字符数量达标只是“可以继续尝试提取”,还要检查替换字符、重复行、关键字段与渲染图的对应关系。对合同编号和金额等关键内容,必须抽检。
第二,矢量路径画出来的字既不是文本,也不是图片。它会进入 REVIEW;不要自动标成空白或静默丢页。第三,关闭 Document 以后再使用 Page 会产生失效对象。保持
with pymupdf.open(...)
的作用域,完成预检、渲染和提取后再释放文件。
不可信 PDF 还应在独立进程中设置文件大小、页数、渲染像素、时间和内存上限。示例是小文件测试,不提供恶意文件沙箱。生产使用 PyMuPDF 前也应核对其许可条件和你的分发方式。
从分流进入翻译工作流
合格的输出不是一串文本,而是带页号和决策原因的记录。翻译前先确认哪些页可以可靠提取;翻译后再验收内容、数字和版式。你可以在
OmniDoc 产品入口
查看文档处理服务,但本文的本地程序独立运行,不依赖 OmniDoc 账号,也不宣称它就是产品内部算法。
结论:先逐页留证据,再决定是否 OCR。把不确定页面留在队列里,比把不确定结果伪装成成功更有用。关于坐标、图片信息与 Page 生命周期,可进一步查阅
PyMuPDF Page API
。
- 1 -
更新于 2026-10-09