
Pdf Processor
- 29 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
One-stop PDF processing: scan preprocessing, OCR dual-layer PDFs, page numbering, merging, decryption, watermark removal, and compression.
About
A unified PDF processing entry covering scan preprocessing, OCR dual-layer PDF generation, page numbering, merging, decryption, watermark removal, and compression, choosing the shortest workflow per intent. A developer uses it to process, optimize, or tidy PDF documents in one pass.
- Covers OCR, merge, decrypt, watermark removal, compress
- Prioritizes protecting the original file
Pdf Processor by the numbers
- 29 all-time installs (skills.sh)
- Ranked #413 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill pdf-processorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
One-stop PDF processing: scan preprocessing, OCR dual-layer PDFs, page numbering, merging, decryption, watermark removal, and compression.
Files
pdf-processor
定位
本技能是 PDF 处理的统一入口,覆盖扫描件预处理、OCR 双层 PDF 生成、页码添加、PDF 合并、解密、水印去除和压缩。优先保护原始文件,按用户意图选择最短可用流程。
核心职责:
1. 扫描件一键处理:解密 → 页面预处理 → 合并输出 → OCR 双层 PDF。 2. 单项处理:只预处理、只 OCR、只压缩、只解密、只去水印、只合并、只加页码。 3. 用户没有特别说明时,扫描件走默认统一入口;明确提出单项需求时只执行对应工具。
本技能不做纯文本 PDF 内容编辑、PDF 阅读批注、电子签名、非压缩目的的格式转换。
默认策略
- 不修改原始文件;输出到新文件,重名时加
_1、_2等序号。 - 扫描件、拍照件、证据材料默认执行预处理后继续生成可搜索双层 PDF。
- “只预处理”“不要 OCR”“只矫正压缩”才使用
--preprocess-only。 - “合并”“加页码”“解密”“去水印”“压缩”只执行对应工具,不自动进入预处理/OCR。
- 压缩只有用户明确提出时才单独执行;统一入口中的默认压缩是预处理输出策略的一部分。
- 水印去除只在用户明确要求时执行,不作为默认自动步骤。
常用流程
1. 一键处理扫描 PDF
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf默认 medium 合并输出为约 200 DPI、JPEG 质量 72、色度子采样 1,优先兼顾法院上传体积和放大阅读清晰度。文件大小限制很严时使用:
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf --compress-level high页面方向已正确的大批量扫描件可提速:
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf \
--skip-coarse-rotation --preprocess-jobs 6 --preprocess-chunk-pages 802. 只预处理,不做 OCR
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf --preprocess-only只做页面矫正、不压缩、不 OCR:
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf \
--preprocess-only --no-compress3. 只做 OCR 文字层
python3 scripts/pdf-ocr.py --input input.pdf --output output.pdf默认后端为 auto:优先按 --api-order、OCR_API_ORDER 或 config/.env 顺序调用 PaddleOCR / MinerU API;外部 API 不可用时回退本地 ocrmypdf。
# 强制本地兜底
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend local_ocrmypdf
# 强制 PaddleOCR API
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend paddle_api
# 强制 MinerU API
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend mineru_api后端选择、API 配置和协议细节见 references/ocr-backend-guide.md、references/paddleocr-api-guide.md、references/mineru-api-guide.md。
单项工具
# 手动旋转
python3 scripts/pdf-rotate.py --input input.pdf --output output.pdf --angle 90
# 解密
python3 scripts/pdf-decrypt.py --input input.pdf --output output.pdf
python3 scripts/pdf-decrypt.py --input input.pdf --output output.pdf --password 123456
# 去水印
python3 scripts/pdf-remove-watermark.py --input input.pdf --output output.pdf
# 压缩
python3 scripts/pdf-compress.py -i input.pdf -o output.pdf --level medium
# 加页码
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf
# 合并
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf file3.pdf -o merged.pdf
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf -o merged.pdf --add-numbers --continuous页码、合并、压缩等详细参数见 references/pdf-workflows.md。
依赖
基础依赖
pip install pymupdf pypdf pillow numpy opencv-python pdf2imagemacOS:
brew install popplerLinux:
sudo apt-get install poppler-utilsOCR 兜底依赖
pip install ocrmypdfmacOS:
brew install tesseract tesseract-langLinux:
sudo apt-get install tesseract-ocr tesseract-ocr-chi-sim完整可选依赖清单见 references/optional-dependencies.txt。历史保留的本地 Paddle 双层实现已拆到 scripts/pdf_ocr_paddle_local.py,不属于默认生产链路;需要实验时再安装 paddleocr paddlepaddle 并单独接入。
质量检查
python3 scripts/pdf-ocr-quality-check.py -i output.pdf --keyword 合同,法院
python3 scripts/pdf-ocr-benchmark.py \
-i input.pdf \
--backend local_ocrmypdf \
--sample-pages 5 \
--skip-coarse-rotation \
--preprocess-jobs 6 \
--preprocess-chunk-pages 80常见问题见 references/troubleshooting.md。
交付前检查
1. 确认输出页数与原始文件一致。 2. 抽查页面方向、清晰度、裁剪边界和文件体积。 3. 对双层 PDF 测试文字搜索、复制和关键词命中。 4. 向用户说明实际使用的后端、输出文件路径和任何回退情况。
更新日志
[2.6.8] - 2026-05-31
改进
- 🧭 SKILL.md 瘦身:将页码、合并、压缩、OCR 参数细节迁移到
references/pdf-workflows.md,核心文档收敛为统一入口、触发规则、依赖和交付检查 - 🧩 本地 Paddle 历史实现解耦:新增
scripts/pdf_ocr_paddle_local.py,将本地 Paddle 双层 PDF 实验后端从pdf-ocr.py主入口中拆出,保留未来恢复空间
修复
- 🐛 单页分块预处理空 PDF:修复单页 PDF 传入
--preprocess-chunk-pages 1时误跳过渲染、生成空临时 PDF,导致后续ocrmypdf报 “Input file is empty” 的问题 - 📝 默认参数说明同步:修正故障排除与任务说明中的旧默认值,明确当前统一入口
medium合并输出为约 200 DPI / JPEG 质量 72
技术优化
- ✅ 增加单页分块请求回归测试
- ✅ 使用真实康复医院病历样本前 1 页跑通本地
ocrmypdf烟测,输出 1 页、可检索、关键词康复医院命中 1 次
[2.6.7] - 2026-05-29
改进
- 📄 默认清晰度提升:
pdf-preprocess-ocr.py的默认medium合并输出从 150 DPI / JPEG 质量 65 / 子采样 2 调整为 200 DPI / JPEG 质量 72 / 子采样 1,减少扫描件文字在放大查看时发糊 - ⚖️ 法院上传场景平衡:基于
20260529145736.pdf跑多组参数对比,推荐默认档在 16 页样本上输出约 2.97 MB,较原始 6.34 MB 仍压缩约 53%,但内嵌图像从 1242×1755 提升到 1655×2340
文档完善
- 📝
SKILL.md补充统一入口默认medium合并输出的清晰度取向,并提示文件大小限制严格时可显式使用--compress-level high
技术优化
- ✅ 更新回归测试,覆盖新的默认合并输出 DPI、JPEG 质量和色度子采样参数
[2.6.6] - 2026-05-17
新增
- 🧩 显式仅预处理模式:
scripts/pdf-preprocess-ocr.py新增--preprocess-only/--only-preprocess,用于在用户明确要求“只做预处理、不要 OCR”时,仅执行解密、页面旋转/倾斜矫正和默认压缩,然后跳过 OCR 文字层生成
改进
- 📝 触发规则收敛:
SKILL.md新增“仅预处理模式(不 OCR)”工作流,明确该模式不是默认路径;默认完整流程仍是预处理后继续生成双层 PDF - 🧭 保真选项说明:补充
--preprocess-only --no-compress示例,用于只做页面矫正、不压缩、不 OCR 的场景
技术优化
- ✅ 新增
write_preprocess_only_output()写出逻辑,复用统一入口的解密、预处理、压缩阶段,并保留原始文件时间戳 - ✅ 扩展
scripts/test_pdf_preprocess_speed_options.py,覆盖仅预处理输出写出与 dry-run 不写文件行为
[2.6.5] - 2026-05-15
新增
- 📊 OCR 端到端基准脚本:新增
scripts/pdf-ocr-benchmark.py,用于运行完整“预处理 + 压缩合并输出 + OCR”链路基准,默认报告总耗时、页数、输出体积、页面尺寸一致性、可提取文字量和关键词命中 - 📄 JSON/CSV 报告输出:每次 benchmark 自动生成
benchmark_report.json与benchmark_report.csv,并保存 stdout/stderr 日志,便于横向比较本地ocrmypdf、Paddle API、MinerU API 以及不同预处理参数
修复
- 🐛 恢复 OCR 主链路脚本:恢复合并过程中被删除的
pdf-ocr.py、pdf_runtime.py、pdf_ocr_layered.py、pdf_ocr_mineru.py、pdf_ocr_paddle_api.py、pdf-compress.py等 OCR/压缩依赖脚本,避免pdf-preprocess-ocr.py导入失败
技术优化
- ✅ 新增
scripts/test_pdf_ocr_benchmark.py,覆盖 PDF 指标采集、抽样 PDF 生成和 benchmark 命令构建 - ✅
SKILL.md新增端到端 OCR benchmark 示例,默认使用当前推荐的--preprocess-jobs 6 --preprocess-chunk-pages 80 - ✅ 基于真实病历样本前 2 页跑通本地
ocrmypdfbenchmark 烟测:总耗时约 9.55s,输出 2 页,可提取文字约 3792 字符
[2.6.4] - 2026-05-15
改进
- ⚡ 预处理分块流水线:
pdf-preprocess-core.py与pdf-preprocess-ocr.py新增--preprocess-chunk-pages,支持按块渲染、处理并顺序写入输出 PDF,避免一次性持有完整文档图像 - ⚡ 后台预渲染下一块:分块模式下使用单独渲染线程提前加载下一块页面,与当前块的倾斜检测和 JPEG 编码阶段重叠
- 📊 耗时拆分统计:预处理统计新增渲染耗时、保存耗时和实际分块页数,便于继续定位瓶颈
- 📝 使用说明同步:
SKILL.md的大批量扫描件示例改为--preprocess-jobs 6 --preprocess-chunk-pages 80
技术优化
- ✅ 扩展
scripts/test_pdf_preprocess_speed_options.py,覆盖分块页数解析,以及两页样本分块处理后的页序与页面尺寸 - ✅ 完整 244 页样本基准(150 DPI、跳过粗方向检测、medium 合并输出):不分块自动并行约 17.68s;
--preprocess-chunk-pages 80 --preprocess-jobs 6约 14.97s - ✅ 分块基准输出均为 244 页,页面尺寸完全一致,52.59 MB 输入生成 43.14 MB PDF
- ✅ 抽取样本前 2 页跑分块预处理 + 本地
ocrmypdf烟测,输出 2 页且可提取文字(约 3792 字符)
[2.6.3] - 2026-05-15
改进
- ⚡ 预处理页面并行处理:
pdf-preprocess-core.py与pdf-preprocess-ocr.py新增--preprocess-jobs,支持对页面级倾斜检测/裁剪流程并行执行;1为串行,0为自动按 CPU 与页数选择 - ⚡ PDF 保存阶段并行 JPEG 编码:使用 PyMuPDF 组装输出 PDF 前,可按同一并行数预编码页面 JPEG,减少保存阶段等待时间
- 📊 耗时统计更清晰:预处理统计新增墙钟耗时与实际并行数,并将原“总耗时”口径改为“页面累计耗时”,避免并行模式下误读
- 📝 使用说明同步:
SKILL.md新增大批量扫描件的并行预处理示例,并修正仅预处理示例入口
技术优化
- ✅ 扩展
scripts/test_pdf_preprocess_speed_options.py,覆盖预处理并行数解析逻辑 - ✅ 完整 244 页样本基准(150 DPI、跳过粗方向检测、medium 合并输出):串行约 32.13s;
--preprocess-jobs 4约 20.34s;--preprocess-jobs 8约 17.41s;--preprocess-jobs 0自动解析为 10 并行约 17.21s - ✅ 上述基准输出均为 244 页,页面尺寸完全一致,52.59 MB 输入生成 43.14 MB PDF
- ✅ 抽取样本前 2 页跑本地
ocrmypdf烟测,输出 2 页且可提取文字(约 3792 字符)
[2.6.2] - 2026-05-14
改进
- ⚡ 预处理 DPI 随压缩档位自动调整:
pdf-preprocess-ocr.py未显式传入--dpi时,按压缩档位选择预处理 DPI(合并压缩输出时medium=150;禁用合并压缩时medium=200;跳过压缩时保持300),避免先 300 DPI 渲染再被 medium 压缩降采样的重复成本 - ⚡ 可跳过粗方向检测:新增
--skip-coarse-rotation,适用于页面方向已经正确的扫描件,可跳过 Tesseract OSD 90° 检测;PDFPreprocessor和process_pdf()同步支持enable_coarse_rotation - ⚡ 预处理与压缩合并输出:默认在预处理阶段直接应用压缩档位的 DPI/JPEG 参数,跳过二次打开、解码、重编码;可用
--no-merge-preprocess-compress回退为旧的两阶段压缩 - ⚡ 跳过未使用页面分析:
process_page()不再执行未被后续逻辑使用的analyze_page(),减少每页一次 Canny 边缘检测 - ⚡ 投影法两阶段搜索:倾斜检测的投影剖面法改为
0.5°粗扫 +0.2°精扫,保持旧版0.2°角度网格,降低旋转搜索次数 - 📐 低 DPI 输出尺寸保真:预处理保存 PDF 时优先使用 PyMuPDF 按原始页面尺寸组装,避免 200 DPI 像素取整导致页面宽度轻微变化
技术优化
- ✅ 新增
scripts/test_pdf_preprocess_speed_options.py,覆盖跳过粗方向检测、压缩档位输出决策、合并压缩跳过逻辑和尺寸保真 - ✅ 扩展
scripts/test_pdf_preprocess_skew.py,覆盖投影法快速两阶段搜索和默认角度网格兼容 - ✅ 基于 13 页样本基准测试:300 DPI + 粗方向检测约 14.31s;200 DPI + 跳过粗方向检测约 3.58s,预处理阶段约提速 75%
- ✅ 完整 244 页预处理 + medium 压缩基准:总耗时约 87.92s,输出 244 页,页面尺寸完全一致,52.59 MB 输入生成 47.98 MB 压缩 PDF
- ✅ 完整 244 页合并输出基准:总耗时约 29.88s,输出 244 页,页面尺寸完全一致,52.59 MB 输入生成 43.14 MB PDF;与旧投影法最终矫正决策一致
[2.6.1] - 2026-05-13
修复
- 🐛 Hough 高置信误判:倾斜检测不再因 Hough 置信度高就直接采用角度,必须与投影剖面法方向一致且角度接近;当 Hough 与投影法冲突时优先采用投影法,投影法也低于阈值则跳过矫正
- 🐛 局部长线污染导致过度旋转:降低单条超长边框、影像矩形、页脚横线对 Hough 角度的支配,并加入角度集中度约束,避免第 201/202/207 类页面被误判为 4° 以上倾斜
- 🐛 轻微歪斜页漏矫正:预处理默认倾斜阈值从
0.5°调整为0.3°,覆盖第 1/5 页这类约0.4°的轻微倾斜
技术优化
- ♻️ 倾斜检测模块解耦:新增
scripts/pdf_preprocess_skew.py,将 Hough 检测、投影剖面法和角度决策从pdf-preprocess-core.py拆出;核心预处理脚本保留兼容包装方法,仅负责 PDF 流程编排 - ✅ 新增
scripts/test_pdf_preprocess_skew.py覆盖 Hough/投影冲突、低置信 Hough、接近角度取平均等决策场景 - ✅ 基于样本
251106-251231 康复医院病历(第二次入院).pdf验证重点页: - 第 1/5 页在默认参数下触发轻微矫正
- 第 201/202 页不再被 4°+ 过度旋转
- 第 207 页改为按投影法轻微矫正
- 第 110/115/117 页不再因 Hough 低估而跳过或欠矫正
- ✅ 完整 244 页仅预处理回归:默认阈值下倾斜矫正 204 页,输出页数 244、页面尺寸一致,残余角度候选(>=1.5°)为 0
[2.6.0] - 2026-05-13
新增
- 📐 Hough 竖线检测:倾斜检测新增近竖直线(>75°)支持,表格竖线贯穿页面高度,对倾斜更敏感。水平线和竖线联合检测,线长平方加权,以占比更大的结构作为矫正依据
- 📊 置信度决策机制:Hough 返回 (角度, 置信度)。高置信(多长水平线=表格结构)→ 信任 Hough;低置信(纯文字页)→ 回退投影剖面法;低置信+Hough 小角度 → 两者平均
- ⏱️ 创建时间保留:macOS 下使用
SetFile -d保留 birthtime,配合os.utime保留 mtime,所有处理阶段(预处理/压缩/OCR)均保留原文件时间戳
修复
- 🐛 300 DPI 下倾斜检测失效:
minLineLength = page_w * 20%在 300 DPI 时为 496px,表格线全部被过滤导致检测为 0°。降为 10% 后 150/300 DPI 检测结果一致 - 🐛 API 图片替换覆盖压缩:
needs_correction误用bool(img_url)判断(API 默认对每页返回 preprocessedImages URL),导致所有页面被全尺寸原图替换,压缩白做。改为检查 payload 中useDocOrientationClassify/useDocUnwarping是否启用 - 🐛 预处理短路误触发:不再因使用 PaddleOCR API 就跳过本地预处理,只有 API payload 中确实启用了方向/去畸变时才短路
- 🐛 大角度误检测:表格线导致 Hough 检测到 6-7° 虚假角度(旧版 HoughLines 含竖线污染)。重写为 HoughLinesP + 只取近水平/竖直线 + 线长加权,消除误检
- 🐛 双层 PDF 体积膨胀:OCR 叠层保存参数从
garbage=3, deflate=True升级为garbage=4, deflate_images=1, deflate_fonts=1, use_objstms=1, compression_effort=100
改进
- 📦 归档路径修复:
archive_ocr_result()新增original_source_path参数,归档目录名和伴生 .md 以原始用户文件为准,conversion_meta.json记录source_file(原始路径)+working_file(临时路径)+preprocess_meta(完整预处理参数) - 🔌 --paddle-api-extra-json 合并修复:
_build_optional_payload()现在正确合并额外 JSON 到 API payload - 📦 压缩预设调整:medium 级别 max_dimension 从 3200 降至 2000,high 从 2600 降至 1600,压缩效果从 ~10% 提升至 ~35%
[2.5.0] - 2026-05-12
新增
- 📷 拍照件自动矫正:PaddleOCR API 返回
doc_preprocessor_res.angle(0/90/180/270),非零页自动下载preprocessedImages替换 PDF 原始页面,处理 90/270° 旋转尺寸互换,坐标空间同步修正。通过--no-photo-correct禁用 - 📦 Archive 运行记录:新增
conversion_meta.json记录运行元数据(时间戳、源文件、模型、后端、页数、文本块数),参照 mineru-ocr 规范 - 📄 MD 同步输出:OCR Markdown 文本同步输出到原文件同目录(与双层 PDF 平行),同时归档到内部
archive/ - ⏱️ 文件时间戳保留:所有 PDF 输出路径通过
shutil.copystat保留原文件的 mtime/atime
改进
- 🎯 双层 PDF 坐标偏移修复:关闭 PP-OCRv5 API 端
useDocOrientationClassify+useDocUnwarping预处理。启用时 API 在服务端预处理图片(矫正/去畸变),OCR 坐标对应预处理后的图片但报告的图片尺寸不变,导致坐标偏移。关闭后坐标与原始图片一致,双层 PDF 对齐准确 - 📝 段落合并尝试与回退:尝试在双层 PDF 文字层中合并连续 OCR 行为段落文本,但因
insert_text单行限制导致合并后字号过小、选中高亮异常,已回退。段落连续文本改为在 Markdown 输出中处理 - 🔌 PaddleOCR API 预处理短路:PP-OCRv5 和 VL-1.5 均启用
useDocOrientationClassify+useDocUnwarping,pdf-preprocess-ocr.py检测到 PaddleOCR API 可用时自动跳过本地预处理(旋转/倾斜/裁剪),保留压缩阶段 - 🏷️ VL-1.5 识别完整性:补充
content、paragraph_title、section_title标签,修复部分页面内容识别丢失 - 🔢 页码过滤:
number标签块 score=0,双层 PDF 保留但 MD 输出时过滤 - 🔙 默认模型回切 PP-OCRv5:PP-OCRv5 返回行级坐标,对双层 PDF 叠层定位更精确
删除
- 🗑️ OCR 正则纠错规则层:移除
--ocr-corrections/--no-ocr-corrections参数,纠错由 agent dump/resume 语义审查完成 - 🗑️ 300+ 页流式处理任务:PaddleOCR API 确认无页数限制,分片逻辑保留但不默认触发
任务管理
- 📝 TASKS.md 重构:已完成功能归入历史区,未完成项按 v2.5.0 组织
- 📝 新增已知问题(文件时间戳、大文档内存)
[2.4.1] - 2026-05-08
技术优化
- ♻️ 脚本模块化拆分(pdf-ocr.py 2197 行 → 1230 行):
- 新建
pdf_ocr_layered.py(572 行):双层 PDF 叠层核心、OCR 结果解析、CJK 归一化 - 新建
pdf_ocr_mineru.py(502 行):MinerU API 后端 - 新建
pdf_ocr_paddle_api.py(162 行):PaddleOCR API 后端 - 扩展
pdf_runtime.py:HTTP 工具函数 + API 环境变量常量 - pdf-ocr.py 瘦身为 CLI 入口 + 后端分发(1230 行)
- ♻️ 消除跨文件重复代码:
- pdf-analyze.py 从 core 导入(-130 行,5 个重复函数)
- pdf-rotate.py 从 core 导入(-101 行,3 个重复函数)
- pdf-crop.py 从 core 导入(-24 行,1 个重复函数)
- pdf-merge.py 从 pdf-add-page-numbers 导入(-47 行,1 个重复函数)
- ♻️ 消除 subprocess 耦合:
- pdf-preprocess-ocr.py 从 subprocess 调用改为直接函数调用
run_ocr() - 减少 ~170 行 argparse 重复,运行时更高效
- pdf-preprocess-ocr.py 从 609 行瘦身至 439 行
[2.4.0] - 2026-05-08
修改
- 🧹 SKILL.md 瘦身:从 580 行精简至 418 行
- OCR 后端配置详情移入
references/ocr-backend-guide.md - 故障排除内容移入
references/troubleshooting.md - 可选依赖表格精简为一行 pip 引用
- 注意事项合并精简
- 📝 frontmatter 补充负面触发词(
不要用于:...)
删除
- 🗑️ 删除冗余文件:
README.md(与 SKILL.md 重复)、OPTIMIZATION-PLAN.md(合入 TASKS.md) - 🗑️ 删除废弃脚本:
pdf-ocr-paddle.py、pdf-ocr-rapid.py(功能已收敛至 pdf-ocr.py) - 🗑️ 删除
pdf_deskew.py(指向 pdf-deskew.py 的冗余 symlink) - 🗑️ 删除
scripts/legacy/目录(gentle-deskew.py、pdf-enhance-contrast.py 已长期归档)
文档完善
- 📝 TASKS.md 合入 OPTIMIZATION-PLAN.md 中的质量目标与后续计划
- 📝 新增
references/ocr-backend-guide.md和references/troubleshooting.md
[2.3.21] - 2026-04-09
文档完善
- 📝 进一步明确 OCR 外部后端推荐顺序:
PaddleOCR API标记为“预处理 + 双层 PDF”主链路的首选外部后端MinerU API明确为“同样支持,但当前链路更偏异步任务式”的可选后端README.md与SKILL.md补充后端选择建议与使用场景说明
[2.3.20] - 2026-04-02
改进
- 🔀 收敛 OCR 默认生产路径为“外部 API 优先 -> 本地
ocrmypdf兜底”: scripts/pdf-ocr.py的auto模式在未配置外部 API 时,不再尝试本地 Paddle 双层- 未配置外部 API 时会明确提示:建议优先配置 PaddleOCR API / MinerU API
- 外部 API 不可用时继续直接回退本地
ocrmypdf - 🔧
pdf-ocr.py与pdf-preprocess-ocr.py的--backend公开选项移除local_paddle_layered
文档完善
- 📝 更新
README.md与SKILL.md: - 默认 OCR 路径说明改为“外部 API 优先,否则
ocrmypdf” - 不再把本地 Paddle 作为公开默认方案或推荐安装依赖
- 📝 更新
TASKS.md与DECISIONS.md,记录本次生产策略收敛
[2.3.19] - 2026-04-02
改进
- 🔧 为主链路与预处理脚本统一依赖提示:
- 新增
scripts/pdf_runtime.py收敛.env加载、API 别名兼容、缺依赖提示 - 当首次缺失 OCR / 图像处理依赖时,额外提醒“安装和首次初始化可能较慢”
pdf-ocr.py、pdf-preprocess-ocr.py、pdf-preprocess-core.py、pdf-crop.py、pdf-rotate.py、pdf-analyze.py全部改用统一提示
技术优化
- ♻️ 删除重复的
.env加载 / API 别名代码,减少pdf-ocr.py与pdf-preprocess-ocr.py的重复实现
清理
- 🧹 删除零价值文件:
scripts/CLAUDE.md- 仓库内遗留
.DS_Store - 📝 明确
pdf-ocr-paddle.py与pdf-ocr-rapid.py仍为历史兼容 / 辅助脚本,暂不纳入本次清理
[2.3.18] - 2026-03-22
改进
- 🔄 清理从旧
doc-processor/ worktree 迁移遗留的技能文档: - 重写
README.md,移除过期的src/cli.py、Word、LibreOffice、requirements/*.txt等描述 - 刷新
TASKS.md,聚焦当前pdf-processor技能的真实待办与已知问题 - 更新
DECISIONS.md,补充本次迁移收尾决策与工作日志
技术优化
- 🗜️ 重构
scripts/pdf-compress.py: - 改为使用 PyMuPDF 对 PDF 做对象流压缩与资源整理
- 对页面图像执行 JPEG 重编码,压缩级别不再只是“名义参数”
- 在安装
Pillow时,按压缩级别对超大图像做缩放
文档完善
- 📝
SKILL.md许可证字段改为MIT,与当前LICENSE文件保持一致 - 📝 更新依赖说明:
pymupdf的用途扩展到 PDF 压缩,pypdf改为解密/合并辅助 - 📝 记录本次迁移收尾中
.env风险暂缓处理的边界,便于后续接手
[2.3.17] - 2026-02-16
修复
- 🐛 修复倾斜矫正中的跨页角度耦合(
scripts/pdf-preprocess-core.py): - 移除
prev_angle跨页影响逻辑,改为“每页独立检测与决策” - 避免前页角度对后页矫正结果产生抑制或误导
- 🐛 修复预处理裁剪统计误报:
- 仅在页面尺寸实际发生裁剪变化时才记录
crop 裁剪页数与页面日志改为真实裁剪结果,不再按开关状态计数- 🐛 修复预处理与 OCR 阶段日志交错问题:
- 页面处理日志补充
flush=True,避免多阶段输出串行时出现乱序粘连
技术优化
- ✅ 回归验证(样本:
20260126154823.pdf): - 预处理结果维持
倾斜矫正: 1/3(仅第一页) - 预处理 + OCR 端到端成功(
paddle_api(official:layered)) - 质量验收通过:
searchable_ratio=100%
[2.3.16] - 2026-02-13
修复
- 🐛 修复预处理中“第 1 页矫正后带偏后续页面”的问题(
scripts/pdf-preprocess-core.py): - 细倾斜检测从“单方法命中即采用”调整为“多方法一致性判定”
- 单方法小角度(<2°)结果不再直接触发矫正,降低误检
- 跨页离群保护改为“当前页不矫正”,不再直接继承前一页角度
技术优化
- ✅ 基于样本
20260126154823.pdf实测: - 预处理阶段倾斜矫正从
3/3收敛为1/3(仅第一页矫正) - 后续 MinerU 叠层链路保持正常,输出
pdf-processor/test/20260126154823_preprocess_mineru_v2.pdf
[2.3.15] - 2026-02-13
修复
- 🐛 修复 MinerU 预签名上传在部分环境下失败(
Broken pipe)的问题: scripts/pdf-ocr.py新增 MinerU 上传头解析(兼容headers返回结构)- 上传阶段不再强制附加
Content-Type,避免 OSS 签名不匹配 urllibPUT 失败时自动回退curl PUT,提升跨网络栈兼容性
技术优化
- ✅ 基于真实 API 实测验证:
- 样本:
20260126154823.pdf - 后端:
mineru_api(layered) - 输出:
pdf-processor/test/20260126154823_mineru_api.pdf - 结果:3/3 页可检索,体积比约
1.021
[2.3.14] - 2026-02-13
改进
- 🔀 默认外部 API 顺序调整为 Paddle 优先:
scripts/pdf-ocr.py的默认顺序改为paddle,mineruconfig/.env与config/.env.example默认OCR_API_ORDER改为paddle,mineru- 🔧 CLI 帮助示例更新为
paddle,mineru(pdf-ocr.py/pdf-preprocess-ocr.py)
文档完善
- 📝 更新
SKILL.md、references/paddleocr-api-guide.md、references/mineru-api-guide.md的顺序示例 - 📝 更新
TASKS.md、DECISIONS.md记录本次默认策略调整
[2.3.13] - 2026-02-12
新增
- ✨ 新增 MinerU Token 过期/鉴权失败检测(
scripts/pdf-ocr.py) - 覆盖
401/403及常见 Unauthorized/Token 失效文案 - 失败提示中明确 14 天有效期与更新地址:
https://mineru.net/apiManage/token
改进
- 🔧 MinerU 环境变量对齐与兼容增强:
- 默认 Base 变量改为
MINERU_API_BASE - 新增
MINERU_USER_TOKEN支持(用于token请求头) - 兼容旧变量:
MINERU_API_BASE_URL、MINERU_BASE_URL、MINERU_API_ENDPOINT - 🔧 修复 MinerU Base 包含
/api/v4时的路径重复拼接问题 - 🔧
pdf-preprocess-ocr.py同步透传--mineru-user-token-env
文档完善
- 📝 更新
config/.env与config/.env.example(MinerU + Paddle 顺序配置说明) - 📝 更新
references/mineru-api-guide.md(新增 Token 14 天有效期与更新流程) - 📝 更新
TASKS.md、DECISIONS.md记录本次改动
[2.3.12] - 2026-02-12
新增
- ✨ 新增
mineru_api外部后端(scripts/pdf-ocr.py) - 支持 MinerU 异步任务流程:创建任务 -> 上传文件 -> 轮询结果 -> 下载 ZIP
- 支持从 MinerU 结果包中的
middle/modelJSON 解析文本与坐标,并本地叠层输出双层 PDF - ✨ 新增 MinerU 相关参数透传(
scripts/pdf-preprocess-ocr.py)
改进
- 🔀
auto后端升级为“多外部 API 顺序优先”: - 同时支持
mineru与paddle - 外部 API 顺序支持:
--api-order、OCR_API_ORDER、.env配置顺序推断 - 🔧 新增 MinerU 环境变量与别名兼容:
- 标准变量:
MINERU_API_BASE_URL、MINERU_API_TOKEN - 别名:
MINERU_BASE_URL、MINERU_API_ENDPOINT、MINERU_TOKEN、MINERU_API_KEY
文档完善
- 📝 更新
config/.env.example:支持 MinerU + Paddle 双配置与顺序控制 - 📝 新增
references/mineru-api-guide.md - 📝 更新
SKILL.md外部 API 章节,明确双 API 与顺序策略 - 📝
TASKS.md、DECISIONS.md同步记录本次能力扩展
[2.3.11] - 2026-02-12
改进
- 🗂️ 调整可选依赖清单目录位置:
assets/optional.txt迁移为references/optional-dependencies.txt - ✅ 依赖说明归类到
references/,减少assets/目录语义混淆
文档完善
- 📝
SKILL.md依赖章节新增可选依赖清单入口(references/optional-dependencies.txt) - 📝
TASKS.md、DECISIONS.md同步记录本次迁移决策与完成状态
[2.3.10] - 2026-02-12
技术优化
- 🗂️ 将零引用历史脚本归档到
scripts/legacy/(替代直接删除): scripts/gentle-deskew.py->scripts/legacy/gentle-deskew.pyscripts/pdf-enhance-contrast.py->scripts/legacy/pdf-enhance-contrast.py- ✅ 主目录保留生产脚本,降低误删风险并提升分发可读性
文档完善
- 📝
TASKS.md增补并勾选“归档零引用历史脚本”任务 - 📝
DECISIONS.md新增 DEC-015,记录归档策略与影响
[2.3.9] - 2026-02-12
技术优化
- 🧹 清理
scripts目录冗余测试与缓存文件(保守策略): - 删除
scripts/test-paddleocr.py - 删除
scripts/__pycache__/下历史.pyc - 删除仓库内遗留
.DS_Store文件 - ✅ 保留当前生产链路与兼容脚本(如
pdf-ocr.py、pdf-preprocess-ocr.py、pdf-ocr-rapid.py),避免误删
文档完善
- 📝
TASKS.md增补并勾选“冗余脚本清理”任务 - 📝
DECISIONS.md新增 DEC-014,记录本次清理原则与范围
[2.3.8] - 2026-02-12
新增
- ✨ 新增外部 API 参考指引:
references/paddleocr-api-guide.md - 推荐法律文档优先使用
PP-OCRv5_API - 汇总 PaddleOCR 官方接口文档链接(PP-OCRv5 / PP-StructureV3 / PaddleOCR-VL / PaddleOCR-VL-1.5)
- 明确
API_URL/TOKEN获取路径与落地步骤
文档完善
- 📝
SKILL.md外部 API 章节新增 references 入口,便于协作者快速定位接入说明 - 📝
TASKS.md增补并勾选“外部 API 接入指引文档”任务 - 📝
DECISIONS.md新增 DEC-013,记录推荐模型与文档结构决策
[2.3.7] - 2026-02-12
新增
- ✨ 新增
.env配置加载能力(scripts/pdf-ocr.py、scripts/pdf-preprocess-ocr.py) - 新增参数:
--env-file、--no-env-file - 默认自动读取
pdf-processor/config/.env - ✨ 新增
config/.env.example模板 - 标准变量:
PADDLE_OCR_API_ENDPOINT、PADDLE_OCR_API_KEY - 兼容别名:
API_URL、TOKEN
改进
- 🔧 外部 API 配置兼容增强
- 自动映射
API_URL -> PADDLE_OCR_API_ENDPOINT - 自动映射
TOKEN -> PADDLE_OCR_API_KEY - 🔧 鉴权头兼容增强
- 同时发送
Authorization: Bearer <token>与token: <token> - 提升对不同网关实现的兼容性
技术优化
- 🧪 零参数链路验证:通过
config/.env自动注入 endpoint/token,无需手工export
[2.3.6] - 2026-02-12
新增
- ✨ 外部 PaddleOCR API 协议支持升级为“官方优先 + 旧协议兼容”(
scripts/pdf-ocr.py) - 新增
--paddle-api-protocol auto|official|legacy(默认auto) auto会先尝试官方请求格式(file+fileType),失败后自动尝试 legacy 协议- ✨ 新增“API OCR 结果本地叠层”能力
- 当 API 未返回
output_pdf_*时,自动解析result/data中的ocrResults/layoutParsingResults - 支持解析
prunedResult.rec_texts/rec_scores/rec_polys并本地生成双层 PDF
改进
- 🔧 外部 API 成功判定增强
- 支持官方字段
errorCode == 0 - 支持从
result或data提取有效载荷 - 🔧 OCR 结果解析增强
parse_paddle_predict_result扩展支持 dict/list 多种返回结构- 兼容 polygon 的二维点格式与扁平坐标格式
技术优化
- 🧪 新增本地 mock 回归(官方
errorCode/result/ocrResults/prunedResult)验证: - 后端链路:
paddle_api(official:layered)成功输出双层 PDF - 样本:
20260126154823.pdf,3 页均完成文字层叠加
[2.3.5] - 2026-02-12
新增
- ✨ 新增本地 Paddle 自动设备检测(
scripts/pdf-ocr.py) - 在
Windows/Linux场景自动探测 CUDA 可用性 - 检测到 CUDA 时,无需额外参数自动启用 GPU 推理
- 未检测到或初始化失败时自动回退 CPU,保证流程稳定
改进
- 🚀
auto档位在设备层面进一步自适应: - 本地检测到 GPU 时,自动联动
quality档位 - CPU 场景继续按既有策略(macOS/Windows 默认
balanced,长文档触发speed) - 🔍 本地 Paddle 日志新增设备信息输出(
device: cpu/gpu + reason),便于分发排障
技术优化
- 🧪 在当前 macOS 样本
20260126154823_input.pdf的回归实测: - 端到端命令:
scripts/pdf-preprocess-ocr.py --backend auto - 结果:
real ≈ 41.41s(日志:bench_default/run_default_after_gpu_auto.log) - 与 2.3.4 的
39.16s同量级,保持在优化后的稳定区间
[2.3.4] - 2026-02-12
新增
- ✨ 新增本地 Paddle 运行档位参数(
scripts/pdf-ocr.py/scripts/pdf-preprocess-ocr.py) --paddle-profile auto|quality|balanced|speed--paddle-long-doc-pages(长文档自动切换 speed 的页数阈值,默认 60)--keep-paddle-model-source-check(恢复模型源连通性检查)--paddle-model-source huggingface|bos(指定模型下载源)
改进
- 🚀 新增设备自适应策略(默认
--paddle-profile auto) macOS / Windows默认 CPU 场景自动选择balanced(PP-OCRv5_mobile_det/rec)- 长文档(页数达到阈值)自动切换
speed - Linux 场景默认保持
quality(可显式覆盖) - 🚀 默认关闭 PDX 模型源连通性检查(
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True) - 减少每次调用本地 Paddle 时的额外等待
- 🔧
pdf-preprocess-ocr.py改为“仅在显式传参时”透传--paddle-dpi / --paddle-det-limit-side-len - 让自动档位可真正接管参数,避免被固定值覆盖
技术优化
- 🧪 基于样本
20260126154823_input.pdf的回归实测(同机同环境): - 旧默认链路(本地 server 模型):平均
real ≈ 66.71s - 新默认链路(auto->balanced):
real ≈ 39.16s - 实测总耗时下降约
41% - 回归日志:
bench_default/run_default_after_opt.log
[2.3.3] - 2026-02-12
新增
- ✨ 新增 OCR 质量验收脚本
scripts/pdf-ocr-quality-check.py - 支持可检索页占比统计
- 支持关键词命中率统计
- 支持 CER(需参考文本)计算
- 支持输出体积比与耗时校验(可配置阈值 + JSON 报告)
改进
- 🔧
auto后端策略调整为“外部 API 优先” - 配置了
--paddle-api-endpoint或环境变量PADDLE_OCR_API_ENDPOINT时,优先走paddle_api - API 不可用时直接回退
local_ocrmypdf(避免触发本地 Paddle 模型下载) - 新增
--paddle-api-endpoint-env,支持自定义 endpoint 环境变量名 PaddleOCR改为懒加载:外部 API 路径下不再触发本地模型初始化检查- 🔧 本地双层 PDF 引擎进一步对齐 Umi-OCR 实践
fitz.Font("cjk")+insert_font持续作为中文字体嵌入主路径- CJK 空格清理增强:新增中文标点邻接空格清理,减少中文文本中异常断裂
- 🔧 一键流程保真策略升级(零参数生效)
- 预处理默认 DPI 调整为
300 - 预处理输出 JPEG 质量默认调整为
90 pdf-preprocess-ocr.py默认关闭裁剪(需--enable-crop才启用),降低不必要重采样导致的发糊- 本地 Paddle 双层渲染默认 DPI 调整为
300,提升中文识别稳定性
技术优化
- 🧭 默认自动流程保持:
- 自动识别旋转/倾斜页(无需手工传参)
auto后端优先local_paddle_layered,失败自动回退local_ocrmypdf- 修复
ocrmypdf --redo-ocr与--deskew/--clean参数冲突(redo模式下不再追加deskew/clean) - 🧪 新增回归产物(样本:
20260126154823_input.pdf) output_auto_default_noargs_v10.pdf(零参数默认链路)quality_v10.json(质量验收报告)
---
[2.3.2] - 2026-02-12
修复
- 🐛 修复预处理倾斜误判导致“应矫正页面被跳过”的问题
- 在
pdf-preprocess-core.py中为倾斜角增加合理范围拦截(>15° 视为异常值) - 修复跨页平滑初始化问题:第一页不再被“初始角度=0”误覆盖,同时保留跨页离群抑制
改进
- 🔧 OCR 默认优化策略调整为保真优先
scripts/pdf-ocr.py默认--optimize 0scripts/pdf-preprocess-ocr.py默认--optimize 0- 默认输出类型从
pdfa调整为pdf - 降低输出双层 PDF 的清晰度损失风险
- 🤖 默认工作流收敛为零参数自动判定
- 自动识别是否需要页面旋转与倾斜矫正
- 减少人工传参成本,更适合自动化 Skill 调用
文档完善
- 📝 更新
SKILL.md:补充“发糊/清晰度下降”故障排查与保真命令示例
---
[2.3.1] - 2026-02-11
新增
- ✨ 为 OCR 流程预留外部 PaddleOCR API 后端接口
scripts/pdf-ocr.py新增--backend paddle_api- 新增接口参数:
--paddle-api-endpoint、超时、重试、额外 JSON、API Key 环境变量 - 支持 API 返回
output_pdf_base64 / output_pdf_url / output_pdf_path
改进
- 🔄
scripts/pdf-preprocess-ocr.py新增后端透传参数 - 一键预处理流程可直接切换至外部 PaddleOCR API
- 外部 API 失败时默认回退本地
ocrmypdf(可关闭回退)
修复
- 🐛 修复
backend=paddle_api场景下的本地依赖误校验 - 不再在程序启动时无条件检查
ocrmypdf - 仅在实际走本地后端或回退本地时才校验本地依赖
文档完善
- 📝 更新
SKILL.md:补充外部 PaddleOCR API 预留接口说明、调用示例与故障排查 - 📝 更新
TASKS.md与DECISIONS.md:记录接口预留任务与技术决策
---
[2.3.0] - 2026-02-11
新增
- ✨ 新增统一双层 PDF 入口脚本
scripts/pdf-ocr.py - 基于
ocrmypdf的生产主路径 - 支持
skip / redo / force三种 OCR 模式 - 支持
--preprocessed场景,避免重复rotate/deskew - 支持
pdf/pdfa输出、优化等级、大图跳过阈值、超时和并行参数
改进
- 🔄 重构
scripts/pdf-preprocess-ocr.py - 由原 RapidOCR 叠层方式,切换为“预处理 + ocrmypdf”流程
- 保留自动解密能力,支持预处理跳过页/裁剪/尺寸恢复等参数
- OCR 阶段统一调用
scripts/pdf-ocr.py,降低路径分裂
技术优化
- 🧭 完成双层 PDF 主路径收敛:默认生产方案统一为
ocrmypdf - 📝 更新
SKILL.mdOCR 命令示例与故障排查,减少误用旧脚本的风险 - 📌 将遗留
pdf-ocr-rapid.py明确标注为历史兼容脚本(非默认生产路径)
---
[2.2.1] - 2026-02-11
新增
- ✨ 新增优化方案文档
OPTIMIZATION-PLAN.md - 明确两大优先方向:图像预处理升级、双层 PDF 质量稳定
- 给出分阶段路线(P0 基线、P1 预处理、P2 双层 PDF 主路径、P3 发布治理)
- 增加量化验收指标(搜索命中率、CER、单页耗时、输出体积)
改进
- 🧭 明确“双层 PDF 主路径收敛”策略:以
ocrmypdf作为默认生产方案 - 🗂️ 更新任务拆分,新增 v2.3.0 优先任务清单,聚焦你最关注的双层 PDF 与畸变矫正
文档完善
- 📝 在
DECISIONS.md新增 DEC-004,记录主路径收敛决策背景、选项与影响
---
[2.2.0] - 2026-01-29
新增
PDF 合并与页码功能
- ✨ PDF 合并工具 (
pdf-merge.py) - 支持合并多个 PDF 文件
- 可选添加页码序号
- 两种编号模式:
- 独立编号(每个文件从 1 开始)
- 连续编号(全局连续编号)
- 支持自定义页码位置(6 个位置)
- 支持自定义字体大小
- ✨ PDF 页码添加工具 (
pdf-add-page-numbers.py) - 为现有 PDF 添加页码
- 精确边距控制(毫米单位)
- 默认设置符合常用配置:
- 位置:底端右边
- 字体:Helvetica 常规 15pt
- 边距:上 10mm/下 5mm/左右 15mm
- 支持 3 种字体(Helvetica、Times、Courier)
- 支持 6 个页码位置
改进
- 📝 更新触发逻辑说明
- 明确合并和添加页码时不自动预处理
- 只有拖入文件或明确说"预处理"时才执行预处理
- 📝 更新 SKILL.md,添加新功能文档和使用示例
功能汇总
本技能现包含以下完整功能:
1. PDF 预处理 - 倾斜矫正、页面旋转、边缘裁剪 2. PDF OCR - 为扫描版 PDF 添加可搜索的文字层(双层 PDF) 3. PDF 添加页码 - 精确控制位置和边距 4. PDF 合并 - 合并多个文件并可选添加页码 5. PDF 解密 - 移除 PDF 密码保护 6. 水印去除 - 检测并移除 PDF 中的水印 7. PDF 压缩 - 压缩 PDF 文件大小
---
[2.1.0] - 2026-01-26
重大变更
- 🚀 PDF 倾斜矫正算法全面升级 - 基于工程实践指南重构
新增
核心处理模块
- ✨ 创建
pdf-preprocess-core.py统一预处理流水线 - PDF 类型自动检测(扫描件/电子原生/混合型)
- 级联式处理:类型检测 → 90° 旋转检测 → 微小倾斜矫正
- Tesseract OSD 集成(90° 倍数旋转检测)
- 多算法冗余:minAreaRect → 投影剖面法 → 霍夫变换
- 智能边界情况处理(页眉页脚排除、角度平滑)
倾斜矫正 v2.1
- ✨
pdf-deskew.py重构,使用新的核心模块 - 🎯 页面跳过功能 (
--skip-pages) - 避免误判正常页面 - 🔍 Dry-run 预览模式 (
--dry-run) - 处理前检查哪些页会被修改 - 📏 原始尺寸恢复 - 确保所有页面尺寸一致
- ✂️ 激进裁剪模式 - 旋转后自动裁剪空白边缘
- 📊 PDF 分析工具 (
pdf-analyze.py) - 生成处理建议报告
页面旋转 v2.0
- ✨
pdf-rotate.py重构,集成 Tesseract OSD - 🎯 自动旋转检测置信度控制
- 🔄 Fallback 机制(OSD 失败时使用宽高比检测)
OCR 工具改进
- 🔄 采用 ocrmypdf 作为主要 OCR 解决方案
- PyMuPDF 内置字体无法支持中文字符
- 尝试多种方法(
insert_text、insert_textbox、HTML、字体选择)均无法解决 - 最终方案:使用
ocrmypdf工具 - ✨ ocrmypdf 优势:
- 原生支持中文(基于 Tesseract)
- 自动处理双层PDF结构(文字层在下,图像在上)
- 智能预处理(自动倾斜矫正、页面旋转)
- 图像优化功能(减小文件大小 15%+)
- 支持多语言(
chi_sim+eng) - 🗑️ 移除 simple-ocr.py - 由于中文支持问题已删除
- 🗑️ 移除 RapidOCR 依赖 - 不再需要
改进
- 📝 更新 SKILL.md,添加 v2.1 新特性说明和使用示例
- 🗑️ 删除 DEPENDENCIES.md(依赖信息已整合到 SKILL.md)
- ⚙️ 优化默认参数:
skew_threshold: 0.5°(微小倾斜阈值)max_skew: 15°(防止误判)rotation_confidence: 0.5(OSD 置信度)dpi: 200(平衡质量和速度)
技术细节
级联流水线架构:
PDF 输入 → 类型检测 → 粗矫正(90°) → 精细矫正(<15°) → 裁剪 → 尺寸恢复 → 输出新增核心类和方法:
PDFPreprocessor- 统一预处理流水线类PDFType- PDF 类型枚举(SCANNED/DIGITAL/HYBRID)ProcessingResult- 处理结果数据类PageAnalysis- 页面分析结果数据类crop_blank_edges(aggressive=True)- 激进裁剪模式resize_to_original()- 原始尺寸恢复
问题修复:
- 旋转后页面变大但裁剪不完整 → 添加激进裁剪模式
- 检测算法对正常页面误判 → 添加页面跳过功能
- 所有页面尺寸不一致 → 添加原始尺寸恢复
- 无法预览处理效果 → 添加 dry-run 模式
- 双层 PDF 视觉伪影 → 优化 OCR 过滤和文字层参数
依赖变更
新增可选依赖:
pytesseract>=0.3.10- Tesseract OSD 支持(90° 旋转检测)pymupdf>=1.23.0- 矢量文本分析(PDF 类型检测)
已安装依赖:
rapidocr-onnxruntime- PDF OCR
---
[1.0.0] - 2026-01-22
重大变更
- 🔄 从 doc-processor 拆分,独立为 pdf-processor 技能
- 🔄 Word 文档转换功能迁移到 word-converter 技能
- 📝 简化依赖,移除 LibreOffice 和 Word 相关依赖
新增
- ✨ PDF 压缩功能
- 新增
pdf-compress.py脚本,压缩 PDF 文件大小 - 支持多种压缩级别(low、medium、high、maximum)
- 支持自定义图像质量(1-100)
- 支持移除元数据以进一步减小文件大小
- 显示压缩前后大小对比和压缩百分比
- ⚠️ 压缩功能仅在用户明确请求时执行,不会自动触发
功能汇总
本技能整合了以下完整功能:
1. PDF 预处理 - 倾斜矫正、页面旋转、边缘裁剪 2. PDF OCR - 为扫描版 PDF 添加可搜索的文字层(双层 PDF) 3. PDF 解密 - 移除 PDF 密码保护 4. 水印去除 - 检测并移除 PDF 中的水印 5. PDF 压缩 - 压缩 PDF 文件大小
改进
- 📝 更新 SKILL.md,专注 PDF 处理文档
- 📝 更新 DEPENDENCIES.md,移除 Word 相关依赖
- 📝 明确技能定位:专注 PDF 预处理、OCR、解密、水印去除、压缩
技术细节
- 使用 pypdf 进行 PDF 解密、加密检测和压缩
- 使用 RapidOCR 进行本地 OCR 识别和页面方向检测
- 使用 PyMuPDF (pymupdf) 创建双层 PDF、水印检测和移除
- 支持简体中文、繁体中文、英文等 80+ 种语言
- 完整流程:解密 → 去水印 → 旋转 → 矫正 → OCR
---
版本说明
v2.x 系列 - PDF 预处理优化
- v2.1.0 (2026-01-26) - 倾斜矫正算法全面升级,新增级联流水线和智能边界处理
- v2.0.0 (2026-01-22) - 初始版本,从 doc-processor 拆分
v1.x 系列 - doc-processor
- v1.0.0 - PDF 预处理基础功能(倾斜矫正、页面旋转、边缘裁剪)
- v0.2 - PDF OCR 功能(双层 PDF、页面方向检测)
- v0.3 - PDF 解密和水印去除功能
- v0.4 - PDF 压缩功能
# 外部 API 调用顺序(auto 模式)
# 该顺序就是 API 尝试顺序;支持值:mineru, paddle(逗号分隔)
OCR_API_ORDER="paddle,mineru"
# MinerU API(可选)
MINERU_API_BASE="https://mineru.net/api/v4"
MINERU_API_TOKEN="your_mineru_token_here"
# 可选:部分网关需要额外 user token 头
MINERU_USER_TOKEN=""
# 注意:MinerU API Token 约 90 天(3 个月)有效,过期后需重新申请
# 申请/更新地址:https://mineru.net/apiManage/token
# 外部 PaddleOCR API(可选)
PADDLE_OCR_API_ENDPOINT="https://your-aistudio-app.com/ocr"
PADDLE_OCR_API_KEY="your_paddle_token_here"
# 兼容别名(可选):如果你已经使用旧变量,可继续保留
# API_URL="https://your-aistudio-app.com/ocr"
# TOKEN="your_token_here"
# MINERU_API_BASE_URL="https://mineru.net"
# MINERU_BASE_URL="https://mineru.net"
# MINERU_TOKEN="your_mineru_token_here"
MIT License
Copyright (c) 2026 maoking
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MinerU API 接入指引(法律文档场景)
本文档用于指导 pdf-processor 接入 MinerU API。
1. API 模式对比
MinerU 提供两种 API:
| 维度 | Precision Extract API | Agent Lightweight API |
|---|---|---|
| Token | 需要 | 不需要(IP 限流) |
| 接口 | /api/v4/extract/task 或 /api/v4/file-urls/batch | /api/v1/agent/parse/url 或 /api/v1/agent/parse/file |
| 模型 | pipeline(默认)/ vlm(推荐)/ MinerU-HTML | 固定 pipeline 轻量模型 |
| 文件大小 | ≤ 200MB | ≤ 10MB |
| 页数限制 | ≤ 200 页 | ≤ 20 页 |
| 批量 | 支持(≤ 200 文件) | 不支持(单文件) |
| 输出 | ZIP(Markdown + JSON + 可选 docx/html/latex) | 仅 Markdown(CDN 链接) |
| 调用方式 | 异步(提交 → 轮询) | 异步(提交 → 轮询) |
本 Skill 使用 Precision Extract API(支持更复杂的文档处理和坐标提取)。
2. 适用说明
- 输入:本地 PDF / 图片 / Word / PPT / Excel 文件
- 输出:MinerU 任务结果 ZIP(含
*_middle.json/*_model.json/full.md,可选docx/html/latex) - 本 Skill 行为:自动解析结果中的文本与坐标,并本地叠层生成双层 PDF
- 不具备图像预处理能力(无方向矫正、去畸变),适合平扫件或已矫正的文档
3. API 基础配置
在 config/.env 中配置:
MINERU_API_BASE="https://mineru.net/api/v4"
MINERU_API_TOKEN="your_mineru_token_here"
MINERU_USER_TOKEN=""可选别名(脚本会自动映射):
MINERU_API_BASE_URL="https://mineru.net"
MINERU_BASE_URL="https://mineru.net"
MINERU_TOKEN="your_mineru_token_here"说明:
MINERU_API_BASE可配置为https://mineru.net/api/v4或https://mineru.net,脚本会自动兼容MINERU_USER_TOKEN为可选项,用于部分网关需要的token请求头- 每账号每日 1000 页高优先级配额,超出后降为低优先级
3.1 Token 过期提醒(90 天)
- MinerU API Token 约 90 天(3 个月)有效
- 当接口返回
401/403(错误码 A0202/A0211)时,脚本会自动提示"Token 可能过期" - 更新地址:<https://mineru.net/apiManage/token>
- 更新后请同步修改
config/.env中的MINERU_API_TOKEN
4. Precision Extract API 接口详情
4.1 提交方式
MinerU 支持三种提交方式:
| 方式 | 接口 | 说明 |
|---|---|---|
| URL 提交 | POST /api/v4/extract/task | 传入文件 URL,无需上传 |
| 文件上传 | POST /api/v4/file-urls/batch | 先获取上传地址,再 PUT 上传文件(≤50 文件/次) |
| 批量 URL | POST /api/v4/extract/task/batch | 批量传入文件 URL(≤50 URL/次) |
本 Skill 当前使用文件上传模式(/api/v4/file-urls/batch)。
4.2 文件上传流程(本 Skill 使用)
1. POST /api/v4/file-urls/batch → 获取 batch_id + 上传 URL
2. PUT 上传 URL → 上传 PDF 文件
3. GET /api/v4/extract-results/batch/{batch_id} → 轮询任务状态
4. 下载 full_zip_url 对应的结果 ZIP
5. 解析 middle/model JSON,提取文本与坐标,生成双层 PDF4.3 请求参数
提交任务通用参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
model_version | string | 否 | pipeline(默认)/ vlm(推荐,精度更高)/ MinerU-HTML(HTML 文件) |
is_ocr | bool | 否 | 是否启用 OCR,默认 false。仅 pipeline/vlm 生效 |
enable_formula | bool | 否 | 是否启用公式识别,默认 true。仅 pipeline/vlm 生效 |
enable_table | bool | 否 | 是否启用表格识别,默认 true。仅 pipeline/vlm 生效 |
language | string | 否 | 文档语言,默认 ch。仅 pipeline/vlm 生效 |
extra_formats | [string] | 否 | 额外输出格式:docx/html/latex(Markdown+JSON 为默认,无需设置) |
page_ranges | string | 否 | 页面范围,如 "2,4-6" 或 "2--2"(倒数第二页) |
callback | string | 否 | 回调通知 URL(HTTP/HTTPS),完成后 POST 推送结果 |
seed | string | 否 | 回调签名随机字符串(使用 callback 时必填) |
no_cache | bool | 否 | 是否忽略缓存,默认 false |
cache_tolerance | int | 否 | 缓存容忍时间(秒),默认 900(15 分钟) |
文件上传模式特有参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
files[].name | string | 是 | 文件名(含扩展名) |
files[].data_id | string | 否 | 业务数据 ID(≤128 字符) |
files[].is_ocr | bool | 否 | 单文件 OCR 开关 |
files[].page_ranges | string | 否 | 单文件页面范围 |
4.4 任务状态
| 状态 | 说明 |
|---|---|
waiting-file | 等待文件上传 |
pending | 排队中 |
running | 解析中 |
converting | 格式转换中 |
done | 完成 |
failed | 失败 |
4.5 响应结构
成功响应:
{
"code": 0,
"msg": "ok",
"data": {
"batch_id": "...",
"extract_result": [{
"file_name": "example.pdf",
"state": "done",
"full_zip_url": "https://cdn-mineru.openxlab.org.cn/pdf/xxx.zip",
"err_msg": "",
"extract_progress": {
"extracted_pages": 10,
"total_pages": 20,
"start_time": "2025-01-20 11:43:20"
}
}]
}
}4.6 支持的文件格式
- PDF、图片(png/jpg/jpeg/jp2/webp/gif/bmp)
- Word(doc/docx)、PPT(ppt/pptx)、Excel(xls/xlsx)
- HTML(需
model_version: "MinerU-HTML")
4.7 常见错误码
| 错误码 | 说明 | 解决方案 |
|---|---|---|
| A0202 | Token 无效 | 检查 Token 或 Bearer 前缀 |
| A0211 | Token 过期 | 更新 Token |
| -500 | 参数错误 | 检查参数类型和 Content-Type |
| -60002 | 文件格式匹配失败 | 确保文件名含正确扩展名 |
| -60005 | 文件超过 200MB | 压缩或拆分文件 |
| -60006 | 页数超过 200 页 | 拆分文件或使用 page_ranges |
| -60018 | 每日提取任务达到上限 | 次日再试 |
5. 顺序控制(与 Paddle 共存)
当同时配置 MinerU 与 Paddle API 时,可通过以下变量控制 auto 模式优先级:
OCR_API_ORDER="paddle,mineru"支持值:mineru、paddle(逗号分隔)。
6. 参考文档
- MinerU 官方 API 文档:<https://mineru.net/apiManage/docs>
- MinerU 产品文档:<https://mineru.net/doc/docs/>
- 输出文件结构说明:<https://opendatalab.github.io/MinerU/reference/output_files/>
OCR 后端配置与对比指南
后端选择建议
| 需求 | 推荐方案 | 说明 |
|---|---|---|
| 可搜索双层 PDF + 拍照件矫正(推荐) | PaddleOCR VL-1.5 API(auto 默认优先) | 支持方向矫正、去畸变、版面分析、图表识别、印章识别;返回矫正后图片可替换 PDF 原始页面 |
| 可搜索双层 PDF(PP-OCRv5 高速) | PaddleOCR PP-OCRv5 API | 纯 OCR,速度更快,支持手写体/竖排文本,适合平扫件 |
| 已接入 MinerU 并复用其服务 | --backend mineru_api 或 auto | 支持双层 PDF + 结构化解析(Markdown/JSON/docx/html/latex),但无图像预处理能力 |
| 尚未配置 API | pdf-preprocess-ocr.py 默认 auto | 提示先配 API,然后回退 ocrmypdf |
| 追求极致稳健兜底 | --backend local_ocrmypdf | 标准实现,成熟稳定 |
| 需要归档标准 | --backend local_ocrmypdf --output-type pdfa | PDF/A-2b 格式 |
历史保留的本地 PaddleOCR 双层 PDF 实现已移至 scripts/pdf_ocr_paddle_local.py。该实现不属于公开默认后端,后续如需在高性能本地硬件上恢复实验,可通过内部编排接入。推荐 API 配置方式
# 使用本地 config/.env 管理 API 配置
cp config/.env.example config/.env
# 在 config/.env 中填写:
# OCR_API_ORDER="paddle,mineru"
# PADDLE_OCR_API_ENDPOINT="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
# PADDLE_OCR_API_KEY="..."
# MINERU_API_BASE="https://mineru.net/api/v4"
# MINERU_API_TOKEN="..."外部 API 协议说明
PaddleOCR API(异步任务模式)
- 推荐首选后端:具备方向矫正、去畸变、版面分析等图像预处理能力,尤其适合拍照件
- 模型:
PaddleOCR-VL-1.5(默认推荐):block 级结构,版面分析 + 方向/去畸变矫正 + 图表识别 + 印章识别 + 异形框定位PP-OCRv5:文本行级 OCR,速度更快,支持手写体/竖排文本PP-StructureV3:复杂版面/表格/图文混排- 任务提交:
POST multipart/form-data到/api/v2/ocr/jobs - 结果轮询:
GET /api/v2/ocr/jobs/{jobId}(状态 pending → running → done/failed) - 结果下载:JSONL 格式,每行包含一页 OCR 结果(文字 + 坐标 + 矫正图片)
- 鉴权方式:
Authorization: token {TOKEN} - PP-OCRv5 独有参数:
useTextlineOrientation(文本行方向矫正)、textDetLimitSideLen、textDetThresh等 - VL-1.5 独有参数:
useLayoutDetection、useChartRecognition、layoutShapeMode、promptLabel、restructurePages等 - 本地叠层:从 JSONL 解析坐标后本地生成双层 PDF
- 环境变量别名:
TOKEN→PADDLE_OCR_API_KEY、API_URL→PADDLE_OCR_API_ENDPOINT - 详细参数说明:
references/paddleocr-api-guide.md
MinerU API(异步任务)
- 文档结构解析后端:擅长版面分析、公式/表格提取、多格式输出(Markdown/JSON/docx/html/latex)
- 不具备图像预处理能力(无方向矫正、去畸变),适合平扫件或已矫正的文档
- 模型:
pipeline(默认)/vlm(推荐,精度更高) /MinerU-HTML(HTML 文件) - 提交方式:
- URL 模式:
POST /api/v4/extract/task(传入文件 URL) - 文件上传:
POST /api/v4/file-urls/batch(获取上传地址 → PUT 上传,≤50 文件/次) - 批量 URL:
POST /api/v4/extract/task/batch(≤50 URL/次) - 结果轮询:
GET /api/v4/extract-results/batch/{batch_id} - 支持
page_ranges参数分段处理(上限 200 页/文件) - 支持
extra_formats输出 docx/html/latex - 支持回调通知(
callback+seed签名验证) - Token 时效约 90 天(3 个月),过期提示更新:<https://mineru.net/apiManage/token>
- 每日配额 1000 页高优先级,超出后降为低优先级
- 支持 PDF/图片/Word/PPT/Excel 等多格式输入
- 详细参数说明:
references/mineru-api-guide.md
{
"description": "OCR 文本上下文纠错规则。每条规则包含正则表达式和替换文本,按顺序依次应用到 OCR 结果上。",
"usage": "编辑此文件即可扩充规则;也可通过 --ocr-corrections <file> 加载自定义规则(与基础规则合并)",
"rules": [
{"pattern": "性别[芝兰芳芙]", "replacement": "性别女", "note": "性别女常见误识别"},
{"pattern": "性别[暮幕另罗量]", "replacement": "性别男", "note": "性别男常见误识别"},
{"pattern": "年能", "replacement": "年龄", "note": "年龄误识别"},
{"pattern": "(\\d)些(?=[,。、\\s\\n))]|$)", "replacement": "\\1岁", "note": "数字后'岁'误为'些'"},
{"pattern": "脑疵", "replacement": "脑疝", "note": "脑疝误识别"},
{"pattern": "狭實", "replacement": "狭窄", "note": "狭窄误识别"},
{"pattern": "(颅内|颈内|基底|冠状|椎)动脑", "replacement": "\\1动脉", "note": "动脉误为动脑"},
{"pattern": "烹治", "replacement": "意识", "note": "意识误识别"},
{"pattern": "降碍", "replacement": "障碍", "note": "障碍误识别"},
{"pattern": "偏[備癱]", "replacement": "偏瘫", "note": "偏瘫误识别"},
{"pattern": "[癲癫][癇癎痫]", "replacement": "癫痫", "note": "癫痫变体"},
{"pattern": "大(面积?)脆板塞", "replacement": "大\\1脑梗塞", "note": "脑梗塞误识别"},
{"pattern": "气產", "replacement": "气管", "note": "气管误识别"},
{"pattern": "碱遐", "replacement": "减弱", "note": "体格检查-减弱"},
{"pattern": "滅退", "replacement": "减退", "note": "体格检查-减退"},
{"pattern": "过礅", "replacement": "过敏", "note": "过敏误识别"},
{"pattern": "法阮", "replacement": "法院", "note": "法院误识别"},
{"pattern": "叛决", "replacement": "判决", "note": "判决误识别"},
{"pattern": "诉论", "replacement": "诉讼", "note": "诉讼误识别"},
{"pattern": "答告人", "replacement": "被告人", "note": "被告人误识别"},
{"pattern": "证剧(?=[,。、\\s\\n))])", "replacement": "证据", "note": "证据误识别"},
{"pattern": "沙库巴去", "replacement": "沙库巴曲", "note": "药名:曲误为去"},
{"pattern": "在乙拉西坦", "replacement": "左乙拉西坦", "note": "药名:左误为在"},
{"pattern": "气室切开", "replacement": "气管切开", "note": "气管误识别"},
{"pattern": "湍謩捾醌", "replacement": "温馨提示", "note": "温馨提示乱码"},
{"pattern": "继续继续", "replacement": "继续", "note": "重复词"},
{"pattern": "改天册", "replacement": "改用天册", "note": "替加环素品牌名前缺动词"},
{"pattern": "性别冬", "replacement": "性别女", "note": "性别女误识别"}
]
}
# PDF Processor - 依赖(PDF 处理功能)
# PDF 转图像
pdf2image>=1.17.0
# 图像处理
opencv-python>=4.9.0
# 图像操作
pillow>=10.0.0
# 数值计算
numpy>=1.24.0
# PDF 文字层叠加、解密、水印检测、压缩
pymupdf>=1.23.0
# PDF 解密、合并辅助
pypdf>=3.17.0
# 本地 Paddle 双层实验后端(非默认生产链路,需要时再安装)
# paddleocr
# paddlepaddle
外部 PaddleOCR API 接入指引
本文档用于指导 pdf-processor 使用外部 PaddleOCR API 完成 OCR,再由本 Skill 自动生成双层 PDF。
若需接入 MinerU API,请另见:references/mineru-api-guide.md当 Paddle 与 MinerU 同时配置时,可在config/.env中通过OCR_API_ORDER指定优先级(如paddle,mineru)。
1. 推荐模型与使用建议
- 推荐首选(双层 PDF):
PP-OCRv5(默认)— 行级 OCR 坐标,双层 PDF 叠层定位最精确 - 推荐首选(MD 输出):
PaddleOCR-VL-1.5— 版面分析 + 方向矫正 + 去畸变 + 印章识别 + 异形框定位,94.5% 精度(OmniDocBench v1.5) - 适用场景:合同、诉讼材料、证据扫描件、拍照件等法律文档
- 说明:外部 API 返回 OCR 结构化结果,本 Skill 基于返回结果自动叠层生成双层 PDF
模型对比:
| 模型 | 特点 | 适用场景 |
|---|---|---|
PaddleOCR-VL-1.5 | block 级结构、版面分析、方向/去畸变矫正、图表识别、印章识别、异形框定位 | 拍照件、复杂版面、需 MD 输出的文档 |
PP-OCRv5 | 文本行级 OCR、速度更快、支持手写体/竖排文本 | 平扫件、纯文字文档、双层 PDF 首选 |
PP-StructureV3 | 复杂版面/表格/图文混排 | 表格密集型文档 |
当前 PaddleX 版本 3.4.0,PaddlePaddle 版本 3.2.1
2. 官方文档入口
- PaddleOCR-VL-1.5:https://ai.baidu.com/ai-doc/AISTUDIO/Cmkz2m0ma
- PP-OCRv5:https://ai.baidu.com/ai-doc/AISTUDIO/Kmfl2ycs0
- PP-StructureV3:https://ai.baidu.com/ai-doc/AISTUDIO/Fmfz6oh2e
- PaddleOCR-VL(旧版):https://ai.baidu.com/ai-doc/AISTUDIO/2mh4okm66
3. API_URL 与 TOKEN 获取方式
1. 访问 https://aistudio.baidu.com/paddleocr/task 2. 选择或创建对应 API 服务 3. 打开该服务的"API 调用示例" 4. 复制示例中的 API_URL 与 TOKEN
4. 在本 Skill 中配置
4.1 推荐做法(使用 config/.env)
cp config/.env.example config/.env在 config/.env 中填写:
PADDLE_OCR_API_ENDPOINT="https://your-aistudio-app.com/ocr"
PADDLE_OCR_API_KEY="your_access_token"兼容别名(脚本会自动映射):
API_URL="https://your-aistudio-app.com/ocr" # → PADDLE_OCR_API_ENDPOINT
TOKEN="your_access_token" # → PADDLE_OCR_API_KEY4.2 运行命令
# 零参数自动流程(默认使用 PP-OCRv5,适用双层 PDF)
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf
# 显式指定后端和模型
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend paddle_api --paddle-model PP-OCRv5
# 使用 VL-1.5 获得 MD 输出(版面分析 + 表格/公式识别)
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend paddle_api --paddle-model PaddleOCR-VL-1.55. API 协议说明
5.1 鉴权
Authorization: token {TOKEN}
Content-Type: application/json5.2 通用请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
file | string | 是 | 文件的 Base64 编码(或服务器可访问的 URL)。默认超过 100 页的 PDF 仅处理前 100 页,可通过产线配置 Serving.extra.max_num_input_imgs: null 解除限制 |
fileType | integer | 否 | 0 = PDF 文件,1 = 图像文件。若缺失则根据 URL 推断 |
5.3 PP-OCRv5 可选参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
useDocOrientationClassify | boolean | false | 自动检测并矫正 0°/90°/180°/270° 方向 |
useDocUnwarping | boolean | false | 自动去畸变矫正(透视/弯曲) |
useTextlineOrientation | boolean | false | 自动识别和矫正 0°/180° 文本行方向 |
textDetLimitSideLen | integer | 64 | 文本检测图像最大边长限制(像素) |
textDetLimitType | string | min | 边长限制类型:min(不小于此值)/ max(不大于此值) |
textDetThresh | number | 0.3 | 文本检测概率阈值(0-1),越低检测越敏感 |
textDetBoxThresh | number | 0.6 | 文本检测框得分阈值(0-1),越高框质量要求越严 |
textDetUnclipRatio | number | 1.5 | 文本检测框扩张系数,越大文本框越宽松 |
textRecScoreThresh | number | 0.0 | 文本识别置信度阈值(0-1),低于此值的识别结果被丢弃 |
visualize | boolean | null | 是否返回可视化结果图。true=返回,false=不返回,null=遵循产线配置。开启后增加响应时间 |
本 Skill 通过异步任务接口调用,以上参数通过 optionalPayload 传入。PP-OCRv5 响应结构(异步任务 JSONL)
本 Skill 使用异步任务模式(/api/v2/ocr/jobs),每行 JSONL 对应一个子批次结果:
{
"result": {
"ocrResults": [
{
"prunedResult": {
"model_settings": {},
"doc_preprocessor_res": { "angle": 0, "model_settings": {} },
"dt_polys": [],
"rec_texts": ["识别文字..."],
"rec_scores": [0.99],
"rec_polys": [],
"rec_boxes": [],
"textline_orientation_angles": [0]
},
"ocrImage": "https://...(OCR 处理后图像 URL)",
"docPreprocessingImage": "https://...(方向/扭曲矫正后图像 URL)",
"inputImage": "https://...(原始输入图像 URL)"
}
],
"preprocessedImages": ["https://...(每页矫正后图像 URL)"],
"dataInfo": {
"numPages": 4,
"pages": [{"width": 1190, "height": 1682}]
}
}
}关键字段说明:
| 字段 | 说明 |
|---|---|
prunedResult.rec_texts | 识别文本数组 |
prunedResult.rec_scores | 对应置信度数组 |
prunedResult.rec_polys / dt_polys | 文本框四点坐标 |
prunedResult.doc_preprocessor_res.angle | 方向检测角度(0/90/180/270),非零表示页面被旋转矫正 |
ocrImage | OCR 处理后的可视化图像 URL |
docPreprocessingImage | 方向/扭曲矫正后的图像 URL |
inputImage | 原始输入图像 URL |
preprocessedImages | 每页矫正后图像 URL 列表(若启用了方向/扭曲矫正) |
dataInfo.pages[i] | 每页原始尺寸(width, height) |
5.4 PaddleOCR-VL-1.5 可选参数
图像预处理
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
useDocOrientationClassify | boolean | false | 自动检测并矫正 0°/90°/180°/270° 方向 |
useDocUnwarping | boolean | false | 自动矫正扭曲图片(褶皱、倾斜等) |
minPixels | number | null | 最小图像尺寸(像素),输入图片太小、文字看不清时适当调高 |
maxPixels | number | null | 最大图像尺寸(像素),输入图片过大、处理变慢时适当调低 |
版面检测
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
useLayoutDetection | boolean | false | 版面区域检测排序模块,自动检测文档各区域并排序 |
layoutThreshold | number | 0.5 | 版面模型得分阈值(0-1),越高过滤越严格 |
layoutNms | boolean | false | 是否使用 NMS 后处理,移除重复/高度重叠的区域框 |
layoutUnclipRatio | number | 1.0 | 版面检测框扩张系数(>0),越大文本框越宽松 |
layoutMergeBboxesMode | string | large | 重叠框过滤方式:large(保留最大外框,删除内部框)/ small(保留内部小框,删除外部框)/ union(内外框都保留) |
layoutShapeMode | string | auto | 检测框几何形状:rect(矩形)/ quad(四边形)/ poly(多边形)/ auto(自动) |
内容识别
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
useChartRecognition | boolean | false | 图表解析模块,自动将柱状图、饼图等转换为表格 |
promptLabel | string | ocr | VL prompt 类型,仅 useLayoutDetection=false 时生效。可选:ocr / formula / table / chart |
repetitionPenalty | number | null | 重复抑制强度。出现重复文字/表格内容时适当调高 |
temperature | number | null | 识别稳定性。结果不稳定或出现幻觉时调低;漏识别时可略微调高 |
topP | number | null | 结果可信范围。结果发散、不够可信时适当调低 |
多页处理
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
restructurePages | boolean | false | 多页重构:跨页表格合并 + 段落标题识别 |
mergeTables | boolean | true | 跨页表格合并,仅 useLayoutDetection=false 时生效 |
relevelTitles | boolean | true | 段落标题级别识别,仅 useLayoutDetection=false 时生效 |
输出控制
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
prettifyMarkdown | boolean | false | 输出美化后的 Markdown 文本 |
showFormulaNumber | boolean | false | Markdown 文本中是否包含公式编号 |
visualize | boolean | null | 是否返回可视化结果图及中间图像。true=返回,false=不返回,null=遵循产线配置。开启后增加响应时间 |
本 Skill 通过异步任务接口调用,以上参数通过 optionalPayload 传入。PaddleOCR-VL-1.5 响应结构(异步任务 JSONL)
本 Skill 使用异步任务模式(/api/v2/ocr/jobs),每行 JSONL 对应一个子批次结果:
{
"result": {
"layoutParsingResults": [
{
"prunedResult": {
"parsing_res_list": [
{
"block_label": "text",
"block_content": "识别文字...",
"block_bbox": [x1, y1, x2, y2],
"block_id": 0,
"block_order": 0,
"group_id": 0
}
],
"width": 1190,
"height": 1682
},
"markdown": {
"text": "# 标题\n\n正文内容...",
"images": { "img_0.jpg": "base64..." }
},
"outputImages": { "output_0.jpg": "base64..." },
"inputImage": "base64..."
}
],
"preprocessedImages": ["https://...(每页矫正后图像 URL)"],
"dataInfo": {
"numPages": 4,
"pages": [{"width": 1190, "height": 1682}]
}
}
}关键字段说明:
| 字段 | 说明 |
|---|---|
prunedResult.parsing_res_list | 版面解析块列表 |
parsing_res_list[].block_label | 块类型:text/title/doc_title/header/footer/list/reference/abstract/catalog/code/table/table_caption/number/content/paragraph_title/section_title/seal |
parsing_res_list[].block_content | 块内文本内容(含 Markdown/LaTeX 标记) |
parsing_res_list[].block_bbox | 块边界框(四点坐标) |
markdown.text | 完整 Markdown 格式文本 |
markdown.images | Markdown 中引用的图片(相对路径 → Base64 数据) |
outputImages | 可视化结果图(JPEG 格式,Base64 编码) |
inputImage | 原始输入图像(JPEG 格式,Base64 编码) |
preprocessedImages | 每页矫正后图像 URL 列表(若启用了方向/扭曲矫正) |
5.5 本 Skill 的异步任务模式
本 Skill 通过 PaddleOCR 云服务的异步任务接口调用:
- 任务提交:
POST multipart/form-data到/api/v2/ocr/jobs - 结果轮询:
GET /api/v2/ocr/jobs/{jobId}(pending → running → done/failed) - 结果格式:JSONL,每行包含一页或一批 OCR 结果(文字 + 坐标 + 矫正图片)
- 本地叠层:从 JSONL 解析坐标后本地生成双层 PDF
5.6 /restructure-pages 端点(可选)
用于对多页 PDF 解析结果进行重构,支持跨页表格合并和段落标题级别识别。
端点:POST /restructure-pages
请求参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mergeTables | boolean | true | 跨页表格合并,仅 useLayoutDetection=false 时生效 |
relevelTitles | boolean | true | 段落标题级别识别,仅 useLayoutDetection=false 时生效 |
concatenatePages | boolean | false | 多页 PDF 解析结果重构 |
prettifyMarkdown | boolean | false | 美化 Markdown 输出 |
showFormulaNumber | boolean | false | 包含公式编号 |
pages | array | 必填 | 每页元素包含 prunedResult(来自 /layout-parsing 返回)和 markdownImages(来自 /layout-parsing 返回的 markdown.images) |
响应格式:
{
"errorCode": 0,
"result": {
"layoutParsingResults": [
{
"prunedResult": { "parsing_res_list": [...] }
}
]
}
}5.7 错误码
| errorCode | HTTP 状态码 | errorMsg | 说明 |
|---|---|---|---|
| 0 | 200 | "Success" | 成功 |
| 非零 | 等于 HTTP 状态码 | 具体错误描述 | 错误(鉴权失败、参数错误、服务异常等) |
响应示例:
// 成功
{ "logId": "uuid", "errorCode": 0, "errorMsg": "Success", "result": {...} }
// 失败
{ "logId": "uuid", "errorCode": 401, "errorMsg": "Token 无效或已过期" }5.8 限制与注意事项
1. PDF 页数限制:默认超过 100 页的 PDF 仅处理前 100 页。可在产线配置文件添加 Serving.extra.max_num_input_imgs: null 解除限制。本 Skill 的异步任务模式实测 300 页+ 无限制。 2. 可视化开销:启用 visualize=true 会显著增加结果返回时间 3. `promptLabel` 生效条件:仅当 useLayoutDetection=false 时生效 4. `mergeTables` 和 `relevelTitles` 生效条件:仅当 useLayoutDetection=false 时生效 5. 跨页表格合并/标题识别:需设置 restructurePages=true,通过 infer 参数或 /restructure-pages 端点实现 6. Base64 编码:大文件 Base64 编码后体积增加约 33%,建议图片文件通过 URL 方式提交
6. OCR dump/resume 工作流(Agent 纠错)
支持 OCR → Agent 审查 → 修正 → 生成 PDF 的完整流程:
# Step 1: OCR 识别,结果存入 JSON(不生成 PDF)
python3 scripts/pdf-ocr.py -i input.pdf --ocr-dump /tmp/ocr_dump.json
# Step 2: Agent 审查可读文本 /tmp/ocr_dump_readable.txt,发现 OCR 错误
# 直接编辑 dump JSON 或生成 corrections 文件 [{from, to}, ...]
# Step 3: 加载修正后的 dump,生成双层 PDF
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --ocr-resume /tmp/ocr_dump.json --corrections-file /tmp/corrections.json7. 安全注意事项
TOKEN属于敏感凭证,不要提交到 Git 仓库config/.env已被仓库忽略;仅提交config/.env.example- 若凭证泄露,请立即在服务端吊销并重新生成
PDF 单项工具参数参考
本文档收纳 SKILL.md 中不需要常驻加载的命令细节。默认一键流程仍以 scripts/pdf-preprocess-ocr.py 为入口。
输出命名规则
- 输出文件不覆盖原始文件。
- 原文件名已有日期前缀时原样保留。
- 输出路径冲突时在文件名末尾追加
_1、_2等序号。 - 示例:
合同.pdf->合同_已处理.pdf;若同名文件已存在,则为合同_已处理_1.pdf。
预处理参数
# 一键预处理 + OCR
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf
# 只预处理,不 OCR
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf --preprocess-only
# 只页面矫正,不压缩、不 OCR
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf \
--preprocess-only --no-compress
# 页面方向已正确的大文件提速
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf \
--skip-coarse-rotation --preprocess-jobs 6 --preprocess-chunk-pages 80
# 需要裁剪白边时显式启用
python3 scripts/pdf-preprocess-ocr.py --input input.pdf --output output.pdf --enable-crop默认 medium 合并输出参数:
| 档位 | 预处理 DPI | JPEG 质量 | 色度子采样 | 适用场景 |
|---|---|---|---|---|
low | 300 | 85 | 0 | 打印或高清保留 |
medium | 200 | 72 | 1 | 默认,兼顾法院上传与放大阅读 |
high | 130 | 45 | 2 | 文件大小限制严格 |
OCR 参数
# 默认 auto
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf
# 仅对无文字层页面做 OCR
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --mode skip
# 强制全量重建 OCR 层,高风险
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --mode force
# 已预处理文件,不重复 rotate/deskew
python3 scripts/pdf-ocr.py -i preprocessed.pdf -o output.pdf --preprocessed
# 指定 API 顺序
python3 scripts/pdf-ocr.py -i input.pdf -o output.pdf --backend auto --api-order paddle,mineru压缩参数
单独压缩只在用户明确要求“压缩 PDF”时执行。
# 默认中等压缩
python3 scripts/pdf-compress.py -i input.pdf -o output.pdf
# 指定压缩级别
python3 scripts/pdf-compress.py -i input.pdf -o output.pdf --level high
# 移除元数据
python3 scripts/pdf-compress.py -i input.pdf -o output.pdf --remove-metadata独立压缩脚本的参数与统一入口的合并输出参数不是同一套实现;统一入口的 medium 默认更偏扫描件文字清晰度。
| 级别 | JPEG 质量 | 等效 DPI | 适用场景 |
|---|---|---|---|
low | 85 | 300 DPI | 打印 |
medium | 65 | 200 DPI | 屏幕阅读 |
high | 45 | 150 DPI | 小体积归档 |
页码参数
# 默认:底端右边,Helvetica 15pt,上10mm/下5mm/左右15mm
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf
# 自定义位置和字体大小
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf \
--position bottom-center --font-size 12
# 自定义边距
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf \
--margin-top 15 --margin-bottom 10
# 从第 5 页开始编号
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf --start 5
# 使用 Times 字体
python3 scripts/pdf-add-page-numbers.py -i input.pdf -o output.pdf --font times页码位置:bottom-right、bottom-center、bottom-left、top-right、top-center、top-left。
字体:helv、times、cour。
合并参数
# 基本合并
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf file3.pdf -o merged.pdf
# 合并并为每个文件独立编号
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf -o merged.pdf --add-numbers
# 合并并全局连续编号
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf -o merged.pdf --add-numbers --continuous
# 自定义页码位置和字体
python3 scripts/pdf-merge.py -i file1.pdf file2.pdf -o merged.pdf \
--add-numbers --position bottom-center --font-size 12合并只做合并和可选编号,不自动预处理输入文件。
故障排除
PDF 预处理报错
检查可选依赖是否已安装:
pip install pdf2image opencv-python pillow numpy
# macOS
brew install poppler
# Linux
sudo apt-get install poppler-utilsOCR 识别失败
# 优先确认是否已配置外部 API;如果没有,可先配置 config/.env
# 若暂时不配 API,则至少确保本地兜底依赖已安装
pip install ocrmypdf
# macOS
brew install tesseract tesseract-lang
# Linux
sudo apt-get install tesseract-ocr tesseract-ocr-chi-sim外部 PaddleOCR API 调用失败
1. 先检查接口地址与端口 2. 官方协议请确认请求体包含 file + fileType(0) 3. 确认响应是 errorCode=0,且 result/data 中包含 ocrResults 或 layoutParsingResults 4. 若服务端直接返回成品 PDF,也支持 output_pdf_base64 / output_pdf_url / output_pdf_path 5. 协议不一致时可切换:--paddle-api-protocol official|legacy 6. 如需临时保障可用性,移除 --no-paddle-fallback-local(允许回退本地)
双层 PDF 看起来发糊 / 清晰度下降
# 一键流程默认 medium 合并输出:约 200 DPI + JPEG 质量 72 + 默认不裁剪
python3 scripts/pdf-preprocess-ocr.py -i input.pdf -o output.pdf
# 如需进一步提升清晰度
python3 scripts/pdf-preprocess-ocr.py -i input.pdf -o output.pdf \
--dpi 300 --pdf-jpeg-quality 95
# 若想控制体积
python3 scripts/pdf-preprocess-ocr.py -i input.pdf -o output.pdf \
--compress-level high中文乱码
确保使用 UTF-8 编码。
"""
OCR dump/resume 模块 + Agent 修正。
工作流:
Step 1: pdf-ocr.py -i input.pdf -o output.pdf --ocr-dump /tmp/ocr_dump.json
→ OCR 识别,结果存入 JSON,同时生成可读文本,不生成 PDF
Step 2: agent 审查可读文本,发现 OCR 错误
→ 直接编辑 dump JSON 文件中的 text 字段,或生成 corrections 文件
Step 3: pdf-ocr.py -i input.pdf -o output.pdf --ocr-resume /tmp/ocr_dump.json
→ 加载 dump(含 agent 修正)+ 生成双层 PDF
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
_SKILL_ROOT = Path(__file__).resolve().parent.parent
ARCHIVE_DIR = _SKILL_ROOT / "archive"
# ---------- 序列化 / 反序列化 ----------
def _entries_to_serializable(entries: list[dict]) -> list[dict]:
"""将 page_entries 转为 JSON 可序列化格式。"""
result = []
for entry in entries:
rows = []
for text, score, poly in entry.get("rows", []):
rows.append({
"text": text,
"score": score,
"poly": poly,
})
result.append({
"rows": rows,
"width": entry.get("width"),
"height": entry.get("height"),
})
return result
def _entries_from_serializable(data: list[dict]) -> list[dict]:
"""从 JSON 格式恢复 page_entries(含 tuple rows)。"""
entries = []
for page in data:
rows = []
for block in page.get("rows", []):
text = block.get("text", "")
score = block.get("score", 1.0)
poly = block.get("poly", [[0, 0], [0, 0], [0, 0], [0, 0]])
rows.append((text, score, poly))
entries.append({
"rows": rows,
"width": page.get("width"),
"height": page.get("height"),
})
return entries
def dump_page_entries(
entries: list[dict],
path: str | Path,
source: str = "",
model: str = "",
) -> None:
"""将 OCR 结果保存为 JSON 文件,供 agent 审查。"""
p = Path(path)
data = {
"source": source,
"model": model,
"total_pages": len(entries),
"pages": _entries_to_serializable(entries),
}
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_page_entries(path: str | Path) -> tuple[list[dict], dict]:
"""从 dump 文件加载 OCR 结果。
Returns:
(page_entries, metadata_dict)
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"OCR dump 文件不存在: {p}")
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
pages = data.get("pages", [])
entries = _entries_from_serializable(pages)
metadata = {
"source": data.get("source", ""),
"model": data.get("model", ""),
}
return entries, metadata
# ---------- 可读文本生成 ----------
def generate_readable_text(entries: list[dict]) -> str:
"""从 page_entries 生成连续可读文本,同段落行自动拼接,段落间空行分隔。"""
paragraphs: list[str] = []
current: list[str] = []
had_wrap = False
prev_y_bot: float | None = None
prev_x_left: float = 0.0
prev_h: float = 0.0
for entry in entries:
for text, score, poly in entry.get("rows", []):
content = text.strip()
if not content or score <= 0:
continue
if not poly or len(poly) < 4:
if current:
paragraphs.append("".join(current))
current = []
paragraphs.append(content)
prev_y_bot = None
had_wrap = False
continue
ys = [p[1] for p in poly]
xs = [p[0] for p in poly]
y_top = min(ys)
y_bot = max(ys)
x_left = min(xs)
h = max(y_bot - y_top, 1.0)
if prev_y_bot is None:
current = [content]
had_wrap = False
prev_y_bot = y_bot
prev_x_left = x_left
prev_h = h
continue
y_gap = y_top - prev_y_bot
min_h = max(min(prev_h, h), 1.0)
x_diff = x_left - prev_x_left
# 段落边界:负间距(同行重叠)、大间距(跨段落)
if y_gap <= 0 or y_gap >= min_h:
if current:
paragraphs.append("".join(current))
current = [content]
had_wrap = False
else:
is_wrap = x_diff < -min_h * 0.3
is_same_margin = abs(x_diff) < min_h * 0.15
if is_wrap:
current.append(content)
had_wrap = True
elif is_same_margin and had_wrap:
current.append(content)
else:
if current:
paragraphs.append("".join(current))
current = [content]
had_wrap = False
prev_y_bot = y_bot
prev_x_left = x_left
prev_h = h
if current:
paragraphs.append("".join(current))
return "\n\n".join(paragraphs)
# ---------- Agent 修正(from/to 文本替换)----------
def load_agent_corrections(path: str | Path) -> list[tuple[str, str]]:
"""加载 agent 修正文件。
JSON 格式:
[
{"from": "性别芝", "to": "性别女"},
{"from": "沙库巴去缬沙坦", "to": "沙库巴曲缬沙坦"}
]
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"修正文件不存在: {p}")
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
raise ValueError("修正文件格式错误:期望 JSON 数组 [{from, to}, ...]")
corrections = []
for item in data:
if isinstance(item, dict):
src = item.get("from")
dst = item.get("to")
if src and dst:
corrections.append((str(src), str(dst)))
return corrections
def apply_agent_corrections(
entries: list[dict],
corrections: list[tuple[str, str]],
quiet: bool = False,
) -> tuple[list[dict], int]:
"""对 page entries 应用 agent 的 from/to 文本替换。"""
total = 0
for entry in entries:
if "rows" not in entry:
continue
new_rows = []
for text, score, poly in entry["rows"]:
corrected = text
for src, dst in corrections:
if src in corrected:
corrected = corrected.replace(src, dst)
if corrected != text:
total += 1
new_rows.append((corrected, score, poly))
entry["rows"] = new_rows
if total and not quiet:
print(f" Agent 修正: {total} 处文本已纠正")
return entries, total
# ---------- Archive 归档(仅保存 MD)----------
def _sanitize_name(name: str) -> str:
"""清理文件名中的特殊字符,保留中文、字母、数字、下划线和连字符。"""
name = re.sub(r'[\\/:*?"<>|]', '_', name)
name = re.sub(r'[“”‘’]', '', name) # 中文引号
name = re.sub(r'\s+', ' ', name).strip()
return name or "unnamed"
def archive_ocr_result(
source_path: str | Path,
entries: list[dict],
model: str = "",
backend: str = "",
extra_meta: dict | None = None,
original_source_path: str | Path | None = None,
) -> tuple[Path, Path]:
"""将 OCR 可读文本归档到 skill 内部 archive/,同时输出到原文件同目录。
归档格式:archive/YYYYMMDD_HHMMSS_文件名/原文件名.md + conversion_meta.json
同时在原文件同目录生成同名 .md 文件。
Args:
source_path: 实际处理的 PDF 路径(可能是临时文件)。
original_source_path: 用户最初传入的原始 PDF 路径。
如果提供,归档目录名和伴生 .md 以原始文件为准,
source_file 记录原始路径,working_file 记录实际处理路径。
Returns:
(archive_md_path, source_dir_md_path)
"""
working = Path(source_path)
original = Path(original_source_path) if original_source_path else None
# 归档命名和伴生 .md 以原始文件为准
naming_source = original or working
stem = _sanitize_name(naming_source.stem)
md_name = naming_source.stem + ".md"
md_content = generate_readable_text(entries)
# archive 内部归档
now = datetime.now()
timestamp = now.strftime("%Y%m%d_%H%M%S")
archive_path = ARCHIVE_DIR / f"{timestamp}_{stem}"
archive_path.mkdir(parents=True, exist_ok=True)
archive_md = archive_path / md_name
archive_md.write_text(md_content, encoding="utf-8")
# 运行记录 JSON
total_pages = len(entries)
total_rows = sum(len(e.get("rows", [])) for e in entries)
meta = {
"timestamp": now.isoformat(),
"source_file": str(naming_source),
"archive_path": str(archive_path),
"model": model,
"backend": backend,
"total_pages": total_pages,
"total_text_blocks": total_rows,
}
if original and original != working:
meta["working_file"] = str(working)
if extra_meta:
meta.update(extra_meta)
meta_path = archive_path / "conversion_meta.json"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
# 原文件同目录输出(始终写到原始文件旁边)
source_md = naming_source.parent / md_name
source_md.write_text(md_content, encoding="utf-8")
return archive_md, source_md
def archive_preprocess_result(
source_path: str | Path,
preprocess_meta: dict | None = None,
output_path: str | Path | None = None,
original_source_path: str | Path | None = None,
) -> Path:
"""仅预处理模式的轻量归档(无 OCR 文本,只记录元数据)。
归档格式:archive/YYYYMMDD_HHMMSS_文件名/conversion_meta.json
Args:
source_path: 实际处理的 PDF 路径。
preprocess_meta: 预处理参数和统计信息。
output_path: 最终输出文件路径。
original_source_path: 用户最初传入的原始 PDF 路径。
Returns:
archive_dir_path
"""
working = Path(source_path)
original = Path(original_source_path) if original_source_path else None
naming_source = original or working
stem = _sanitize_name(naming_source.stem)
now = datetime.now()
timestamp = now.strftime("%Y%m%d_%H%M%S")
archive_path = ARCHIVE_DIR / f"{timestamp}_{stem}"
archive_path.mkdir(parents=True, exist_ok=True)
meta = {
"timestamp": now.isoformat(),
"source_file": str(naming_source),
"archive_path": str(archive_path),
"mode": "preprocess_only",
}
if output_path:
meta["output_file"] = str(Path(output_path))
if original and original != working:
meta["working_file"] = str(working)
if preprocess_meta:
meta["preprocess"] = preprocess_meta
meta_path = archive_path / "conversion_meta.json"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
return archive_path
#!/usr/bin/env python3
"""
PDF 双层叠层核心模块。
将 OCR 结果(文字 + 坐标)叠入 PDF 透明文字层。
支持两种来源:
1. 本地 PaddleOCR predict 输出
2. 外部 API 返回的 payload
公共函数:
- normalize_cjk_spacing
- page_has_text_layer
- parse_paddle_predict_result
- calculate_font_size
- extract_page_image_size
- extract_page_entries_from_api_payload
- infer_page_scale
- apply_page_entries_as_layered_pdf
- apply_api_payload_as_layered_pdf
- save_output_from_api_payload
"""
from __future__ import annotations
import base64
import os
import platform
import re
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from pdf_runtime import http_get_bytes
# ---------- 通用 Payload 提取 ----------
def extract_payload(resp: dict) -> dict:
"""
提取有效载荷。
支持两类格式:
1) 顶层直接放 output 字段
2) `data` 字段中放 output 字段
"""
result = resp.get("result")
if isinstance(result, dict):
return result
if isinstance(result, list):
return {"ocrResults": result}
data = resp.get("data")
if isinstance(data, dict):
return data
if isinstance(data, list):
return {"ocrResults": data}
return resp
# ---------- CJK 空格归一化 ----------
_CJK_SPACE_RE = re.compile(
r"(?<=[㐀-䶿一-鿿豈- -〿-])"
r"\s+"
r"(?=[㐀-䶿一-鿿豈- -〿-])"
)
_CJK_BEFORE_PUNC_SPACE_RE = re.compile(r"\s+([,。!?;:、)》】」』)])")
_CJK_AFTER_OPEN_PUNC_SPACE_RE = re.compile(r"([(《【「『])\s+")
def normalize_cjk_spacing(text: str) -> str:
"""移除 CJK 字符间误插入空格,保留英文词间空格。"""
if not text:
return text
text = _CJK_SPACE_RE.sub("", text)
text = _CJK_BEFORE_PUNC_SPACE_RE.sub(r"\1", text)
text = _CJK_AFTER_OPEN_PUNC_SPACE_RE.sub(r"\1", text)
text = re.sub(r"\s{2,}", " ", text)
return text.strip()
# ---------- 几何工具 ----------
def _poly_to_points(poly) -> list[list[float]]:
"""将多种 polygon 表示统一为 [[x,y], ...]。"""
if poly is None:
return []
if not isinstance(poly, (list, tuple)):
return []
if not poly:
return []
first = poly[0]
if isinstance(first, (list, tuple)):
out = []
for p in poly:
if not isinstance(p, (list, tuple)) or len(p) < 2:
continue
try:
out.append([float(p[0]), float(p[1])])
except Exception:
continue
return out
# 扁平数组: [x1,y1,x2,y2,...]
out = []
if len(poly) >= 8:
for i in range(0, len(poly) - 1, 2):
try:
out.append([float(poly[i]), float(poly[i + 1])])
except Exception:
continue
return out
def _as_float(val, default: float = 0.0) -> float:
try:
return float(val)
except Exception:
return default
def _as_num_list(obj, length: int) -> list[float] | None:
if not isinstance(obj, (list, tuple)) or len(obj) < length:
return None
nums = []
for i in range(length):
try:
nums.append(float(obj[i]))
except Exception:
return None
return nums
def _bbox_to_poly4(bbox) -> list[list[float]]:
nums = _as_num_list(bbox, 4)
if not nums:
return []
x0, y0, x1, y1 = nums
return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
# ---------- 页面判断 ----------
def page_has_text_layer(page, min_chars: int) -> bool:
"""判断页面是否已有较明显文本层。"""
text = page.get_text("text")
return len(text.strip()) >= min_chars
# ---------- PaddleOCR 结果解析 ----------
def _parse_rec_dict(item: dict) -> list[tuple[str, float, list[list[float]]]]:
texts = item.get("rec_texts")
scores = item.get("rec_scores")
polys = item.get("rec_polys")
if texts is None and "texts" in item:
texts = item.get("texts")
if scores is None and "scores" in item:
scores = item.get("scores")
if polys is None:
polys = (
item.get("dt_polys")
or item.get("rec_boxes")
or item.get("polys")
or item.get("text_region")
)
if not isinstance(texts, list) or not isinstance(polys, list):
return []
if not isinstance(scores, list):
scores = []
parsed = []
for i, text in enumerate(texts):
poly = polys[i] if i < len(polys) else None
poly4 = _poly_to_points(poly)
if len(poly4) < 4:
continue
score = _as_float(scores[i], default=1.0) if i < len(scores) else 1.0
parsed.append((str(text), score, poly4[:4]))
return parsed
def parse_paddle_predict_result(result) -> list[tuple[str, float, list[list[float]]]]:
"""
解析 PaddleOCR 输出,统一为: [(text, score, poly4), ...]
poly4: [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
"""
if result is None:
return []
# 允许直接传 dict(常见于 API 的 prunedResult)
if isinstance(result, dict):
parsed = _parse_rec_dict(result)
if parsed:
return parsed
for key in ("prunedResult", "ocrResult", "result", "data"):
nested = result.get(key)
nested_rows = parse_paddle_predict_result(nested)
if nested_rows:
return nested_rows
return []
# 新版 `predict` 常见结构: [ {rec_texts, rec_scores, rec_polys, ...} ]
if isinstance(result, list) and result and isinstance(result[0], dict):
rows = []
for item in result:
rows.extend(parse_paddle_predict_result(item))
return rows
# 旧版结构: [ [poly, (text, score)], ... ]
if isinstance(result, list) and result and isinstance(result[0], list):
blocks = result[0]
parsed = []
if isinstance(blocks, list):
for block in blocks:
if not isinstance(block, list) or len(block) < 2:
continue
poly = block[0]
rec = block[1]
if not isinstance(rec, (list, tuple)) or len(rec) < 2:
continue
text = str(rec[0])
score = _as_float(rec[1], default=1.0)
poly4 = _poly_to_points(poly)
if len(poly4) >= 4:
parsed.append((text, score, poly4[:4]))
return parsed
return []
# ---------- 字号计算 ----------
def calculate_font_size(font, text: str, w: float, h: float) -> float:
"""计算贴合文本框的字号,支持多行文本。
策略:
1. 估算文本在当前框内需要的行数
2. 按行高算出初始字号
3. 微调使每行尽量填满宽度
4. 字号不超过框高
"""
if not text:
return max(1.0, h)
if h > w:
w, h = h, w
min_size = 5.0
if w <= 0 or h <= 0:
return min_size
def text_len(size: float) -> float:
return font.text_length(text, fontsize=size)
# 估算行数:在字号=h 时文本需要几行
text_w_at_h = text_len(h)
if text_w_at_h <= 0:
return max(min_size, h)
estimated_lines = max(1.0, text_w_at_h / w)
# 按行数算出单行高度
line_height = h / estimated_lines
fontsize = max(min_size, round(line_height))
# 微调:如果单行文字比框宽,缩小字号
if text_len(fontsize) > w * estimated_lines * 1.1:
while text_len(fontsize) > w * estimated_lines * 1.1 and fontsize > min_size:
fontsize -= 0.5
# 单行文本:字号不超过框高
if estimated_lines <= 1.0:
max_size = max(min_size, h)
if fontsize > max_size:
fontsize = max_size
return max(min_size, fontsize)
# ---------- 页面图像尺寸 ----------
def _pick_positive_number(value):
try:
num = float(value)
except Exception:
return None
return num if num > 0 else None
def extract_page_image_size(*objs) -> tuple[float | None, float | None]:
"""从页面结果中提取 OCR 坐标空间的宽高。"""
key_pairs = [
("imageWidth", "imageHeight"),
("image_width", "image_height"),
("imgW", "imgH"),
("img_w", "img_h"),
("pageWidth", "pageHeight"),
("page_width", "page_height"),
("width", "height"),
]
shape_keys = (
"imageShape",
"image_shape",
"inputImageShape",
"input_image_shape",
"shape",
)
for obj in objs:
if not isinstance(obj, dict):
continue
for wk, hk in key_pairs:
if wk in obj and hk in obj:
w = _pick_positive_number(obj.get(wk))
h = _pick_positive_number(obj.get(hk))
if w and h:
return w, h
for sk in shape_keys:
shape = obj.get(sk)
if isinstance(shape, (list, tuple)) and len(shape) >= 2:
h = _pick_positive_number(shape[0])
w = _pick_positive_number(shape[1])
if w and h:
return w, h
if isinstance(shape, dict):
w = _pick_positive_number(shape.get("w") or shape.get("width"))
h = _pick_positive_number(shape.get("h") or shape.get("height"))
if w and h:
return w, h
return None, None
# ---------- API payload -> 分页 entries ----------
def extract_page_entries_from_api_payload(payload: dict) -> list[dict]:
"""
从外部 API 返回中提取分页 OCR 结果。
返回: [{"rows": [...], "width": x, "height": y}, ...]
"""
page_sources = None
if isinstance(payload, list):
page_sources = payload
elif isinstance(payload, dict):
for key in ("ocrResults", "layoutParsingResults", "pageResults", "pages", "results"):
if isinstance(payload.get(key), list):
page_sources = payload.get(key)
break
if page_sources is None and (
isinstance(payload.get("prunedResult"), dict) or "rec_texts" in payload
):
page_sources = [payload]
if not isinstance(page_sources, list):
return []
entries = []
for page_obj in page_sources:
pruned = None
if isinstance(page_obj, dict):
pruned = page_obj.get("prunedResult")
candidate = pruned if isinstance(pruned, dict) else page_obj
rows = parse_paddle_predict_result(candidate)
width, height = extract_page_image_size(page_obj, pruned)
entries.append(
{
"rows": rows,
"width": width,
"height": height,
}
)
return entries
# ---------- 缩放推断 ----------
def infer_page_scale(page_rect, rows, source_w, source_h) -> tuple[float, float]:
"""推断 OCR 坐标到 PDF 页面坐标的缩放系数。"""
if source_w and source_h:
return page_rect.width / source_w, page_rect.height / source_h
max_x = 0.0
max_y = 0.0
for _, _, poly in rows:
for p in poly:
try:
max_x = max(max_x, float(p[0]))
max_y = max(max_y, float(p[1]))
except Exception:
continue
if max_x <= page_rect.width * 1.25 and max_y <= page_rect.height * 1.25:
return 1.0, 1.0
if max_x <= 0 or max_y <= 0:
return 1.0, 1.0
return page_rect.width / max_x, page_rect.height / max_y
# ---------- 透明文字块插入(去重后的共享函数) ----------
def _insert_text_blocks(
page,
font,
rows: list[tuple[str, float, list[list[float]]]],
*,
scale_x: float,
scale_y: float,
min_score: float,
cjk_normalize: bool,
page_rotation: int,
source_name: str,
pno: int,
total_pages: int,
quiet: bool,
) -> int:
import fitz
"""
向单个 PDF 页面插入透明文字块。
Returns:
插入的文本块数量。
"""
font_inserted = False
page_inserted = 0
page.clean_contents()
for text, score, poly in rows:
if score < min_score:
continue
content = text.strip()
if not content:
continue
if cjk_normalize:
content = normalize_cjk_spacing(content)
if not content:
continue
xs = [p[0] * scale_x for p in poly]
ys = [p[1] * scale_y for p in poly]
x0 = min(xs)
x1 = max(xs)
y0 = min(ys)
y1 = max(ys)
w = max(1.0, x1 - x0)
h = max(1.0, y1 - y0)
fontsize = calculate_font_size(font, content, w, h)
if not font_inserted:
page.insert_font(fontname="cjk", fontbuffer=font.buffer)
font_inserted = True
point = fitz.Point(x0, y1) * page.derotation_matrix
page.insert_text(
point,
content,
fontsize=fontsize,
fontname="cjk",
rotate=page_rotation,
stroke_opacity=0,
fill_opacity=0,
render_mode=3,
)
page_inserted += 1
if not quiet:
print(f" 第 {pno}/{total_pages} 页({source_name}): 新增 {page_inserted} 文本块")
return page_inserted
# ---------- 叠层 PDF(分页 entries) ----------
def apply_page_entries_as_layered_pdf(page_entries: list[dict], args, source_name: str) -> bool:
"""将分页 OCR 结果叠层为双层 PDF。"""
if not page_entries:
return False
import fitz
doc = fitz.open(args.input)
font = fitz.Font("cjk")
inserted_pages = 0
inserted_blocks = 0
skipped_pages = 0
total_pages = len(doc)
cjk_normalize = not args.no_paddle_cjk_space_normalize
for pno, page in enumerate(doc, start=1):
if pno - 1 >= len(page_entries):
continue
if args.mode == "skip" and page_has_text_layer(page, args.paddle_skip_text_min_chars):
skipped_pages += 1
continue
entry = page_entries[pno - 1]
rows = entry.get("rows") or []
if not rows:
continue
scale_x, scale_y = infer_page_scale(
page.rect,
rows,
entry.get("width"),
entry.get("height"),
)
page_rotation = int(page.rotation) if page.rotation else 0
page_inserted = _insert_text_blocks(
page,
font,
rows,
scale_x=scale_x,
scale_y=scale_y,
min_score=args.paddle_min_score,
cjk_normalize=cjk_normalize,
page_rotation=page_rotation,
source_name=source_name,
pno=pno,
total_pages=total_pages,
quiet=args.quiet,
)
if page_inserted > 0:
inserted_pages += 1
inserted_blocks += page_inserted
if inserted_pages == 0:
doc.close()
src = Path(args.input).resolve()
dst = Path(args.output).resolve()
if src != dst:
shutil.copy2(src, dst)
if not args.quiet:
print(f"{source_name} 已返回 OCR 结果,但无可叠层文本块,已原样输出。")
return True
try:
doc.subset_fonts()
except Exception:
pass
doc.save(
args.output,
garbage=4,
clean=1, deflate=1, deflate_images=1, deflate_fonts=1,
use_objstms=1, compression_effort=100,
)
doc.close()
# 保留原文件时间戳(创建时间 + 修改时间)
try:
src_stat = Path(args.input).stat()
mtime = src_stat.st_mtime
birthtime = getattr(src_stat, "st_birthtime", mtime)
os.utime(args.output, (mtime, mtime))
if platform.system() == "Darwin":
try:
dt = datetime.fromtimestamp(birthtime)
date_str = dt.strftime("%m/%d/%Y %H:%M:%S")
subprocess.run(
["SetFile", "-d", date_str, str(args.output)],
check=True, capture_output=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
pass
except Exception:
pass
if not args.quiet:
print(f"\n{source_name} OCR 叠层完成:")
print(f" 新增页面: {inserted_pages}/{total_pages}")
print(f" 新增文本块: {inserted_blocks}")
print(f" 跳过页面: {skipped_pages}")
return True
# ---------- 叠层 PDF(API payload) ----------
def apply_api_payload_as_layered_pdf(payload: dict, args) -> bool:
"""将 API 返回的 OCR 结果叠层为双层 PDF。"""
page_entries = extract_page_entries_from_api_payload(payload)
return apply_page_entries_as_layered_pdf(page_entries, args, source_name="API")
# ---------- 保存 API 输出 PDF ----------
def save_output_from_api_payload(payload: dict, output_path: Path, timeout: int):
"""从 API payload 保存输出 PDF。"""
if "output_pdf_base64" in payload:
raw = base64.b64decode(payload["output_pdf_base64"])
output_path.write_bytes(raw)
return
if "output_pdf_url" in payload:
raw = http_get_bytes(payload["output_pdf_url"], timeout=timeout)
output_path.write_bytes(raw)
return
if "output_pdf_path" in payload:
p = Path(payload["output_pdf_path"])
if not p.exists():
raise RuntimeError(f"API 返回的输出路径不存在: {p}")
shutil.copy2(p, output_path)
return
raise RuntimeError(
"API 响应未直接返回 PDF。可继续尝试解析 OCR 结果并本地叠层。"
)
#!/usr/bin/env python3
"""Historical local PaddleOCR layered-PDF backend.
This module is intentionally kept outside the public OCR entrypoint. The
production path prefers external PaddleOCR / MinerU APIs and local ocrmypdf
fallback, but this local implementation is preserved for future hardware-rich
environments and controlled experiments.
"""
from __future__ import annotations
import os
import platform
import shutil
from pathlib import Path
from pdf_runtime import print_dependency_help
from pdf_ocr_layered import (
_insert_text_blocks,
page_has_text_layer,
parse_paddle_predict_result,
)
try:
import fitz # PyMuPDF
HAS_PYMUPDF = True
except Exception:
HAS_PYMUPDF = False
try:
import numpy as np
HAS_NUMPY = True
except Exception:
HAS_NUMPY = False
PaddleOCR = None
HAS_PADDLEOCR = None
PADDLE_PROFILE_PRESETS = {
"quality": {
"det_model": "PP-OCRv5_server_det",
"rec_model": "PP-OCRv5_server_rec",
"dpi": 300,
"det_limit_side_len": 1536,
},
"balanced": {
"det_model": "PP-OCRv5_mobile_det",
"rec_model": "PP-OCRv5_mobile_rec",
"dpi": 300,
"det_limit_side_len": 1216,
},
"speed": {
"det_model": "PP-OCRv5_mobile_det",
"rec_model": "PP-OCRv5_mobile_rec",
"dpi": 260,
"det_limit_side_len": 960,
},
}
def get_paddle_layered_missing_dependencies() -> list[str]:
"""Detect Python dependencies required by the local Paddle backend."""
global PaddleOCR, HAS_PADDLEOCR
missing = []
if not HAS_PYMUPDF:
missing.append("pymupdf")
if not HAS_NUMPY:
missing.append("numpy")
if HAS_PADDLEOCR is None:
try:
from paddleocr import PaddleOCR as _PaddleOCR # lazy import
PaddleOCR = _PaddleOCR
HAS_PADDLEOCR = True
except Exception:
HAS_PADDLEOCR = False
if not HAS_PADDLEOCR:
missing.extend(["paddleocr", "paddlepaddle"])
return missing
def ensure_paddle_layered_available() -> bool:
"""Check local Paddle backend dependencies."""
missing = get_paddle_layered_missing_dependencies()
if missing:
print_dependency_help(
"本地 Paddle 双层引擎",
missing_python=missing,
install_commands=[
"pip install pymupdf numpy paddleocr paddlepaddle",
],
extra_notes=[
"本地 Paddle 后端是历史实验能力,不属于默认生产链路。",
"首次运行 Paddle 相关能力时,模型初始化也可能继续耗时。",
],
)
return False
return True
def map_tesseract_lang_to_paddle(language: str) -> str:
"""Map Tesseract-style language values to PaddleOCR language values."""
lang = (language or "").lower()
if "chi_sim" in lang or "chi_tra" in lang or "ch" in lang:
return "ch"
if "jpn" in lang or "jap" in lang:
return "japan"
if "kor" in lang:
return "korean"
if "eng" in lang:
return "en"
return "ch"
def detect_cuda_device_count() -> int:
"""Detect available CUDA devices on Windows/Linux."""
system = platform.system().lower()
if system not in {"windows", "linux"}:
return 0
try:
import paddle # type: ignore
except Exception:
return 0
try:
device_mod = getattr(paddle, "device", None)
if device_mod is None:
return 0
is_cuda_fn = getattr(device_mod, "is_compiled_with_cuda", None)
if not callable(is_cuda_fn) or not is_cuda_fn():
return 0
cuda_mod = getattr(device_mod, "cuda", None)
if cuda_mod is None:
return 1
count_fn = getattr(cuda_mod, "device_count", None)
if callable(count_fn):
count = int(count_fn() or 0)
return max(0, count)
return 1
except Exception:
return 0
def resolve_paddle_device(args) -> tuple[bool, str]:
"""Resolve local Paddle runtime device."""
if args.paddle_use_gpu:
return True, "用户显式指定 GPU"
cuda_count = detect_cuda_device_count()
if cuda_count > 0:
return True, f"{platform.system()} 自动检测到 CUDA GPU x{cuda_count}"
return False, f"{platform.system()} 默认 CPU"
def resolve_paddle_profile(args, total_pages: int, use_gpu: bool) -> tuple[str, str]:
"""Resolve local Paddle profile."""
requested = args.paddle_profile
if requested != "auto":
return requested, "用户显式指定"
if args.paddle_long_doc_pages > 0 and total_pages >= args.paddle_long_doc_pages:
return "speed", f"页数 {total_pages} >= 阈值 {args.paddle_long_doc_pages}"
if use_gpu:
return "quality", "启用 GPU"
system = platform.system().lower()
if system in {"darwin", "windows"}:
return "balanced", f"{platform.system()} CPU 默认平衡档"
return "quality", f"{platform.system()} 默认高精度档"
def build_paddle_runtime_config(args, total_pages: int, use_gpu: bool) -> dict:
"""Build final local Paddle runtime config from profile and CLI args."""
profile, reason = resolve_paddle_profile(args, total_pages, use_gpu)
preset = PADDLE_PROFILE_PRESETS[profile]
det_model = args.paddle_det_model_name or preset["det_model"]
rec_model = args.paddle_rec_model_name or preset["rec_model"]
dpi = args.paddle_dpi if args.paddle_dpi_user_set else preset["dpi"]
det_limit = (
args.paddle_det_limit_side_len
if args.paddle_det_limit_user_set
else preset["det_limit_side_len"]
)
return {
"profile": profile,
"profile_reason": reason,
"use_gpu": bool(use_gpu),
"det_model": det_model,
"rec_model": rec_model,
"dpi": int(dpi),
"det_limit_side_len": int(det_limit),
}
def run_local_paddle_layered_backend(args, fallback_backend=None):
"""Run local PaddleOCR + PyMuPDF transparent text-layer generation."""
if not args.keep_paddle_model_source_check:
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
os.environ.setdefault("DISABLE_MODEL_SOURCE_CHECK", "True")
if args.paddle_model_source:
os.environ["PADDLE_PDX_MODEL_SOURCE"] = args.paddle_model_source.lower()
if not ensure_paddle_layered_available():
raise RuntimeError("本地 Paddle 双层引擎不可用")
paddle_lang = args.paddle_lang or map_tesseract_lang_to_paddle(args.language)
with fitz.open(args.input) as probe_doc:
total_pages = len(probe_doc)
has_text_pages = [
i + 1
for i, page in enumerate(probe_doc)
if page_has_text_layer(page, args.paddle_skip_text_min_chars)
]
use_gpu, device_reason = resolve_paddle_device(args)
runtime_cfg = build_paddle_runtime_config(args, total_pages, use_gpu)
if not args.quiet:
print("\n本地 Paddle 双层后端参数:")
print(f" profile: {runtime_cfg['profile']} ({runtime_cfg['profile_reason']})")
print(f" device: {'gpu' if runtime_cfg['use_gpu'] else 'cpu'} ({device_reason})")
print(f" lang: {paddle_lang}")
print(f" dpi: {runtime_cfg['dpi']}")
print(f" min_score: {args.paddle_min_score}")
print(f" skip_text_min_chars: {args.paddle_skip_text_min_chars}")
print(f" textline_orientation: {args.paddle_textline_orientation}")
print(f" det_limit_side_len: {runtime_cfg['det_limit_side_len']}")
print(f" det_model: {runtime_cfg['det_model']}")
print(f" rec_model: {runtime_cfg['rec_model']}")
print(f" model_source_check: {'keep' if args.keep_paddle_model_source_check else 'disabled'}")
if args.paddle_model_source:
print(f" model_source: {args.paddle_model_source.lower()}")
print(f" normalize_cjk_spaces: {not args.no_paddle_cjk_space_normalize}")
if args.dry_run:
print("[DRY-RUN] 本地 Paddle 双层后端参数已输出,未实际执行。")
args.backend_used = "local_paddle_layered"
return
if has_text_pages and args.mode in {"redo", "force"}:
if fallback_backend is None:
raise RuntimeError("检测到已有文本层,local_paddle_layered 需要 ocrmypdf 兜底处理 redo/force")
if not args.quiet:
print(
"警告: 检测到已有文本层,`redo/force` 语义下自动回退 ocrmypdf,"
"以避免重复文字层或旧层残留。"
)
fallback_backend(args)
return
ocr_kwargs = {
"lang": paddle_lang,
"use_doc_orientation_classify": False,
"use_doc_unwarping": False,
"use_textline_orientation": args.paddle_textline_orientation,
"text_det_limit_side_len": runtime_cfg["det_limit_side_len"],
"text_det_limit_type": "max",
"text_detection_model_name": runtime_cfg["det_model"],
"text_recognition_model_name": runtime_cfg["rec_model"],
}
if runtime_cfg["use_gpu"]:
ocr_kwargs["device"] = "gpu"
try:
ocr = PaddleOCR(**ocr_kwargs)
except TypeError:
ocr_kwargs.pop("device", None)
ocr = PaddleOCR(**ocr_kwargs)
except Exception as e:
if runtime_cfg["use_gpu"] and not args.quiet:
print(f"警告: PaddleOCR GPU 初始化失败,回退 CPU。原因: {e}")
ocr_kwargs.pop("device", None)
ocr = PaddleOCR(**ocr_kwargs)
else:
raise
doc = fitz.open(args.input)
font = fitz.Font("cjk")
inserted_pages = 0
inserted_blocks = 0
skipped_pages = 0
cjk_normalize = not args.no_paddle_cjk_space_normalize
for pno, page in enumerate(doc, start=1):
if args.mode == "skip" and page_has_text_layer(page, args.paddle_skip_text_min_chars):
skipped_pages += 1
continue
pix = page.get_pixmap(dpi=runtime_cfg["dpi"], alpha=False)
arr = np.frombuffer(pix.samples, dtype=np.uint8)
arr = arr.reshape(pix.height, pix.width, pix.n)
if pix.n == 4:
arr = arr[:, :, :3]
rows = parse_paddle_predict_result(ocr.predict(arr))
if not rows:
continue
page_inserted = _insert_text_blocks(
page,
font,
rows,
scale_x=page.rect.width / float(pix.width),
scale_y=page.rect.height / float(pix.height),
min_score=args.paddle_min_score,
cjk_normalize=cjk_normalize,
page_rotation=int(page.rotation) if page.rotation else 0,
source_name="Paddle",
pno=pno,
total_pages=total_pages,
quiet=args.quiet,
)
if page_inserted > 0:
inserted_pages += 1
inserted_blocks += page_inserted
if inserted_pages == 0:
doc.close()
src = Path(args.input).resolve()
dst = Path(args.output).resolve()
if src != dst:
shutil.copy2(src, dst)
if not args.quiet:
print("未新增 OCR 文本层,已原样输出。")
args.backend_used = "local_paddle_layered"
return
try:
doc.subset_fonts()
except Exception:
pass
doc.save(args.output, garbage=3, deflate=True)
doc.close()
try:
shutil.copystat(args.input, args.output)
except Exception:
pass
if not args.quiet:
print("\n本地 Paddle 双层完成:")
print(f" 新增页面: {inserted_pages}/{total_pages}")
print(f" 新增文本块: {inserted_blocks}")
print(f" 跳过页面: {skipped_pages}")
args.backend_used = "local_paddle_layered"
#!/usr/bin/env python3
"""
PDF 解密工具
移除 PDF 密码保护,使其可以正常编辑和处理
"""
import sys
import argparse
from pathlib import Path
try:
import pypdf
except ImportError as e:
print(f"错误: 缺少必需的依赖 - {e}")
print("\n请运行以下命令安装:")
print(" pip install pypdf")
sys.exit(1)
def is_encrypted(pdf_path):
"""检查 PDF 是否加密"""
try:
with open(pdf_path, 'rb') as f:
reader = pypdf.PdfReader(f)
return reader.is_encrypted
except Exception as e:
print(f"检查文件时出错: {e}")
return False
def decrypt_pdf(input_pdf, output_pdf, password=''):
"""
移除 PDF 密码保护
Args:
input_pdf: 输入 PDF 文件
output_pdf: 输出 PDF 文件
password: PDF 密码(如果有)
Returns:
dict: 处理结果
"""
print(f"正在处理 PDF: {input_pdf}")
try:
with open(input_pdf, 'rb') as f:
reader = pypdf.PdfReader(f)
if not reader.is_encrypted:
print("PDF 未加密,无需解密")
# 直接复制文件
import shutil
shutil.copy2(input_pdf, output_pdf)
return {
'status': 'not_encrypted',
'message': 'PDF 未加密'
}
print(f"PDF 已加密,尝试解密...")
# 尝试解密
if reader.decrypt(password):
print("解密成功!")
# 写入解密后的 PDF
writer = pypdf.PdfWriter()
for page in reader.pages:
writer.add_page(page)
with open(output_pdf, 'wb') as f:
writer.write(f)
return {
'status': 'success',
'message': '解密成功'
}
else:
return {
'status': 'failed',
'message': '解密失败,密码可能不正确'
}
except Exception as e:
return {
'status': 'error',
'message': f'处理失败: {e}'
}
def main():
parser = argparse.ArgumentParser(
description='PDF 解密工具 - 移除 PDF 密码保护',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 基本使用(尝试空密码)
python pdf-decrypt.py -i input.pdf -o output.pdf
# 使用指定密码
python pdf-decrypt.py -i input.pdf -o output.pdf --password 123456
"""
)
parser.add_argument('--input', '-i', required=True, help='输入 PDF 文件')
parser.add_argument('--output', '-o', required=True, help='输出 PDF 文件')
parser.add_argument('--password', '-p', default='',
help='PDF 密码(默认尝试空密码)')
args = parser.parse_args()
# 验证输入文件
input_path = Path(args.input)
if not input_path.exists():
print(f"错误: 输入文件不存在: {args.input}", file=sys.stderr)
sys.exit(1)
# 创建输出目录
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
# 执行解密
result = decrypt_pdf(args.input, args.output, args.password)
# 显示结果
print("\n" + "=" * 50)
if result['status'] == 'success':
print("✓ 处理完成!")
print(f"输出文件: {args.output}")
elif result['status'] == 'not_encrypted':
print("✓ 无需处理(未加密)")
print(f"输出文件: {args.output}")
elif result['status'] == 'failed':
print("✗ 处理失败")
print(f"原因: {result['message']}")
sys.exit(1)
else:
print("✗ 处理失败")
print(f"原因: {result['message']}")
sys.exit(1)
print("=" * 50)
if __name__ == '__main__':
main()