
Paddle Ocr
- 63 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Convert legal PDFs and scans to Markdown with PaddleOCR structured parsing, keeping a traceable archive, for tables, formulas, and multi-column layouts.
About
Uses PaddleOCR to convert legal PDFs and scanned images into editable Markdown with structured parsing and a traceable internal archive, targeting case files, records, evidence, invoices, and table- or formula-heavy documents. A developer or lawyer uses it for complex legal-document OCR to Markdown.
- Structured PaddleOCR parsing for tables, formulas, layouts
- Keeps traceable archive for review and reprocessing
Paddle Ocr by the numbers
- 63 all-time installs (skills.sh)
- Ranked #354 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 paddle-ocrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 63 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Convert legal PDFs and scans to Markdown with PaddleOCR structured parsing, keeping a traceable archive, for tables, formulas, and multi-column layouts.
Files
PaddleOCR 法律 PDF 转 Markdown
本技能服务于法律材料 OCR。默认目标不是返回一段临时文本,而是:
1. 将本地 PDF / 图片转换为可继续编辑和分析的 Markdown。 2. 在 archive/ 下保留完整归档,便于复核、追溯和二次处理。
何时使用
在以下场景使用本技能:
- 需要把卷宗、病历、证据材料、法院通知、财报、票据等扫描 PDF 转成 Markdown。
- 文档包含表格、印章、页眉页脚、多栏排版、公式或复杂版面。
- 希望保留一个技能内的 archive,沉淀原文件、Markdown、结构化 JSON 和批次结果。
- 后续还要继续做法律分析、证据摘录、知识入库或 RAG 切片。
在以下场景不要优先使用本技能:
- 只是快速读取一小段清晰文本,且不需要 Markdown 和归档。
- 只是截图抄字,速度比结构化质量更重要。
- 输入不是 PDF / 常见图片格式。
主产出
默认主产出只有两类:
- Markdown 文件:保存在源文件同目录,默认与原文件同名、扩展名为
.md - archive 归档目录:保存在
paddle-ocr/archive/时间戳_文件名/
archive 默认包含:
- 原始输入文件副本
- 最终
result.md - 最终
result.json - 批次级
batches/*.json - 提取出的图片资源
metadata.json
依赖
系统依赖
| 依赖 | 安装方式 |
|---|---|
python3 | macOS 通常已内置 |
uv | macOS: brew install uv |
Python 包
脚本使用 uv run 执行,依赖写在脚本头部,无需单独维护 requirements.txt。
首次配置
获取 API 信息
1. 打开 PaddleOCR 官网 2. 进入对应模型的 API 页面 3. 在示例代码中复制:
API_URLAccess Token
配置方式
优先编辑 config/.env:
cd paddle-ocr/config
cp .env.example .env
nano .env必填项:
PADDLEOCR_DOC_PARSING_API_URLPADDLEOCR_ACCESS_TOKEN
常用命令
主工作流:生成 Markdown + archive
在技能根目录运行:
uv run scripts/convert.py "/path/to/legal-document.pdf"或继续兼容旧入口:
/usr/bin/osascript -l JavaScript scripts/convert.js "/path/to/legal-document.pdf"可选参数:
uv run scripts/convert.py "/path/to/legal-document.pdf" --pages "1-20"
uv run scripts/convert.py "/path/to/legal-document.pdf" --output "/tmp/output.md"
uv run scripts/convert.py "/path/to/legal-document.pdf" --archive-name "某案卷宗-证据一"底层调试:只调用解析接口,输出结构化 JSON
uv run scripts/layout_caller.py --file-path "/path/to/legal-document.pdf" --pretty
uv run scripts/layout_caller.py --file-url "https://example.com/document.pdf" --stdout --pretty当你只想检查原始接口结果,或后续要自己解析表格/坐标信息时,使用这个底层脚本。
自检
uv run scripts/smoke_test.py --skip-api-test
uv run scripts/smoke_test.py拆分页码
uv run scripts/split_pdf.py input.pdf output.pdf --pages "1-5,8,10-12"法律 PDF 工作流
按以下顺序工作:
1. 优先使用 scripts/convert.py。 2. 如只需部分页码,先传 --pages,避免整卷上传。 3. 对大体量卷宗,脚本会按配置自动分批请求,再合并为一个 Markdown。 4. 需要复核时,到 archive/ 查看:
output/result.mdoutput/result.jsonmetadata.jsonbatches/*.json
大文件策略
本技能为了法律材料的稳定性,默认采用保守批次策略:
- PDF 页数超过
PADDLEOCR_BATCH_PAGES时自动分批 - 预估 Base64 大小超过
PADDLEOCR_MAX_BASE64_MB时自动分批
这意味着它可能比官方上限更早拆分,但通常能降低长卷宗、病历合并件和扫描质量不稳定文档的失败率。
输出说明
Markdown
- 默认保存到源文件同目录
- 如果传
--output且是.md文件路径,则保存到指定路径 - 如果
--output是目录,则在该目录下生成同名.md
archive
默认归档目录结构:
archive/
└── 20260405_153000_文件名/
├── input/
│ └── 原文件.pdf
├── output/
│ ├── result.md
│ ├── result.json
│ └── images/
├── batches/
│ ├── batch_001_1-40.json
│ └── batch_002_41-67.json
└── metadata.json配置项
编辑 config/.env:
| 选项 | 默认值 | 说明 |
|---|---|---|
PADDLEOCR_DOC_PARSING_API_URL | 空 | 官方要求的完整 layout-parsing 端点 |
PADDLEOCR_ACCESS_TOKEN | 空 | 官方 Access Token |
PADDLEOCR_DOC_ORIENTATION | false | 是否启用方向分类 |
PADDLEOCR_DOC_UNWARP | false | 是否启用去扭曲 |
PADDLEOCR_CHART_RECOG | false | 是否启用图表识别 |
PADDLEOCR_DOC_PARSING_TIMEOUT | 600 | 单次请求超时秒数 |
PADDLEOCR_BATCH_PAGES | 40 | PDF 自动分批页数阈值兼批次大小 |
PADDLEOCR_MAX_BASE64_MB | 20 | 触发分批的保守大小阈值 |
PADDLEOCR_LOG_LEVEL | medium | low / medium / high |
结果结构
如果需要理解底层 JSON 包装格式,读取:
references/output_schema.md
故障排除
| 问题 | 解决方式 |
|---|---|
| 未配置 API | 先补 config/.env,再执行 uv run scripts/smoke_test.py --skip-api-test |
| 403 / Token 错误 | 更新 PADDLEOCR_ACCESS_TOKEN |
| 请求超时 | 调大 PADDLEOCR_DOC_PARSING_TIMEOUT,或减少页码范围 |
| 大 PDF 失败 | 使用 --pages 缩小范围,或让脚本自动分批 |
| Markdown 为空 | 到 archive/ 查看 batches/*.json 和 metadata.json,确认是否原文件质量过差 |
| 需要看原始坐标和表格结构 | 使用 scripts/layout_caller.py,并读取 result.result.layoutParsingResults[*].prunedResult |
整合路线图
本技能将与 mineru-ocr 整合为统一的 legal-ocr Skill,支持双后端(PaddleOCR + MinerU)、自动路由和法律后处理管线。
完整规划见 `docs/ROADMAP_LEGAL_OCR.md`。
维护建议
修改本技能后,同步更新:
TASKS.mdDECISIONS.mdCHANGELOG.md
# 此目录用于存储转换历史和归档文件
# 每次转换会创建一个子目录,包含:
# - input.*: 原始输入文件
# - response.json: API 响应
# - result.md: 转换后的 Markdown 文件
变更记录
[1.1.1] - 2026-04-05
改进
- 将对外配置字段统一收敛为官方命名:
PADDLEOCR_DOC_PARSING_API_URL与PADDLEOCR_ACCESS_TOKEN。 SKILL.md与.env.example删除旧别名说明,避免用户在配置时产生歧义。
技术优化
scripts/lib.py不再从旧别名字段读取 API 地址和 Token,配置接口与官方保持一致。
文档完善
- 更新配置章节,明确只按官方字段填写
.env。
[1.1.0] - 2026-04-05
新增
- 新增
scripts/lib.py,统一配置读取、接口调用、稳定 JSON envelope 与错误包装。 - 新增
scripts/layout_caller.py,支持直接调试底层 JSON 结果。 - 新增
scripts/split_pdf.py,支持 PDF 页码提取与自动分批。 - 新增
scripts/smoke_test.py,支持配置检查与 API 连通性自检。 - 新增
scripts/optimize_file.py,支持对扫描图片做压缩优化。 - 新增
references/output_schema.md,说明底层 JSON envelope 与 archive 结构。 - 新增
TASKS.md与DECISIONS.md,补齐技能级协作文档。 - 新增
LICENSE.txt,统一许可证文件名与版权信息。
改进
- 将技能定位收敛为“面向法律 PDF / 扫描件的 Markdown + archive 工作流”。
- 默认输出保持为 Markdown 文件,并在技能内部保留可追溯 archive。
- 为卷宗、病历、证据材料等长文档增加自动分批逻辑,优先保障稳定性。
convert.js改为兼容层,转而调用 Python 主链路,不再内置核心 OCR 逻辑。SKILL.md重写为以法律文档场景为中心的说明文档,并补充适用/不适用场景。
技术优化
- 移除旧的固定
test/paddle-ocr路径依赖,改为基于脚本位置动态推导 skill 根目录。 - 用
pypdfium2替代 Ghostscript 方案,降低系统依赖。 - 统一支持新旧环境变量字段,兼容已有
.env配置。 - 归档目录新增
metadata.json、批次 JSON 和输出结构说明,增强复核与追溯能力。
文档完善
- 将配置说明更新为
PADDLEOCR_DOC_PARSING_API_URL/PADDLEOCR_ACCESS_TOKEN主字段。 - 补充大文件策略、页码范围、主入口与底层入口的分工说明。
待办事项
- 增加真实法律 PDF 样本的回归测试集。
- 评估页眉页脚、印章、批注的后处理去噪规则。
[1.0.0] - 2026-01-15
新增
- 初始版本发布。
- 支持将 PDF 和图片转换为 Markdown。
- 集成 PaddleOCR 文档解析接口。
- 支持 OCR、表格识别、公式识别与图片提取。
- 增加基础 archive 归档能力。
技术优化
- 使用 JXA 与 Python 组合实现基础转换流程。
- 支持 Base64 上传与
fileType自动检测。
文档完善
- 提供基础配置说明、故障排除和与 MinerU 的差异说明。
# ===== PaddleOCR API 官方配置 =====
# 从 https://www.paddleocr.com 对应模型 API 页面复制
PADDLEOCR_DOC_PARSING_API_URL=https://your-endpoint.example.com/layout-parsing
PADDLEOCR_ACCESS_TOKEN=your_access_token_here
# ===== 识别选项 =====
PADDLEOCR_DOC_ORIENTATION=false
PADDLEOCR_DOC_UNWARP=false
PADDLEOCR_CHART_RECOG=false
# ===== 稳定性与大文件策略 =====
PADDLEOCR_DOC_PARSING_TIMEOUT=600
PADDLEOCR_BATCH_PAGES=40
PADDLEOCR_MAX_BASE64_MB=20
# ===== 日志 =====
PADDLEOCR_LOG_LEVEL=medium
legal-ocr 整合路线图
Last updated: 2026-05-12
状态:规划中,未开始实施
背景
当前 paddle-ocr(Python,百度 PaddleOCR API)和 mineru-ocr(JXA,MinerU API)功能高度重叠——都将 PDF/图片转为 Markdown 并归档。合并为统一 Skill 的收益:
- 消除用户在两个 OCR 工具间选择的困惑
- 统一归档格式和配置体验
- 根据文件类型/大小自动路由到最优后端
- 加入法律文档后处理(词典纠错、标题结构、标点规范化)
新 Skill 命名
`legal-ocr` — 传达三个信息:(a) OCR 工具 (b) 法律文档优化 (c) 生态内主 OCR Skill。
---
目录结构
skills/legal-ocr/
├── SKILL.md
├── CHANGELOG.md
├── DECISIONS.md
├── LICENSE.txt
├── config/
│ ├── .env.example # 统一配置
│ └── legal_dictionaries/
│ ├── general_legal_terms.md # 预置:法律 OCR 常见纠错
│ ├── court_document_patterns.md # 预置:法院文书结构识别
│ └── seal_detection_rules.md # 预置:印章标注规则
├── scripts/
│ ├── convert.py # 统一入口 + 路由
│ ├── backends/
│ │ ├── base.py # 抽象接口 + OCRResult
│ │ ├── paddle_ocr.py # 从 paddle-ocr/scripts/lib.py 提取
│ │ └── mineru_ocr.py # 从 convert.js (JXA) 重写为 Python
│ ├── router.py # 自动路由决策
│ ├── postprocess/
│ │ ├── legal_postprocess.py # 后处理管线
│ │ ├── punctuation_fix.py # 标点规范化
│ │ ├── heading_structure.py # 法律标题结构检测
│ │ └── blank_line_cleanup.py # 空行整理
│ ├── optimize_file.py # 沿用 paddle-ocr
│ ├── split_pdf.py # 沿用 paddle-ocr
│ ├── smoke_test.py # 统一健康检查
│ └── convert.js # JXA 兼容层
├── references/
│ └── output_schema.md
└── archive/
└── .gitkeep---
统一配置
所有现有变量名完全保留,用户只需复制值到新 .env:
# ===== 后端选择 =====
LEGAL_OCR_BACKEND=auto # auto | paddle | mineru
# ===== PaddleOCR 后端(变量名不变) =====
PADDLEOCR_DOC_PARSING_API_URL=
PADDLEOCR_ACCESS_TOKEN=
PADDLEOCR_DOC_ORIENTATION=false
PADDLEOCR_DOC_UNWARP=false
PADDLEOCR_CHART_RECOG=false
PADDLEOCR_DOC_PARSING_TIMEOUT=600
PADDLEOCR_BATCH_PAGES=40
PADDLEOCR_MAX_BASE64_MB=20
# ===== MinerU 后端(变量名不变) =====
MINERU_API_BASE=https://mineru.net/api/v4
MINERU_API_TOKEN=
MINERU_ENABLE_OCR=true
MINERU_ENABLE_TABLE=true
MINERU_ENABLE_FORMULA=false
MINERU_LANGUAGE_CODE=ch
MINERU_MODEL_VERSION=pipeline
MINERU_PAGE_RANGES=
MINERU_POLL_MAX=20
MINERU_POLL_SLEEP=10
# ===== 统一设置 =====
LEGAL_OCR_LOG_LEVEL=medium
LEGAL_OCR_POST_PROCESS=true # 开启法律后处理管线
LEGAL_OCR_LEGAL_DICT=true # 启用法律词典纠错---
自动路由规则
按顺序评估,命中即停止:
| # | 条件 | 路由到 | 原因 |
|---|---|---|---|
| R1 | DOC/DOCX/PPT/PPTX 文件 | MinerU | PaddleOCR 不支持 |
| R2 | 网页 URL(非文档链接) | MinerU (需 token) | 仅 MinerU 支持网页提取 |
| R3 | 远程文档 URL | MinerU 优先 | MinerU 处理远程 URL 更顺畅 |
| R4 | PDF > 600 页 | PaddleOCR | PaddleOCR 无限页数自动分批 |
| R5 | 启用印章/图表检测 | PaddleOCR | 专用印章检测能力 |
| R6 | 小文件 (<10MB, <20页),无 MinerU token | MinerU Light | 零配置即用 |
| R7 | 本地 PDF/图片(默认) | PaddleOCR | 法律场景优化更好 |
| R8 | 首选后端失败 | 回退到另一个 | 双后端互为兜底 |
---
法律后处理管线
OCR 后自动执行(可通过 --no-post-process 关闭):
Stage 1:标点规范化
- 英文标点 → 中文标点(
, . ; : ( ) ? !) - 保护 URL 和代码块内的标点不被替换
- 参考:
legal-text-format/scripts/format_step2_v2.py的占位符保护机制
Stage 2:法律标题结构检测
自动识别并标记:
- "第X条" → 加粗
- "第X章" / "第X节" →
##/###标题 - 案号:"(20XX)X民初X号"
- 法院文书段落:"原告诉称"、"被告辩称"、"本院认为"、"判决如下"
- 参考:
legal-text-format/scripts/format_legal_cases.py
Stage 3:空行整理
- 折叠 3+ 连续空行为 2
- 确保段落间单个空行
- 去除行尾空白
Stage 4:词典纠错
三层词典体系(参考 post-ocr-formatter 的四类型设计):
| 类型 | 行为 |
|---|---|
| 强制替换 | 命中即替换,记录日志。仅高置信度。如 "但保"→"担保" |
| 术语白名单 | 不做批量替换,用于确认近似写法的正确形式 |
| 标题词表 | 不做替换,用于提升标题检测置信度 |
| 禁止改写 | 命中则保留原文,标记人工复核。如 "权力/权利" |
词典文件:
config/legal_dictionaries/general_legal_terms.md— 预置通用纠错config/legal_dictionaries/court_document_patterns.md— 预置法院文书正则config/legal_dictionaries/user_dictionary.md— 用户自定义(首次运行自动创建)
Stage 5:印章/图表标注
- PaddleOCR 后端检测到的印章区域自动添加标注
- 格式:
 - 关联图片与 Markdown 输出路径
---
CLI 统一入口
legal-ocr <input_path_or_url>
--output <path> # 输出 Markdown 路径
--pages <spec> # 页码范围(如 "1-20")
--backend <name> # paddle | mineru | auto(默认)
--no-post-process # 跳过法律后处理
--no-archive # 跳过归档
--archive-name <name> # 自定义归档目录名
--seal-detection # 启用印章检测(PaddleOCR)
--model <version> # MinerU 模型:pipeline | vlm---
统一归档格式
archive/
└── 20260512_153000_某案卷宗/
├── input/
│ └── 某案卷宗.pdf
├── output/
│ ├── result.md # 最终 Markdown(后处理后)
│ ├── result_raw.md # OCR 原始输出(后处理前)
│ ├── result.json # 结构化结果元数据
│ └── images/
├── batches/ # PaddleOCR 分批 JSON(如适用)
├── backend_result/ # 后端特定原始输出
│ └── [content_list.json, layout.json 等]
├── postprocess_log.json # 纠正记录、词典命中
└── metadata.json # 含 backend、post_processing 统计---
两个后端的能力对比
| 能力 | PaddleOCR | MinerU |
|---|---|---|
| 本地 PDF | ✅ 自动分批 | ✅ 最大 600 页 |
| 本地图片 | ✅ | ✅ |
| DOC/DOCX/PPT/PPTX | ❌ | ✅ |
| 远程文档 URL | ⚠️ 有限制 | ✅ |
| 网页提取 | ❌ | ✅ (需 token) |
| 表格识别 | ✅ | ✅ (需 token) |
| 公式识别 | ✅ | ✅ (需 token) |
| 印章检测 | ✅ 专用能力 | ❌ |
| 图表识别 | ✅ 可开关 | ✅ |
| 零配置使用 | ❌ 需 token | ✅ Light 模式 |
| 图片预压缩 | ✅ | ❌ |
| 大文件分批 | ✅ 自动 | ❌ 单次上限 |
| 结构化内容提取 | ❌ | ✅ content_list.json |
---
实施阶段
Phase 1:基础框架 + PaddleOCR 后端
目标:创建 legal-ocr 骨架,PaddleOCR 后端可用。
- [ ] 创建
legal-ocr/目录结构 - [ ] 实现
backends/base.py(抽象接口 + OCRResult 数据类) - [ ] 实现
backends/paddle_ocr.py(从paddle-ocr/scripts/lib.py+convert.py提取适配) - [ ] 沿用
optimize_file.py、split_pdf.py - [ ] 实现统一配置加载
- [ ] 实现统一
convert.py入口(仅 PaddleOCR 后端) - [ ] 实现
smoke_test.py - [ ] 编写
SKILL.md
关键源文件:
paddle-ocr/scripts/lib.py(334 行,核心 HTTP 逻辑)paddle-ocr/scripts/convert.py(批处理、归档、图片保存)
Phase 2:MinerU Python 后端
目标:MinerU 后端从 JXA 迁移到 Python,功能对齐。
- [ ] 实现
backends/mineru_ocr.py(从 convert.js 1107 行 JXA 重写为 Python + httpx) - [ ] 四条转换路径:local/token、local/light、remote/token、remote/light
- [ ] Token 解析链:
.env→ 环境变量 →~/.mineru/config.yaml - [ ] Light 模式限制检查(10MB、20 页)
- [ ] URL 类型检测(文档 vs 网页)
- [ ] 远程图片下载
- [ ] 更新 smoke_test
关键源文件:
mineru-ocr/scripts/convert.js(1107 行,需完整重写)
Phase 3:路由 + 后处理
目标:自动路由和法律后处理管线可用。
- [ ] 实现
router.py(8 条路由规则) - [ ] 实现后处理管线(标点/标题/空行/词典/印章)
- [ ] 创建预置法律词典
- [ ] 实现
convert.jsJXA 兼容层
参考文件:
private-skills/post-ocr-formatter/assets/correction-dictionary.template.mdlegal-text-format/scripts/format_legal_cases.py
Phase 4:文档 + 迁移
目标:文档齐全,迁移路径清晰。
- [ ] 编写
references/output_schema.md - [ ] 编写
DECISIONS.md - [ ] 编写
CHANGELOG.md(v1.0.0) - [ ] 端到端测试:两条后端路径、自动路由、后处理、归档
- [ ] 在旧 Skill 中添加废弃提示
---
用户迁移路径
1. 安装 legal-ocr skill 2. 将 paddle-ocr/config/.env 中的 PADDLEOCR_* 值复制到 legal-ocr/config/.env 3. 将 mineru-ocr/config/.env 中的 MINERU_* 值复制到 legal-ocr/config/.env 4. 设置 LEGAL_OCR_BACKEND=auto 5. 旧 Skill 保持功能不变,无破坏性
稳定后(2-4 周)在旧 Skill 中添加废弃提示,最终归档。
---
验证方式
1. PaddleOCR 路径:用一份法律 PDF 运行 legal-ocr,确认输出与原 paddle-ocr 一致 2. MinerU 路径:同一 PDF 运行 --backend mineru,确认输出与原 mineru-ocr 一致 3. 自动路由:传入 .docx、网页 URL、>600 页 PDF,确认路由正确 4. 后处理:对比 raw Markdown 和 processed Markdown,确认标题/标点/词典效果 5. 回退:模拟一个后端失败,确认自动切换
---
关键架构决策
D-1:Python-first,不是 JXA-first
PaddleOCR 已是 Python。MinerU 的 JXA 逻辑是纯 HTTP+JSON,映射到 Python+httpx 很直接。统一 Python 代码库才能共享工具函数和模块化。
D-2:后端接口模式,不是适配器模式
每个后端直接实现 OCRBackend 接口,代码扁平可调试。convert() 方法各自负责分批、轮询、错误处理。
D-3:后处理默认开启,可关闭
LEGAL_OCR_POST_PROCESS=true 默认开启。需要原始 OCR 输出的用户可关闭。
D-4:保留所有现有环境变量名
PADDLEOCR_* 和 MINERU_* 全部保留,消除迁移摩擦。
D-5:PaddleOCR 为本地 PDF/图片的默认后端
PaddleOCR 有印章检测、大文件分批、图片预压缩等法律文档专用能力,更适合作为默认选择。MinerU 作为 PaddleOCR 不支持格式(DOCX、PPTX、网页)的默认选择。
MIT License
Copyright (c) 2025 杨卫薪律师(微信ywxlaw)
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.
输出结构说明
本技能有两层输出:
1. 底层接口层:scripts/layout_caller.py 输出稳定 JSON envelope 2. 高层法律工作流:scripts/convert.py 生成 Markdown,并把结构化结果写入 archive
一、layout_caller.py 输出结构
layout_caller.py 用于直接调用 PaddleOCR 接口,返回统一包装:
{
"ok": true,
"text": "从所有页面拼接出的 Markdown 文本",
"result": { "errorCode": 0, "result": { "...": "原始接口结果" } },
"error": null
}失败时:
{
"ok": false,
"text": "",
"result": null,
"error": {
"code": "CONFIG_ERROR | INPUT_ERROR | API_ERROR",
"message": "可直接展示给用户的错误信息"
}
}重点字段:
text:由result.result.layoutParsingResults[*].markdown.text拼接而成result.result.layoutParsingResults[*].markdown.images:页面内图片资源result.result.layoutParsingResults[*].prunedResult:坐标、分类、置信度等结构化版面信息
二、convert.py 的 archive 结构
convert.py 是高层入口,默认生成 Markdown 并写入 archive/。
归档目录示例:
archive/
└── 20260405_153000_某案卷宗/
├── input/
│ └── 某案卷宗.pdf
├── output/
│ ├── result.md
│ ├── result.json
│ └── images/
├── batches/
│ ├── batch_001_1-40.json
│ └── batch_002_41-67.json
└── metadata.jsonoutput/result.json
这是高层工作流的汇总文件,包含:
- 输入文件基本信息
- 处理模式(单次 / 自动分批)
- 提取出的全文 Markdown
- 输出图片列表
- 各批次摘要
示例:
{
"ok": true,
"source": {
"path": "/path/to/file.pdf",
"name": "file.pdf",
"sha256": "..."
},
"processing": {
"mode": "batched",
"batch_count": 2,
"total_pages": 67,
"processed_pages": 67,
"selected_pages": "1-67"
},
"text": "最终合并后的 Markdown",
"images": [],
"batches": [
{
"index": 1,
"label": "1-40",
"text_length": 12345,
"image_count": 2
}
]
}batches/*.json
每个批次对应一个底层 envelope,便于排查:
- 哪一批 OCR 异常
- 哪一批版面错乱
- 某页的
prunedResult是否需要单独读取
metadata.json
记录:
- 处理时间
- provider 名称
- 关键配置
- Markdown 输出路径
- 图片目录路径
三、建议读取顺序
如果只是要最终文本:
1. 读取 output/result.md
如果需要排查 OCR 质量:
1. 读取 output/result.json 2. 再按需读取 batches/*.json
如果需要提取表格、坐标、阅读顺序:
1. 直接运行 scripts/layout_caller.py 2. 读取 result.result.layoutParsingResults[*].prunedResult
ObjC.import("Foundation");
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function getScriptPath() {
const args = $.NSProcessInfo.processInfo.arguments;
if (args.count < 4) {
throw new Error("无法定位当前脚本路径");
}
return ObjC.unwrap(args.objectAtIndex(3));
}
function run(argv) {
const app = Application.currentApplication();
app.includeStandardAdditions = true;
const sh = (cmd) => app.doShellScript(cmd);
try {
const scriptPath = getScriptPath();
const scriptDir = sh(`/usr/bin/dirname ${shellQuote(scriptPath)}`).trim();
const pythonScript = `${scriptDir}/convert.py`;
const skillRoot = sh(`/usr/bin/dirname ${shellQuote(scriptDir)}`).trim();
const uvPath = sh(`/bin/zsh -lc 'command -v uv || true'`).trim();
const runner = uvPath ? `${shellQuote(uvPath)} run` : "/usr/bin/python3";
const argList = (argv || []).map(shellQuote).join(" ");
const command =
`cd ${shellQuote(skillRoot)} && ${runner} ${shellQuote(pythonScript)}` +
(argList ? ` ${argList}` : "");
const output = sh(command);
console.log(output);
return output;
} catch (error) {
const message =
"PaddleOCR 转换失败\n" +
"===============================================\n" +
`${error.message}\n\n` +
"建议:\n" +
"1. 先运行 `uv run scripts/smoke_test.py --skip-api-test`\n" +
"2. 检查 `config/.env` 是否已配置\n" +
"3. 直接运行 `uv run scripts/convert.py \"文件路径\"` 查看详细日志";
console.log(message);
return message;
}
}
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "httpx>=0.27.0",
# "pypdfium2>=4.30.0",
# ]
# ///
"""面向法律 PDF 的高层转换入口:产出 Markdown,并写入 archive。"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import shutil
import sys
import tempfile
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lib import ( # noqa: E402
SUPPORTED_LOCAL_SUFFIXES,
extract_markdown_and_images,
get_runtime_config,
get_skill_root,
is_pdf_file,
parse_document,
sanitize_name,
)
from split_pdf import ( # noqa: E402
extract_pages_to_pdf,
format_pages_compact,
get_pdf_page_count,
parse_pages_spec,
split_pdf_by_batch_size,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="将法律 PDF / 图片转换为 Markdown,并保留 archive 归档"
)
parser.add_argument("input", help="本地 PDF 或图片路径")
parser.add_argument(
"--output",
help="输出 Markdown 路径;如果不是 .md,则按目录处理",
)
parser.add_argument(
"--pages",
help='只处理指定页码,例如 "1-20" 或 "1-5,8,10-12"(仅 PDF)',
)
parser.add_argument(
"--archive-name",
help="自定义 archive 目录名后缀,默认使用输入文件名",
)
parser.add_argument(
"--no-archive",
action="store_true",
help="不写入 archive,仅输出 Markdown",
)
return parser.parse_args()
def sha256_of_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def estimate_base64_mb(path: Path) -> float:
return (path.stat().st_size * 4 / 3) / 1024 / 1024
def resolve_output_markdown_path(input_path: Path, output_arg: str | None) -> Path:
if not output_arg:
return input_path.with_suffix(".md").resolve()
output_path = Path(output_arg).expanduser()
if output_path.suffix.lower() == ".md":
return output_path.resolve()
if output_arg.endswith("/") or output_path.is_dir() or not output_path.suffix:
return (output_path / f"{input_path.stem}.md").resolve()
return output_path.resolve()
def resolve_images_dir(markdown_path: Path) -> Path:
return markdown_path.with_name(f"{markdown_path.stem}_images")
def write_markdown(markdown_path: Path, content: str) -> None:
markdown_path.parent.mkdir(parents=True, exist_ok=True)
markdown_path.write_text(content, encoding="utf-8")
def decode_base64_image(raw_data: str) -> bytes:
payload = raw_data.strip()
if payload.startswith("data:") and "," in payload:
payload = payload.split(",", 1)[1]
return base64.b64decode(payload)
def save_images(
batch_outputs: list[dict[str, Any]],
images_dir: Path,
) -> list[dict[str, str]]:
saved: list[dict[str, str]] = []
if images_dir.exists():
shutil.rmtree(images_dir)
if not any(batch["images"] for batch in batch_outputs):
return saved
images_dir.mkdir(parents=True, exist_ok=True)
used_names: set[str] = set()
for batch in batch_outputs:
batch_label = sanitize_name(batch["label"])
for index, (source_path, image_data) in enumerate(
sorted(batch["images"].items()),
start=1,
):
suffix = Path(source_path).suffix.lower() or ".png"
filename = f"{batch_label}_{index:03d}{suffix}"
while filename in used_names:
filename = f"{batch_label}_{index:03d}_{len(used_names):03d}{suffix}"
used_names.add(filename)
target_path = images_dir / filename
if str(image_data).startswith(("http://", "https://")):
urllib.request.urlretrieve(str(image_data), target_path)
else:
target_path.write_bytes(decode_base64_image(str(image_data)))
saved.append(
{
"batch": batch["label"],
"source": str(source_path),
"path": str(target_path),
"filename": filename,
}
)
return saved
def build_archive_paths(archive_root: Path, archive_name: str) -> dict[str, Path]:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
slug = sanitize_name(archive_name)
base = archive_root / f"{timestamp}_{slug}"
return {
"root": base,
"input": base / "input",
"output": base / "output",
"images": base / "output" / "images",
"batches": base / "batches",
"metadata": base / "metadata.json",
"result_md": base / "output" / "result.md",
"result_json": base / "output" / "result.json",
}
def copy_into_archive(
archive_paths: dict[str, Path],
input_path: Path,
output_md_path: Path,
images_dir: Path,
result_json: dict[str, Any],
batch_outputs: list[dict[str, Any]],
metadata: dict[str, Any],
) -> Path:
archive_paths["input"].mkdir(parents=True, exist_ok=True)
archive_paths["output"].mkdir(parents=True, exist_ok=True)
archive_paths["batches"].mkdir(parents=True, exist_ok=True)
shutil.copy2(input_path, archive_paths["input"] / input_path.name)
shutil.copy2(output_md_path, archive_paths["result_md"])
archive_paths["result_json"].write_text(
json.dumps(result_json, ensure_ascii=False, indent=2),
encoding="utf-8",
)
archive_paths["metadata"].write_text(
json.dumps(metadata, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if images_dir.exists():
shutil.copytree(images_dir, archive_paths["images"], dirs_exist_ok=True)
for index, batch in enumerate(batch_outputs, start=1):
label = sanitize_name(batch["label"])
batch_json_path = archive_paths["batches"] / f"batch_{index:03d}_{label}.json"
batch_json_path.write_text(
json.dumps(batch["envelope"], ensure_ascii=False, indent=2),
encoding="utf-8",
)
return archive_paths["root"]
def build_batch_output(
label: str,
input_path: Path,
) -> dict[str, Any]:
envelope = parse_document(file_path=str(input_path))
if not envelope.get("ok"):
error = envelope.get("error") or {}
raise RuntimeError(f"{label} 处理失败:{error.get('message', '未知错误')}")
text, images = extract_markdown_and_images(envelope["result"])
if not text.strip():
raise RuntimeError(f"{label} OCR 完成,但未提取到有效文本")
return {
"label": label,
"input_path": str(input_path),
"envelope": envelope,
"text": text,
"images": images,
}
def prepare_pdf_batches(
input_path: Path,
pages_spec: str | None,
temp_dir: Path,
batch_pages: int,
max_base64_mb: float,
) -> tuple[list[tuple[str, Path]], dict[str, Any]]:
total_pages = get_pdf_page_count(input_path)
selected_pages = (
parse_pages_spec(pages_spec, total_pages)
if pages_spec
else list(range(total_pages))
)
selected_label = format_pages_compact(selected_pages)
processed_page_count = len(selected_pages)
estimated_subset_base64_mb = estimate_base64_mb(input_path)
if processed_page_count and total_pages:
estimated_subset_base64_mb *= processed_page_count / total_pages
needs_batch = (
processed_page_count > batch_pages or estimated_subset_base64_mb > max_base64_mb
)
if needs_batch:
batch_specs = split_pdf_by_batch_size(
input_path=input_path,
output_dir=temp_dir / "pdf-batches",
batch_size=batch_pages,
page_indices=selected_pages,
)
batches = [(spec["label"], spec["path"]) for spec in batch_specs]
else:
if pages_spec:
single_path = temp_dir / f"{input_path.stem}_selected.pdf"
extract_pages_to_pdf(input_path, single_path, selected_pages)
batches = [(selected_label, single_path)]
else:
batches = [("all-pages", input_path)]
info = {
"total_pages": total_pages,
"processed_pages": processed_page_count,
"selected_pages": selected_label,
"needs_batch": needs_batch,
"batch_pages": batch_pages,
"estimated_base64_mb": round(estimated_subset_base64_mb, 2),
}
return batches, info
def process_input(
input_path: Path,
pages_spec: str | None,
batch_pages: int,
max_base64_mb: float,
temp_dir: Path,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if is_pdf_file(input_path):
batch_inputs, pdf_info = prepare_pdf_batches(
input_path=input_path,
pages_spec=pages_spec,
temp_dir=temp_dir,
batch_pages=batch_pages,
max_base64_mb=max_base64_mb,
)
else:
if pages_spec:
raise ValueError("--pages 仅适用于 PDF 文件")
batch_inputs = [(input_path.stem, input_path)]
pdf_info = {
"total_pages": None,
"processed_pages": 1,
"selected_pages": None,
"needs_batch": False,
"batch_pages": None,
"estimated_base64_mb": round(estimate_base64_mb(input_path), 2),
}
outputs = [build_batch_output(label, path) for label, path in batch_inputs]
return outputs, pdf_info
def main() -> int:
args = parse_args()
runtime = get_runtime_config()
input_path = Path(args.input).expanduser().resolve()
if not input_path.exists():
print(f"错误:文件不存在:{input_path}", file=sys.stderr)
return 1
if input_path.suffix.lower() not in SUPPORTED_LOCAL_SUFFIXES:
print(
f"错误:不支持的文件类型:{input_path.suffix.lower()}",
file=sys.stderr,
)
return 1
output_md_path = resolve_output_markdown_path(input_path, args.output)
output_images_dir = resolve_images_dir(output_md_path)
archive_root = get_skill_root() / "archive"
archive_name = args.archive_name or input_path.stem
temp_dir = Path(tempfile.mkdtemp(prefix="paddleocr_convert_"))
archive_path: Path | None = None
try:
batch_outputs, pdf_info = process_input(
input_path=input_path,
pages_spec=args.pages,
batch_pages=runtime["batch_pages"],
max_base64_mb=runtime["max_base64_mb"],
temp_dir=temp_dir,
)
merged_text = "\n\n".join(batch["text"].strip() for batch in batch_outputs).strip()
write_markdown(output_md_path, merged_text)
saved_images = save_images(batch_outputs, output_images_dir)
result_json = {
"ok": True,
"source": {
"path": str(input_path),
"name": input_path.name,
"sha256": sha256_of_file(input_path),
"kind": "pdf" if is_pdf_file(input_path) else "image",
},
"processing": {
"mode": "batched" if len(batch_outputs) > 1 else "single",
"batch_count": len(batch_outputs),
**pdf_info,
},
"text": merged_text,
"images": [
{
"batch": image["batch"],
"source": image["source"],
"filename": image["filename"],
"path": image["path"],
}
for image in saved_images
],
"batches": [
{
"index": index,
"label": batch["label"],
"input_path": batch["input_path"],
"text_length": len(batch["text"]),
"image_count": len(batch["images"]),
}
for index, batch in enumerate(batch_outputs, start=1)
],
}
metadata = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"provider": "PaddleOCR Document Parsing API",
"purpose": "法律 PDF / 图片 OCR,主产出为 Markdown,保留 archive 追溯链",
"config": {
"timeout_seconds": runtime["timeout_seconds"],
"doc_orientation": runtime["doc_orientation"],
"doc_unwarp": runtime["doc_unwarp"],
"chart_recognition": runtime["chart_recognition"],
"batch_pages": runtime["batch_pages"],
"max_base64_mb": runtime["max_base64_mb"],
},
"output_markdown": str(output_md_path),
"image_output_dir": str(output_images_dir) if saved_images else None,
}
if not args.no_archive:
archive_paths = build_archive_paths(archive_root, archive_name)
archive_path = copy_into_archive(
archive_paths=archive_paths,
input_path=input_path,
output_md_path=output_md_path,
images_dir=output_images_dir,
result_json=result_json,
batch_outputs=batch_outputs,
metadata=metadata,
)
print("转换完成")
print(f"Markdown: {output_md_path}")
if saved_images:
print(f"图片目录: {output_images_dir}")
if archive_path:
print(f"Archive: {archive_path}")
print(
"模式: "
+ ("自动分批" if len(batch_outputs) > 1 else "单次请求")
)
return 0
except Exception as error: # noqa: BLE001
print(f"转换失败:{error}", file=sys.stderr)
return 1
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "httpx>=0.27.0",
# ]
# ///
"""底层接口调用脚本:输出稳定 JSON envelope。"""
from __future__ import annotations
import argparse
import json
import sys
import tempfile
import uuid
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lib import parse_document # noqa: E402
def default_output_path() -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
short_id = uuid.uuid4().hex[:8]
return (
Path(tempfile.gettempdir())
/ "paddleocr"
/ "legal-doc-parsing"
/ f"result_{timestamp}_{short_id}.json"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="调用 PaddleOCR 接口并输出稳定 JSON envelope"
)
source_group = parser.add_mutually_exclusive_group(required=True)
source_group.add_argument("--file-path", help="本地 PDF / 图片路径")
source_group.add_argument("--file-url", help="远程 PDF / 图片 URL")
parser.add_argument(
"--file-type",
type=int,
choices=[0, 1],
help="显式指定文件类型:0=PDF,1=图片",
)
parser.add_argument("--pretty", action="store_true", help="格式化 JSON 输出")
output_group = parser.add_mutually_exclusive_group()
output_group.add_argument("--stdout", action="store_true", help="直接打印 JSON")
output_group.add_argument("--output", help="写入指定 JSON 文件")
return parser.parse_args()
def main() -> int:
args = parse_args()
envelope = parse_document(
file_path=args.file_path,
file_url=args.file_url,
file_type=args.file_type,
)
json_text = json.dumps(
envelope,
ensure_ascii=False,
indent=2 if args.pretty else None,
)
if args.stdout:
print(json_text)
else:
output_path = Path(args.output).expanduser().resolve() if args.output else default_output_path()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_text, encoding="utf-8")
print(f"Result saved to: {output_path}", file=sys.stderr)
return 0 if envelope.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())
from __future__ import annotations
import base64
import math
import os
import re
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
import httpx
DEFAULT_TIMEOUT_SECONDS = 600
DEFAULT_BATCH_PAGES = 40
DEFAULT_MAX_BASE64_MB = 20.0
SUPPORTED_IMAGE_SUFFIXES = (
".png",
".jpg",
".jpeg",
".bmp",
".tiff",
".tif",
".webp",
)
SUPPORTED_LOCAL_SUFFIXES = (".pdf",) + SUPPORTED_IMAGE_SUFFIXES
def get_skill_root() -> Path:
return Path(__file__).resolve().parent.parent
def get_config_path() -> Path:
return get_skill_root() / "config" / ".env"
def read_env_file(path: Path) -> dict[str, str]:
if not path.exists():
return {}
env: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
def parse_bool(value: str | bool | None, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if not normalized:
return default
return normalized in {"1", "true", "yes", "on"}
def parse_positive_int(value: str | None, default: int) -> int:
if not value or not str(value).strip():
return default
try:
parsed = int(str(value).strip())
except ValueError as error:
raise ValueError(f"整数配置无效:{value}") from error
if parsed <= 0:
raise ValueError(f"整数配置必须大于 0:{value}")
return parsed
def parse_positive_float(value: str | None, default: float) -> float:
if not value or not str(value).strip():
return default
try:
parsed = float(str(value).strip())
except ValueError as error:
raise ValueError(f"数字配置无效:{value}") from error
if not math.isfinite(parsed) or parsed <= 0:
raise ValueError(f"数字配置必须大于 0:{value}")
return parsed
def sanitize_name(value: str) -> str:
sanitized = re.sub(r"[^\w\u4e00-\u9fff.-]+", "_", value, flags=re.UNICODE)
sanitized = sanitized.strip("._")
return sanitized or "document"
def first_non_empty(env: dict[str, str], *keys: str) -> str:
for key in keys:
value = env.get(key, "").strip()
if value:
return value
return ""
def normalize_api_url(api_url: str) -> str:
url = api_url.strip()
if not url:
raise ValueError("未配置 PaddleOCR API 地址")
if "://" not in url:
url = f"https://{url}"
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in {"https", "http"}:
raise ValueError("PaddleOCR API 地址必须以 https:// 或 http:// 开头")
if parsed.scheme == "http" and host not in {"127.0.0.1", "localhost"}:
raise ValueError("仅允许 localhost / 127.0.0.1 使用 http://")
if not parsed.path.rstrip("/").endswith("/layout-parsing"):
raise ValueError(
"PaddleOCR API 地址必须是完整的 layout-parsing 端点,例如 "
"https://your-endpoint/layout-parsing"
)
return url
def get_runtime_config() -> dict[str, Any]:
file_env = read_env_file(get_config_path())
merged_env = {**file_env, **os.environ}
api_url = first_non_empty(merged_env, "PADDLEOCR_DOC_PARSING_API_URL")
access_token = first_non_empty(merged_env, "PADDLEOCR_ACCESS_TOKEN")
if not api_url:
raise ValueError(
"未配置 PADDLEOCR_DOC_PARSING_API_URL。请先编辑 paddle-ocr/config/.env。"
)
if not access_token:
raise ValueError(
"未配置 PADDLEOCR_ACCESS_TOKEN。请先编辑 paddle-ocr/config/.env。"
)
return {
"api_url": normalize_api_url(api_url),
"access_token": access_token,
"doc_orientation": parse_bool(
first_non_empty(merged_env, "PADDLEOCR_DOC_ORIENTATION"),
default=False,
),
"doc_unwarp": parse_bool(
first_non_empty(merged_env, "PADDLEOCR_DOC_UNWARP"),
default=False,
),
"chart_recognition": parse_bool(
first_non_empty(merged_env, "PADDLEOCR_CHART_RECOG"),
default=False,
),
"timeout_seconds": parse_positive_float(
first_non_empty(merged_env, "PADDLEOCR_DOC_PARSING_TIMEOUT"),
default=DEFAULT_TIMEOUT_SECONDS,
),
"batch_pages": parse_positive_int(
first_non_empty(merged_env, "PADDLEOCR_BATCH_PAGES"),
default=DEFAULT_BATCH_PAGES,
),
"max_base64_mb": parse_positive_float(
first_non_empty(merged_env, "PADDLEOCR_MAX_BASE64_MB"),
default=DEFAULT_MAX_BASE64_MB,
),
"log_level": first_non_empty(merged_env, "PADDLEOCR_LOG_LEVEL") or "medium",
}
def detect_file_type(path_or_url: str) -> int:
normalized = path_or_url.lower()
if normalized.startswith(("http://", "https://")):
normalized = unquote(urlparse(normalized).path)
if normalized.endswith(".pdf"):
return 0
if normalized.endswith(SUPPORTED_IMAGE_SUFFIXES):
return 1
raise ValueError(f"不支持的文件类型:{path_or_url}")
def is_pdf_file(path: Path) -> bool:
return path.suffix.lower() == ".pdf"
def load_file_as_base64(file_path: str) -> str:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"文件不存在:{file_path}")
if not path.is_file():
raise ValueError(f"不是普通文件:{file_path}")
if path.stat().st_size == 0:
raise ValueError(f"文件为空:{file_path}")
return base64.b64encode(path.read_bytes()).decode("utf-8")
def _error(code: str, message: str) -> dict[str, Any]:
return {
"ok": False,
"text": "",
"result": None,
"error": {"code": code, "message": message},
}
def make_request(
*,
api_url: str,
access_token: str,
payload: dict[str, Any],
timeout_seconds: float,
) -> dict[str, Any]:
headers = {
"Authorization": f"token {access_token}",
"Content-Type": "application/json",
"Client-Platform": "private-legal-skill",
}
try:
with httpx.Client(timeout=timeout_seconds) as client:
response = client.post(api_url, json=payload, headers=headers)
except httpx.TimeoutException as error:
raise RuntimeError(f"请求超时:{timeout_seconds} 秒") from error
except httpx.RequestError as error:
raise RuntimeError(f"网络请求失败:{error}") from error
if response.status_code != 200:
detail = response.text[:500].strip() or "空响应"
if response.status_code == 403:
raise RuntimeError(f"鉴权失败(403):{detail}")
if response.status_code == 429:
raise RuntimeError(f"配额或频率受限(429):{detail}")
raise RuntimeError(f"接口错误({response.status_code}):{detail}")
try:
result = response.json()
except ValueError as error:
raise RuntimeError(f"接口返回的不是合法 JSON:{response.text[:200]}") from error
if not isinstance(result, dict):
raise RuntimeError("接口返回结构异常:顶层不是对象")
if result.get("errorCode", 0) != 0:
raise RuntimeError(f"接口返回错误:{result.get('errorMsg', '未知错误')}")
return result
def extract_markdown_and_images(provider_result: dict[str, Any]) -> tuple[str, dict[str, str]]:
raw_result = provider_result.get("result")
if not isinstance(raw_result, dict):
raise ValueError("接口返回结构异常:缺少 result 对象")
layout_results = raw_result.get("layoutParsingResults")
if not isinstance(layout_results, list) or not layout_results:
raise ValueError("接口未返回 layoutParsingResults")
texts: list[str] = []
images: dict[str, str] = {}
for index, page_result in enumerate(layout_results):
if not isinstance(page_result, dict):
raise ValueError(f"第 {index + 1} 页结构异常")
markdown = page_result.get("markdown")
if not isinstance(markdown, dict):
raise ValueError(f"第 {index + 1} 页缺少 markdown 字段")
text = markdown.get("text")
if isinstance(text, str) and text.strip():
texts.append(text)
page_images = markdown.get("images")
if isinstance(page_images, dict):
for key, value in page_images.items():
images[str(key)] = str(value)
return "\n\n".join(texts), images
def parse_document(
*,
file_path: str | None = None,
file_url: str | None = None,
file_type: int | None = None,
visualize: bool = False,
) -> dict[str, Any]:
if bool(file_path) == bool(file_url):
return _error("INPUT_ERROR", "必须在 file_path 和 file_url 中二选一")
try:
runtime = get_runtime_config()
except ValueError as error:
return _error("CONFIG_ERROR", str(error))
try:
if file_path:
resolved_file_type = file_type if file_type is not None else detect_file_type(file_path)
payload = {
"file": load_file_as_base64(file_path),
"fileType": resolved_file_type,
}
else:
assert file_url is not None
payload = {
"file": file_url.strip(),
}
try:
resolved_file_type = file_type if file_type is not None else detect_file_type(file_url)
except ValueError:
resolved_file_type = None
if resolved_file_type is not None:
payload["fileType"] = resolved_file_type
payload["useDocOrientationClassify"] = runtime["doc_orientation"]
payload["useDocUnwarping"] = runtime["doc_unwarp"]
payload["useChartRecognition"] = runtime["chart_recognition"]
payload["visualize"] = visualize
except (ValueError, OSError, MemoryError) as error:
return _error("INPUT_ERROR", str(error))
try:
provider_result = make_request(
api_url=runtime["api_url"],
access_token=runtime["access_token"],
payload=payload,
timeout_seconds=runtime["timeout_seconds"],
)
text, _images = extract_markdown_and_images(provider_result)
except (RuntimeError, ValueError) as error:
return _error("API_ERROR", str(error))
return {
"ok": True,
"text": text,
"result": provider_result,
"error": None,
}
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "Pillow>=10.0.0",
# ]
# ///
"""扫描图片压缩工具,适合先压缩后做 OCR。"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
from PIL import Image
DEFAULT_QUALITY = 85
DEFAULT_TARGET_MB = 20.0
SUPPORTED_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp")
def positive_int(value: str) -> int:
parsed = int(value)
if parsed < 1 or parsed > 100:
raise argparse.ArgumentTypeError("quality 必须在 1-100 之间")
return parsed
def positive_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed) or parsed <= 0:
raise argparse.ArgumentTypeError("target-size 必须大于 0")
return parsed
def optimize_image(input_path: Path, output_path: Path, quality: int, target_mb: float) -> None:
image = Image.open(input_path)
original_size = input_path.stat().st_size / 1024 / 1024
print(f"原始大小:{original_size:.2f} MB")
print(f"原始尺寸:{image.size[0]}x{image.size[1]}")
is_jpeg_like = output_path.suffix.lower() in {".jpg", ".jpeg", ".webp"}
if is_jpeg_like and image.mode in {"RGBA", "LA", "P"}:
background = Image.new("RGB", image.size, (255, 255, 255))
if image.mode == "P":
image = image.convert("RGBA")
background.paste(image, mask=image.split()[-1] if image.mode in {"RGBA", "LA"} else None)
image = background
save_kwargs = {"optimize": True}
if is_jpeg_like:
save_kwargs["quality"] = quality
def save_current(current_image: Image.Image) -> float:
output_path.parent.mkdir(parents=True, exist_ok=True)
current_image.save(output_path, **save_kwargs)
return output_path.stat().st_size / 1024 / 1024
current_size = save_current(image)
scale_factor = 0.9
while current_size > target_mb and scale_factor >= 0.4:
width = max(1, int(image.size[0] * scale_factor))
height = max(1, int(image.size[1] * scale_factor))
resized = image.resize((width, height), Image.Resampling.LANCZOS)
current_size = save_current(resized)
print(f"调整后尺寸:{width}x{height},大小:{current_size:.2f} MB")
scale_factor -= 0.1
print(f"优化后大小:{current_size:.2f} MB")
print(f"已输出:{output_path}")
def main() -> int:
parser = argparse.ArgumentParser(description="压缩 OCR 输入图片")
parser.add_argument("input", help="输入图片")
parser.add_argument("output", help="输出图片")
parser.add_argument("--quality", type=positive_int, default=DEFAULT_QUALITY)
parser.add_argument("--target-size", type=positive_float, default=DEFAULT_TARGET_MB)
args = parser.parse_args()
input_path = Path(args.input).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
if not input_path.exists():
print(f"错误:文件不存在:{input_path}")
return 1
if input_path.suffix.lower() not in SUPPORTED_SUFFIXES:
print(f"错误:不支持的图片格式:{input_path.suffix.lower()}")
return 1
optimize_image(input_path, output_path, args.quality, args.target_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "httpx>=0.27.0",
# ]
# ///
"""配置与连通性自检。"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lib import get_runtime_config, parse_document # noqa: E402
DEFAULT_TEST_URL = (
"https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/"
"pp_structure_v3_demo.png"
)
def masked_token(token: str) -> str:
if len(token) <= 12:
return "***"
return f"{token[:8]}...{token[-4:]}"
def main() -> int:
parser = argparse.ArgumentParser(description="PaddleOCR skill 自检")
parser.add_argument("--skip-api-test", action="store_true", help="只检查配置,不发请求")
parser.add_argument("--test-url", help="覆盖默认测试文档 URL")
args = parser.parse_args()
print("=" * 60)
print("PaddleOCR Skill Smoke Test")
print("=" * 60)
try:
runtime = get_runtime_config()
except ValueError as error:
print(f"配置错误:{error}")
print("请先编辑 paddle-ocr/config/.env")
return 1
print("配置检查通过")
print(f"API URL: {runtime['api_url']}")
print(f"Token: {masked_token(runtime['access_token'])}")
print(f"Timeout: {runtime['timeout_seconds']} 秒")
print(f"Batch pages: {runtime['batch_pages']}")
if args.skip_api_test:
print("已跳过 API 连通性测试")
return 0
test_url = args.test_url or DEFAULT_TEST_URL
print(f"测试文档: {test_url}")
result = parse_document(file_url=test_url)
if not result.get("ok"):
error = result.get("error") or {}
print(f"API 测试失败:{error.get('message', '未知错误')}")
return 1
preview = result.get("text", "").replace("\n", " ")[:200]
if preview:
print(f"文本预览: {preview}...")
print("API 测试通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "pypdfium2>=4.30.0",
# ]
# ///
"""PDF 页码提取与自动分批工具。"""
from __future__ import annotations
import argparse
from pathlib import Path
import pypdfium2 as pdfium
def get_pdf_page_count(input_path: Path) -> int:
document = pdfium.PdfDocument(str(input_path))
try:
return len(document)
finally:
document.close()
def parse_pages_spec(pages_spec: str, total_pages: int) -> list[int]:
if not pages_spec or not pages_spec.strip():
raise ValueError("页码范围不能为空")
selected: list[int] = []
seen: set[int] = set()
def add_page(page_number: int) -> None:
if page_number < 1 or page_number > total_pages:
raise ValueError(f"页码超出范围:{page_number},有效范围是 1-{total_pages}")
index = page_number - 1
if index not in seen:
seen.add(index)
selected.append(index)
for token in [part.strip() for part in pages_spec.split(",") if part.strip()]:
if "-" in token:
start_text, end_text = token.split("-", 1)
if not start_text.isdigit() or not end_text.isdigit():
raise ValueError(f"非法页码范围:{token}")
start_page, end_page = int(start_text), int(end_text)
if start_page > end_page:
raise ValueError(f"起始页不能大于结束页:{token}")
for page_number in range(start_page, end_page + 1):
add_page(page_number)
else:
if not token.isdigit():
raise ValueError(f"非法页码:{token}")
add_page(int(token))
if not selected:
raise ValueError("没有解析出有效页码")
return selected
def format_pages_compact(page_indices: list[int]) -> str:
if not page_indices:
return "none"
pages = sorted(index + 1 for index in page_indices)
ranges: list[str] = []
start = prev = pages[0]
for page in pages[1:]:
if page == prev + 1:
prev = page
continue
ranges.append(f"{start}-{prev}" if start != prev else str(start))
start = prev = page
ranges.append(f"{start}-{prev}" if start != prev else str(start))
return ",".join(ranges)
def extract_pages_to_pdf(input_path: Path, output_path: Path, page_indices: list[int]) -> None:
source_pdf = pdfium.PdfDocument(str(input_path))
try:
output_pdf = pdfium.PdfDocument.new()
try:
output_pdf.import_pages(source_pdf, page_indices)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_pdf.save(str(output_path))
finally:
output_pdf.close()
finally:
source_pdf.close()
def split_pdf_by_batch_size(
*,
input_path: Path,
output_dir: Path,
batch_size: int,
page_indices: list[int] | None = None,
) -> list[dict[str, Path | str]]:
total_pages = get_pdf_page_count(input_path)
selected_pages = page_indices or list(range(total_pages))
if batch_size <= 0:
raise ValueError("batch_size 必须大于 0")
output_dir.mkdir(parents=True, exist_ok=True)
batches: list[dict[str, Path | str]] = []
for batch_index in range(0, len(selected_pages), batch_size):
chunk = selected_pages[batch_index : batch_index + batch_size]
label = format_pages_compact(chunk)
filename = f"batch_{batch_index // batch_size + 1:03d}_{label.replace(',', '_')}.pdf"
output_path = output_dir / filename
extract_pages_to_pdf(input_path, output_path, chunk)
batches.append({"label": label, "path": output_path})
return batches
def main() -> int:
parser = argparse.ArgumentParser(description="按页码范围提取 PDF")
parser.add_argument("input_pdf", help="输入 PDF")
parser.add_argument("output_pdf", help="输出 PDF")
parser.add_argument("--pages", required=True, help='页码范围,例如 "1-5,8,10-12"')
args = parser.parse_args()
input_path = Path(args.input_pdf).expanduser().resolve()
output_path = Path(args.output_pdf).expanduser().resolve()
if not input_path.exists():
print(f"错误:文件不存在:{input_path}")
return 1
if input_path.suffix.lower() != ".pdf":
print(f"错误:输入必须是 PDF:{input_path}")
return 1
if output_path.suffix.lower() != ".pdf":
print(f"错误:输出必须是 PDF:{output_path}")
return 1
total_pages = get_pdf_page_count(input_path)
page_indices = parse_pages_spec(args.pages, total_pages)
extract_pages_to_pdf(input_path, output_path, page_indices)
print(f"已生成:{output_path}")
print(f"原始总页数:{total_pages}")
print(f"提取页码:{format_pages_compact(page_indices)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())