Skill: cat-xierluo/paddle-ocr (version 1.1.1)

--- name: paddle-ocr homepage: https://github.com/cat-xierluo/legal-skills author: 杨卫薪律师（微信ywxlaw） version: "1.1.1" license: MIT description: 面向法律 PDF 与扫描件的 PaddleOCR 结构化解析技能。默认将本地 PDF 或图片转换为 Markdown，并在技能内部保留可追溯 archive 归档。本技能应在用户需要法律 PDF OCR、卷宗 OCR、病历 OCR、证据扫描件转 Markdown、表格识别、公式识别、版面分析、PDF 转 Markdown、复杂 PDF 解析时使用。 --- # 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 官网](https://www.paddleocr.com) 2. 进入对应模型的 API 页面 3. 在示例代码中复制： - `API_URL` - `Access Token` ### 配置方式 优先编辑 `config/.env`： ```bash cd paddle-ocr/config cp .env.example .env nano .env ``` 必填项： - `PADDLEOCR_DOC_PARSING_API_URL` - `PADDLEOCR_ACCESS_TOKEN` ## 常用命令 ### 主工作流：生成 Markdown + archive 在技能根目录运行： ```bash uv run scripts/convert.py "/path/to/legal-document.pdf" ``` 或继续兼容旧入口： ```bash /usr/bin/osascript -l JavaScript scripts/convert.js "/path/to/legal-document.pdf" ``` 可选参数： ```bash 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 ```bash 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 ``` 当你只想检查原始接口结果，或后续要自己解析表格/坐标信息时，使用这个底层脚本。 ### 自检 ```bash uv run scripts/smoke_test.py --skip-api-test uv run scripts/smoke_test.py ``` ### 拆分页码 ```bash 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.md` - `output/result.json` - `metadata.json` - `batches/*.json` ## 大文件策略 本技能为了法律材料的稳定性，默认采用**保守批次策略**： - PDF 页数超过 `PADDLEOCR_BATCH_PAGES` 时自动分批 - 预估 Base64 大小超过 `PADDLEOCR_MAX_BASE64_MB` 时自动分批 这意味着它可能比官方上限更早拆分，但通常能降低长卷宗、病历合并件和扫描质量不稳定文档的失败率。 ## 输出说明 ### Markdown - 默认保存到源文件同目录 - 如果传 `--output` 且是 `.md` 文件路径，则保存到指定路径 - 如果 `--output` 是目录，则在该目录下生成同名 `.md` ### archive 默认归档目录结构： ```text 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` | ## 维护建议 修改本技能后，同步更新： - `CHANGELOG.md`

path: CHANGELOG.md
content: # 变更记录

## [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 的差异说明。


path: LICENSE.txt
content: 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.


path: references/output_schema.md
content: # 输出结构说明

本技能有两层输出：

1. **底层接口层**：`scripts/layout_caller.py` 输出稳定 JSON envelope
2. **高层法律工作流**：`scripts/convert.py` 生成 Markdown，并把结构化结果写入 archive

## 一、`layout_caller.py` 输出结构

`layout_caller.py` 用于直接调用 PaddleOCR 接口，返回统一包装：

```json
{
  "ok": true,
  "text": "从所有页面拼接出的 Markdown 文本",
  "result": { "errorCode": 0, "result": { "...": "原始接口结果" } },
  "error": null
}
```

失败时：

```json
{
  "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/`。

归档目录示例：

```text
archive/
└── 20260405_153000_某案卷宗/
    ├── input/
    │   └── 某案卷宗.pdf
    ├── output/
    │   ├── result.md
    │   ├── result.json
    │   └── images/
    ├── batches/
    │   ├── batch_001_1-40.json
    │   └── batch_002_41-67.json
    └── metadata.json
```

### `output/result.json`

这是高层工作流的汇总文件，包含：

- 输入文件基本信息
- 处理模式（单次 / 自动分批）
- 提取出的全文 Markdown
- 输出图片列表
- 各批次摘要

示例：

```json
{
  "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`


path: scripts/convert.js
content: 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;
  }
}


path: scripts/convert.py
content: #!/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())


path: scripts/layout_caller.py
content: #!/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())


path: scripts/lib.py
content: 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,
    }


path: scripts/optimize_file.py
content: #!/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())


path: scripts/smoke_test.py
content: #!/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"[REDACTED_SECRET]'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())


path: scripts/split_pdf.py
content: #!/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 [REDACTED_SECRET], 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())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)