PDF 页面方向统一
Overview
把扫描版/图片版 PDF 中方向不一的页面统一调整为文字正立的竖向页面。逐页自动判断文字方向(区分正立 / 旋转 90° / 旋转 270° / 倒置),并按判定结果设置页面旋转属性——无损操作,不重编码、不改动图像数据,原文件保留不动。
核心原则
- 不要凭尺寸猜测方向:页面是横向只说明需要转竖,转 90° 还是 270° 必须判断内容(文字)的实际朝向。
- 禁止用 OCR 识别置信度判向:RapidOCR/PaddleOCR 的识别模型对 180° 倒置文本同样能识别出高置信度结果,置信度对比不可靠。必须使用行级方向分类器(cls 模型,标签为
0/180)投票。 - 无损优先:只修改页面的
/Rotate属性(page.set_rotation()),不要旋转/重采样图像。主流阅读器、打印、云文档均遵循该属性。 - 无文字页不擅自处理:判不出文字方向的页面(图片页、图表页)保持原样并在结果中明确标注,提示用户人工/视觉复核。
- 横向版式的原件不动:若某页无文字且视觉上确认是天生横向的内容(如横向证书、表格大图),保留原方向并告知用户,不要强制转竖导致内容侧躺。
工作流
1. 环境准备
依赖(Python 3.8+):pip install pymupdf rapidocr-onnxruntime pillow numpy(脚本会自检并给出缺失提示)。
- 首次运行会下载 OCR 模型(rapidocr-onnxruntime 首次初始化拉取约 15MB 模型文件,需要联网,之后走本地缓存);首次运行明显偏慢属正常,不要因此判定脚本卡死。
- 处理 100 页扫描件约需 3-7 分钟(页均 2-4 秒)。可以先跑
--list-only抽查几页确认判向效果,再全量处理。
2. 运行判向与旋转脚本
python <skill_dir>/scripts/fix_pdf_orientation.py 输入.pdf -o 输出.pdf --report 报告.json
脚本行为:
- 横向页候选方向 90/270,竖向页候选 0/180(校正已存在的 /Rotate 后计算显示尺寸);
- RapidOCR 检测文本行 → 裁剪长条行(宽高比 ≥ 1.2)→ cls 分类器投票 → 正立行占比 ≥ 0.6 判正立、≤ 0.4 判倒置,之间或无文字则保持原样并标记;
- 逐行输出判定日志,末尾汇总:总页数、调整页数、待复核页列表;
- --list-only 只检测不保存;--in-place 原地覆盖;--threshold 调整判定阈值;--report 输出逐页 JSON 详情;--max-pages 3 只处理前 3 页(先抽查判向效果再全量跑)。
3. 复核标记页(no_text / uncertain)
对脚本标记的页面:
1. 渲染缩略图(page.get_pixmap(dpi=50~60));
2. 用视觉能力查看,确认内容是「横排文字被旋转」还是「天生横向版式」;
3. 前者按视觉判断的方向用 page.set_rotation() 手动修正;后者保留原方向。
批量复核时可把多页拼成 2x2 蒙太奇图(PIL 拼接 + 红色页码标签),一次视觉调用判断多页。不要只用尺寸/页数推断整份文件方向一致——同一份文件里不同扫描批次的朝向可能不同(实测 108 页的文件中出现过 5 个朝向交替批次)。
4. 结果核验
保存后必须核验,不能只看脚本退出码:
- 重新打开输出文件:page_count 与原文件一致;
- 统计各页显示尺寸集合,确认除刻意保留的横向页外全部为竖向;
- 抽样 3-9 页渲染缩略图,视觉确认文字正立;
- 确认每页图像数据完整(page.get_images() 非空)。
5. 交付
- 原文件保留不动,输出为独立新文件(默认命名
原名_统一竖向.pdf); - 明确告知用户:调整了多少页、哪些页保留横向及原因;
- 如会话绑定了云文档连接器(扫描全能王等)且用户未指定保存方式,可按"本地 + 云端"双保存惯例回存;上传时
content_length必须等于实际文件字节数(见踩坑第 5 条)。没有连接器就只交付本地文件,不要凭空报云端链接。
踩坑记录(重要)
- 判向:cls 分类器投票在实测中结果极清晰(占比为 1.0 或 0.0),阈值 0.6 安全;若某页长期落在 uncertain 区间,直接走视觉复核,不要放宽阈值硬判。
- 渲染分辨率:dpi=100、宽度上限 1000px 足够判向,页均耗时约 2-4 秒;判向只需要文字行的存在,不需要识别内容。
- 旋转语义:
set_rotation(270)= 显示时顺时针转 270°(即逆时针 90°)。调试方向时直接渲染两种候选并对比,不要在脑内推演坐标变换。 - 无文字层 ≠ 无内容:纯扫描页
get_text()返回空是正常的,判向依赖图像渲染 + OCR,不依赖文字层。 - content_length 必须与实际字节数完全一致:上传前用
stat/os.path.getsize取精确值,不要用 MB 换算的估算值。