mirror of
https://github.com/opendatalab/MinerU.git
synced 2026-10-25 09:35:17 +08:00
@@ -92,7 +92,7 @@ GET /v1/models HTTP/1.1
|
||||
```
|
||||
|
||||
- 描述性 tier 文案(如 `Standard parsing for most documents.`)属于 `/v1/tiers` 返回的 tier info;`/v1/models` 的 `description` 当前为 `null`。
|
||||
Local Parse Server 默认(`--tier standard`)按上述顺序返回 `MinerU-Flash`、`Hybrid-Basic`、`MinerU-HTML`、`MinerU2.5-Pro-2605-1.2B`;实际模型列表由启动 `--tier`(与 `--no-flash`)决定。
|
||||
Local Parse Server 默认(`--tier standard`)按上述顺序返回 `MinerU-Flash`、`Hybrid-Basic`、`MinerU-HTML`、`MinerU2.5-Pro-2605-1.2B`;实际模型列表由启动 `--tier` 与 tier 禁用开关决定。禁用 Advanced 后,Standard 仍需使用的共享模型不会从列表移除。
|
||||
|
||||
模型对象字段:
|
||||
|
||||
@@ -198,8 +198,11 @@ Local Parse Server 的 `health` 通常返回:
|
||||
| `--tier flash` | `flash`;默认选择请求应失败,除非显式请求 Flash。 |
|
||||
| `--tier basic` | `flash`、`basic` |
|
||||
| `--tier basic --no-flash` | `basic` |
|
||||
| `--tier basic --no-advanced` | `flash`、`basic` |
|
||||
| `--tier standard` | `flash`、`basic`、`standard`、`advanced` |
|
||||
| `--tier standard --no-flash` | `basic`、`standard`、`advanced` |
|
||||
| `--tier standard --no-advanced` | `flash`、`basic`、`standard` |
|
||||
| `--tier standard --no-flash --no-advanced` | `basic`、`standard` |
|
||||
|
||||
本地或兼容服务应以 `/v1/tiers` 返回值作为能力发现事实。客户端不应根据启动参数猜测具体能力。
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Job 状态:
|
||||
}
|
||||
],
|
||||
"tier": null,
|
||||
"ocr_mode": "auto",
|
||||
"output_formats": ["markdown", "middle_json", "structured_content", "zip"],
|
||||
"callback": {
|
||||
"url": "https://your.app/mineru-webhook",
|
||||
@@ -54,9 +55,12 @@ Job 状态:
|
||||
|------|------|:--:|------|------|
|
||||
| `files` | array | 是 | 无 | 至少 1 个,最多由 access level 决定。 |
|
||||
| `tier` | string 或 null | 否 | `null` | 当前服务支持的 tier 或 `null`。省略或传 `null` 表示使用默认选择策略;完整 tier 语义见 [解析 Tier](../tiers.md) 与 [ADR-0024](../decisions/0024-file-type-tier-normalization.md)。HTML 输入自动路由到 HTML 解析器。 |
|
||||
| `ocr_mode` | string | 否 | `"auto"` | 本次任务的解析模式:`auto` 自动判断、`txt` 使用文本层、`ocr` 强制 OCR。覆盖所有 tier,沿用底层 PDF/图片解析语义;其他格式使用各自的固定解析路径。非法值或 `null` 返回 HTTP 400。 |
|
||||
| `output_formats` | array | 否 | `["markdown"]` | 请求产物格式。 |
|
||||
| `callback` | object | 否 | `null` | Webhook 回调配置,官方 API registered 用户可用。 |
|
||||
|
||||
`ocr_mode` 在同一任务的文件间共用,任务之间独立。省略时固定使用 `auto`;Local Parse Server 不再通过启动参数设置全局 OCR 模式。
|
||||
|
||||
HTML job 固定使用静态 `auto` 正文选择,不执行 JavaScript,也不下载页面引用的远程图片。URL source 的来源 URL
|
||||
会用于解析相对链接;本地 source 的相对图片仅允许位于源 HTML 的安全根目录内。
|
||||
|
||||
|
||||
@@ -48,13 +48,18 @@ api-server 必须提供能力发现接口,让 doclib 或客户端知道当前
|
||||
| `--tier flash` | `flash` | 返回 `quality_tier_unavailable`,除非请求显式传 `tier=flash` |
|
||||
| `--tier basic` | `flash`、`basic` | `basic` |
|
||||
| `--tier basic --no-flash` | `basic` | `basic` |
|
||||
| `--tier basic --no-advanced` | `flash`、`basic` | `basic` |
|
||||
| `--tier standard` 或未传 `--tier` | `flash`、`basic`、`standard`、`advanced` | `standard` |
|
||||
| `--tier standard --no-flash` | `basic`、`standard`、`advanced` | `standard` |
|
||||
| `--tier standard --no-advanced` | `flash`、`basic`、`standard` | `standard` |
|
||||
| `--tier standard --no-flash --no-advanced` | `basic`、`standard` | `standard` |
|
||||
|
||||
因此,如果只以 `--tier flash` 启动裸 api-server,请求未指定 tier 时不应静默使用 `flash`。需要 `flash` 时调用方必须显式传 `tier=flash`;非 PDF/image 文件的批量归一规则见 [ADR-0024](../decisions/0024-file-type-tier-normalization.md)。
|
||||
|
||||
`--no-flash` 会同时关闭 Flash 能力发现和执行。显式 Flash 请求以及 OFD/EPUB/Office/HTML/CSV 等必须归一到 Flash 的输入都会被拒绝。`--tier flash --no-flash` 因为没有可用能力而启动失败。
|
||||
|
||||
`--no-advanced` 会同时关闭 Advanced 能力发现和执行:`GET /v1/tiers` 不再发布 `advanced`,显式 Advanced 请求返回该 tier 不可用。Advanced 不是启动 tier,因此它与 `--tier flash` 或 `--tier basic` 组合时不会改变原有能力;Standard 仍保留自身所需的共享模型。
|
||||
|
||||
如果不同启动能力需要不同硬件、并发或生命周期策略,应启动多个 api-server 进程并由 doclib 或上层配置分别管理 URL。Doclib managed server 固定使用 `--no-flash`,因为 Flash 在 doclib 进程内执行。
|
||||
|
||||
## 4. self-hosted 与 managed
|
||||
@@ -74,13 +79,57 @@ api-server 启动时可使用单个 `--tier` 指定能力上限:
|
||||
mineru-kit api-server --tier basic --port 16580
|
||||
mineru-kit api-server --tier standard --port 15982
|
||||
mineru-kit api-server --tier standard --no-flash --port 8000
|
||||
mineru-kit api-server --tier standard --no-advanced --port 8000
|
||||
mineru-kit api-server --tier standard --no-flash --no-advanced --port 8000
|
||||
mineru-kit api-server --tier standard --preload-models
|
||||
mineru-kit api-server --tier standard --language en --ocr-mode ocr --disable-image-analysis
|
||||
mineru-kit api-server --tier standard --language en --disable-image-analysis
|
||||
```
|
||||
|
||||
未传 `--tier` 时暴露 `flash`、`basic`、`standard`、`advanced`;PDF/image 请求未指定 tier 时默认 `standard`。
|
||||
|
||||
模型默认在首次解析时懒加载。`--preload-models` 会在 Basic 或 Standard 服务启动时提前加载所需模型,并在加载失败时让能力接口返回明确错误;Flash 没有本地模型,该参数对 Flash 无操作。Doclib managed parse-server 会自动启用模型预加载。
|
||||
OCR 模式通过每次 `POST /v1/parse/jobs` 请求的 `ocr_mode` 设置,可选 `auto`、`txt`、`ocr`,省略时为 `auto`。启动参数 `--ocr-mode` 已移除,传入会报错;Python `create_app()` 同样不再接受该参数。
|
||||
|
||||
模型默认在首次解析时懒加载。`--preload-models` 会在 Basic 或 Standard 服务启动时提前初始化所需模型或 VLM 客户端,并在失败时让能力接口返回明确错误;Flash 没有本地模型,该参数对 Flash 无操作。Doclib managed parse-server 会自动启用模型预加载。
|
||||
|
||||
### 使用远程 MinerU VLM 服务
|
||||
|
||||
```bash
|
||||
mineru-kit api-server --tier standard --vlm-server-url http://127.0.0.1:30000/v1
|
||||
mineru-api --vlm-server-url https://vlm.example.com/proxy/v1 --vlm-model mineru-model --preload-models
|
||||
```
|
||||
|
||||
两个命令和 `python -m mineru.parser.api_server` 支持相同的连接参数:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--vlm-server-url` | 空 | 远程服务地址,空值选择本地 VLM |
|
||||
| `--vlm-api-key` | 空 | 远程 VLM 服务的 Bearer Key |
|
||||
| `--vlm-model` | 空 | 上游模型名;为空时通过 `/v1/models` 唯一发现 |
|
||||
| `--vlm-http-timeout` | 600 | VLM HTTP 读写超时,单位秒,必须为正整数 |
|
||||
| `--vlm-max-concurrency` | 100 | VLM 推理请求并发,必须为正整数 |
|
||||
|
||||
表中为未配置时的默认值。命令行只覆盖显式传入的字段,其余读取全局 `model.vlm`;
|
||||
环境变量覆盖 YAML。详见 [远程 VLM 配置](../config.md#远程-vlm)。例如:
|
||||
|
||||
```bash
|
||||
export MINERU_MODEL_VLM_SERVER_URL=http://127.0.0.1:30000/v1
|
||||
export MINERU_MODEL_VLM_API_KEY=your-vlm-key
|
||||
mineru-kit api-server --vlm-http-timeout 120 --vlm-max-concurrency 16
|
||||
# 本次启动覆盖全局地址,恢复本地 VLM:
|
||||
mineru-kit api-server --vlm-server-url ""
|
||||
```
|
||||
|
||||
`--api-key` 认证解析 API 的调用方,`--vlm-api-key` 认证 API server 对上游 VLM 的请求,
|
||||
两者独立。远程连接只用于 Standard/Advanced 的 VLM 推理,仍需本地 Hybrid 模型;
|
||||
启动依赖预检不要求本地 VLM 引擎。Basic/Flash 不会连接远程 VLM。
|
||||
|
||||
启用 `--preload-models` 时,Standard 服务连接上游进行模型发现或校验,并预加载本地
|
||||
Hybrid 模型;否则上游连接推迟到首次 Standard/Advanced 解析。连接、认证或模型校验失败
|
||||
通过已有预加载错误或解析任务错误返回,不自动回退本地 VLM。推理重试继续使用底层默认设置。
|
||||
|
||||
Python 可使用 `create_app(vlm_config=VlmConfig(...))` 完整覆盖全局配置。配置由服务实例
|
||||
传入其任务,不写回全局设置。此功能不增加 HTTP 请求字段;`/v1/models` 和 `/v1/tiers`
|
||||
继续发布 MinerU 逻辑模型及质量档位,上游 serving 模型别名仅用于推理请求。
|
||||
|
||||
启动完成后,HTTP API 不暴露 backend。`GET /v1/tiers` 也不新增 backend 字段;调用方如需推断实现,只能从 `current_model` 做弱推断。
|
||||
|
||||
@@ -93,13 +142,14 @@ mineru-kit api-server --tier standard --language en --ocr-mode ocr --disable-ima
|
||||
- host / port
|
||||
- tier,单值 `flash` / `basic` / `standard`
|
||||
- no-flash
|
||||
- no-advanced
|
||||
- preload-models
|
||||
- API key
|
||||
- vlm-server-url / vlm-api-key / vlm-model / vlm-http-timeout / vlm-max-concurrency
|
||||
|
||||
### 稳定解析参数
|
||||
|
||||
- language
|
||||
- ocr-mode
|
||||
- disable-image-analysis
|
||||
- concurrency
|
||||
- upload-dir
|
||||
|
||||
@@ -28,7 +28,7 @@ mineru-kit gradio \
|
||||
--api-key "$MINERU_API_KEY"
|
||||
```
|
||||
|
||||
未指定 `--api-url` 时,Gradio 会启动一个 loopback `mineru-kit api-server`。本地 server 默认使用 Standard 能力上限、开启 Flash、关闭 `local` source,文件通过 V1 Uploads API 提交。
|
||||
未指定 `--api-url` 时,Gradio 会启动一个 loopback `mineru-kit api-server`。本地 server 默认使用 Standard 能力上限、开启 Flash 和 Advanced、关闭 `local` source,文件通过 V1 Uploads API 提交。
|
||||
|
||||
未指定 `--server-port` 时,界面由 Gradio 从 `7860` 开始寻找空闲端口;可通过 `GRADIO_SERVER_PORT` 设置起始端口、`GRADIO_NUM_PORTS` 设置尝试数量(默认 `100`)。显式指定 `--server-port 7861` 时只尝试该端口,覆盖环境变量;该端口已被占用则启动失败。界面最终访问地址以启动输出为准,与内部 API server 的端口不同。
|
||||
|
||||
@@ -40,29 +40,58 @@ mineru-kit gradio \
|
||||
| `--api-key` | Bearer API Key;省略时读取 `MINERU_API_KEY`。 |
|
||||
| `--server-name` / `--server-port` | Gradio UI 的监听地址和端口;省略端口时自动寻找空闲端口。 |
|
||||
| `--output-dir` | 本地 UI 产物根目录,默认 `./output`。 |
|
||||
| `--max-pages` | 单次非 Flash PDF 解析的最多页数,必须为正整数;省略则不限制。不限制 Flash 或非 PDF。 |
|
||||
| `--api-server-tier` | 自动启动 server 的能力档位:`flash`、`basic`、`standard`。 |
|
||||
| `--api-server-concurrency` | 自动启动 server 的最大并发任务数。 |
|
||||
| `--api-server-language` | 自动启动 server 的 OCR 语言提示。 |
|
||||
| `--api-server-ocr-mode` | 自动启动 server 的 OCR 模式:`auto`、`txt`、`ocr`。 |
|
||||
| `--api-server-preload-models` | 在自动启动阶段预加载模型。 |
|
||||
| `--api-server-no-flash` | 自动启动 server 时关闭 Flash。 |
|
||||
| `--api-server-disable-image-analysis` | 自动启动 server 时关闭图片分析。 |
|
||||
| `--enable-example` | 显示当前工作目录 `examples/` 中的示例文件。 |
|
||||
| `--enable-api` | 暴露 Gradio 转换事件 API。 |
|
||||
| `--latex-delimiters-type` | Markdown 预览公式分隔符:`a`、`b` 或 `all`。 |
|
||||
|
||||
Gradio 不接受 `--api-server-no-flash`、`--no-flash`、`--api-server-no-advanced`、`--no-advanced`;传入会报错。独立 API 启动命令仍保留 `--no-flash` 和 `--no-advanced`。
|
||||
|
||||
指定外部 `--api-url` 时,Gradio 先校验 `/v1/health` 和 `/v1/tiers`。远程已提供 Flash 时,全部请求使用远程;远程缺少 Flash 时,在运行 Gradio 的机器上自动启动仅提供 Flash 的 loopback V1 API,并完成能力发现后启动界面。Flash 请求只上传至本地服务,其他档位继续使用远程;远程未提供的 Advanced 不会补充。
|
||||
|
||||
补充服务固定使用 `--tier flash`,其余托管配置沿用上述参数;远程 API Key 只用于远程客户端,本地服务和客户端使用空密钥。本地服务启动失败会报告启动错误,Gradio 退出时自动清理托管进程和临时目录。能力判断以启动时的声明为准,远程连接或解析失败仍报告原错误,不触发本地重试。
|
||||
|
||||
## 解析流程
|
||||
|
||||
界面一次提交一个文件,支持 `filetypes.PARSEABLE_EXTENSIONS` 中的 PDF、图片、Office/ODF、RTF、HTML、CSV/TSV、EPUB 和 OFD。
|
||||
|
||||
解析 tier 使用原生离散滑块选择,上方即时显示当前档位。滑块按 `flash → basic → standard → advanced` 排列,仅包含服务实际支持的档位。默认优先选择 `standard`,否则选择最高可用档位;仅有一个档位时禁用滑块。上传新文件或清除结果会保留当前选择。
|
||||
解析 tier 使用原生离散滑块选择,上方即时显示当前档位。滑块按 `flash → basic → standard → advanced` 排列,包含远程档位与补充的本地 Flash;未指定远程时使用托管服务的档位。默认优先选择 `standard`,否则选择最高可用档位;仅有一个档位时禁用滑块。
|
||||
|
||||
启用 Gradio 事件 API 时,转换事件的 `tier_position` 参数为从 `0` 开始的整数位置,替代原来的 tier 字符串。例如四档齐全时 `3` 对应 `advanced`;仅支持 `basic/standard/advanced` 时 `2` 对应 `advanced`。位置始终按照上述顺序对实际可用档位编号,越界位置会报错。V1 API 的 `tier` 参数仍使用字符串,启动参数不变。
|
||||
上传 Office/ODF、RTF、HTML、CSV/TSV、EPUB、OFD 时,tier 自动切到 `flash` 并锁定滑块。界面记住本会话最后一次未锁定的档位,连续更换这类文件不会覆盖记忆;切回 PDF/图片或清除文件时恢复此前选择。PDF/图片之间切换继续保留当前档位。锁定与恢复同时更新档位标签、页码控件和转换按钮,无需向 Python 发送滑块拖动请求。
|
||||
|
||||
页码输入使用 V1 `page_range` 语法,例如 `1-5,8,r1`;留空表示全部页面。只有原始 PDF 支持显式页码范围,其他格式会自动清空并禁用页码控件。
|
||||
远程未提供 Flash 时,上述格式自动使用本地 Flash,可以正常转换。转换事件仍校验 `tier_position` 的合法性,并对这些格式强制使用 `flash`。直接调用 Python 构建界面而未接入补充服务时,仍会拒绝不可用档位。
|
||||
|
||||
上传原始 PDF 时,tier 下方显示“强制 OCR”开关,所有档位(包括 Flash)均可使用。默认关闭,提交 `ocr_mode:"auto"` 自动判断;开启时提交 `ocr_mode:"ocr"`,忽略 PDF 文本层并进行 OCR。更换或清除文件会重置开关,同一文件切换 tier 保留选择。图片与其他格式隐藏开关,提交时固定使用 `auto`。
|
||||
|
||||
Gradio 转换事件新增 `force_ocr` 布尔参数,默认 `false`,位于 `raw_page_range` 之后。服务端处理转换事件时仍会检查原始文件类型,非 PDF 即使传入 `true` 也不会强制 OCR。
|
||||
|
||||
OCR 模式由每次 V1 解析任务决定;API Server 不再提供启动时的 OCR 配置,Gradio 的 `--api-server-ocr-mode` 及其 `--ocr-mode` 别名已移除,传入会报错。连接外部 V1 服务时,需要该服务支持任务级 `ocr_mode` 字段。
|
||||
|
||||
启用 Gradio 事件 API 时,转换事件的 `tier_position` 参数为从 `0` 开始的整数位置,替代原来的 tier 字符串。例如四档齐全时 `3` 对应 `advanced`;远程仅支持 `basic/standard` 时,本地补充 Flash 后 `0` 对应 `flash`,`2` 对应 `standard`。位置始终按照上述顺序对实际可用档位编号,越界位置会报错。V1 API 的 `tier` 参数仍使用字符串。
|
||||
|
||||
仅在已上传原始 PDF 且 tier 不是 `flash` 时显示页码双滑块。轨道范围由 `pypdfium2` 读取的实际页数确定,为 `1~n`,两端对应包含首尾页的连续选区。Flash 和其他文件格式隐藏控件,并始终全部解析。
|
||||
|
||||
默认选择全部页;配置 `mineru-kit gradio --max-pages 20` 后,100 页 PDF 的初始选区为 `[1-20]`,轨道仍为 `1~100`。选区最多 20 页,允许缩小;只有超限时才联动另一端。例如将右端拖到 40 得到 `[21-40]`,再把左端拖到 15 得到 `[15-34]`,随后把左端拖到 20 得到 `[20-34]`。
|
||||
|
||||
两个滑块可以互相越过并实时交换起止角色,拖动过程中始终抓住同一个滑块,不需要松手。显示和提交的范围始终从小到大排列。例如从 `[20-35]` 把原左滑块拖到 40,得到 `[35-40]`;同一滑块继续到 60,按上限联动为 `[41-60]`;不松手退到 30,则得到 `[30-41]`。两端重合表示单页,此时保留原来的角色,直到严格越过才交换;键盘操作遵循相同规则。
|
||||
|
||||
更换或清除文件会重置选区与角色;同一 PDF 切换 tier 保留两个滑块的位置和角色,包括切到 Flash 后再切回。读取页数期间,非 Flash PDF 暂不可提交;文件损坏或需要密码时显示错误,不降级为全部解析。
|
||||
|
||||
Gradio 转换事件保留 `raw_page_range` 字符串接口,仍接受 V1 `page_range` 语法,例如 `1-5,8,r1`。未指定时按当前上限选择前若干页,无上限则全部;显式选区(包括 `all`)超限会返回 `page_range_invalid`,不会截断。提交端按真实页数复核,直接调用事件也不能绕过上限。该限制不改变通用 Python、V1 API 或 Doclib 的选页规则。
|
||||
|
||||
Gradio 首先发现 `/v1/health` 和 `/v1/tiers`,然后通过 `MinerUApiParser` 上传文件、创建 `/v1/parse/jobs`、轮询任务并下载 ZIP 结果。解析结果保存为严格 Middle JSON 2.0。
|
||||
|
||||
左下角使用与 3.4.5 一致的两列步骤卡片,展示准备请求、检查服务、提交任务、排队、解析中、下载结果、整理输出和完成。排队状态来自本地任务槽或 V1 的 `queued` 响应,圆点每秒循环更新;不显示前方任务数。每个界面服务仍一次执行一个转换,等待中的会话也会立即显示排队动画。
|
||||
|
||||
收到 V1 的 `running` 后开始解析计时,每 0.1 秒刷新一次;收到任务终态时冻结,下载和整理输出不计入该耗时。最终完成文案保留观察到的解析耗时;直接完成且没有观察到 `running` 的快速任务不显示耗时。界面动画不增加 API 请求,任务轮询仍为每秒一次。清除或更换文件会停止当前会话的状态流并释放本地等待位置,不向 V1 发送远端取消请求。
|
||||
|
||||
`MinerUApiParser.parse()` 和 `parse_async()` 提供可选的 `status_callback` 参数,接收公开的 `ApiJobStatus`(`queued`、`running`、`completed`、`partial`、`failed`、`canceled`)。回调在任务提交响应及每次轮询响应后同步调用;回调异常只记录日志。这里的 `completed` 表示服务端任务完成,解析方法仍需下载并构造 `ParseResult` 后才返回。
|
||||
|
||||
## 结果与下载
|
||||
|
||||
页面只展示:
|
||||
@@ -73,6 +102,8 @@ Gradio 首先发现 `/v1/health` 和 `/v1/tiers`,然后通过 `MinerUApiParser
|
||||
|
||||
下载图标位于右侧结果栏的标签行最右端,鼠标悬停或键盘聚焦即可展开菜单。菜单包含 ZIP、HTML、DOCX、LaTeX bundle、EPUB 和 PDF。HTML、DOCX、EPUB、PDF 和 LaTeX 不作为 API job 输出请求,而是在用户点击菜单后从本次保存的 Middle JSON 按需渲染。LaTeX 下载包包含 `.tex` 与 `images/`,可交给 XeLaTeX 使用;Gradio 不自动执行 TeX 编译。
|
||||
|
||||
每次选择格式后,按钮会显示“准备中”,文件就绪后自动开始下载,无需再次点击。再次下载同一格式复用已有文件;生成失败时显示错误并允许重试。清除、更换文件或重新解析会使旧下载请求失效。
|
||||
|
||||
每次解析的文件保存在独立的 `output-dir/gradio/<run-id>/` 目录,包含源文件、Middle JSON、基础文本产物以及可选的 `origin.pdf`、`layout.pdf` 和图片资源。路径只向 Gradio 暴露在配置的 output root 内。
|
||||
|
||||
## 预览与兼容边界
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
|
||||
## 2. 与 api-server 的边界
|
||||
|
||||
`mineru-kit vlm-server` 可作为 `mineru-kit api-server` 的后端,尤其服务于:
|
||||
|
||||
- `vlm-http-client`
|
||||
- `hybrid-http-client`
|
||||
`mineru-kit vlm-server` 可作为 `mineru-kit api-server` 的远程 VLM 后端。
|
||||
API 服务通过 `--vlm-server-url` 或全局 `model.vlm.server_url` 连接它,内部使用
|
||||
`http-client` 执行 Standard/Advanced 的 VLM 推理;本地 Hybrid 处理仍在 API 服务进程中执行。
|
||||
连接、鉴权及模型名配置见 [api-server](mineru-kit-api-server.md#使用远程-mineru-vlm-服务)。
|
||||
|
||||
边界:
|
||||
|
||||
|
||||
@@ -24,6 +24,51 @@ MinerU 有两类配置:
|
||||
- 默认 DB、日志和 UDS socket 从 `MINERU_HOME` 派生;默认 `data_dir` 是 `$MINERU_HOME/doclib`,其下包含 `parsed/` 和 `temp/`。
|
||||
- `MINERU_CONFIG` 仍可显式指定配置文件路径;`MINERU_HOME` 只影响默认 home 和默认配置文件位置。
|
||||
|
||||
### 远程 VLM
|
||||
|
||||
`model.vlm` 是所有本地解析入口的 VLM 默认配置,适用于 API server、Python
|
||||
`parse()` / `parse_async()` 和 `mineru-kit parse`。Standard/Advanced 将 VLM 推理交给
|
||||
远程 MinerU OpenAI-compatible 服务,本地继续执行 Hybrid 处理;Flash/Basic 不调用远程 VLM。
|
||||
上游须部署支持 MinerU 文档理解输出的模型,并提供 `/v1/models` 和 `/v1/chat/completions`。
|
||||
|
||||
```yaml
|
||||
model:
|
||||
vlm:
|
||||
server_url: http://127.0.0.1:30000/v1
|
||||
api_key: ${VLM_API_KEY:-}
|
||||
model: ""
|
||||
http_timeout: 600
|
||||
max_concurrency: 100
|
||||
```
|
||||
|
||||
未配置 `server_url` 或显式设为空字符串时使用本地 VLM。地址可使用 HTTP/HTTPS
|
||||
服务根地址或以 `/v1` 结尾的地址;反向代理前缀会保留,例如 `https://host/proxy/v1`
|
||||
对应 `https://host/proxy/v1/chat/completions`。URL 不接受内嵌用户名、密码、query 或 fragment。
|
||||
`api_key` 可为空;`model` 为空时要求上游模型列表恰好包含一个模型,多模型服务须显式指定模型名。
|
||||
显式模型名也会通过模型列表校验。`http_timeout` 单位为秒,`max_concurrency` 是 VLM
|
||||
推理请求并发,两者均为正整数;API 的 `--concurrency` 则控制解析任务并发。
|
||||
|
||||
环境变量包括 `MINERU_MODEL_VLM_SERVER_URL`、`MINERU_MODEL_VLM_API_KEY`、
|
||||
`MINERU_MODEL_VLM_MODEL`、`MINERU_MODEL_VLM_HTTP_TIMEOUT` 和
|
||||
`MINERU_MODEL_VLM_MAX_CONCURRENCY`。API 启动时的优先级为:显式启动参数 > 环境变量 >
|
||||
YAML > 默认值。启动参数及示例见 [api-server](cli/mineru-kit-api-server.md)。
|
||||
|
||||
Python 的 `create_app()`、`MinerUParser`、`parse()` / `parse_async()` 以及底层分析入口
|
||||
可传入 `vlm_config: VlmConfig | None`:`None` 使用全局设置;显式对象完整覆盖全局设置。
|
||||
应用与解析器各自保存配置副本,不修改全局配置或进程环境。
|
||||
|
||||
```python
|
||||
from mineru.config import VlmConfig
|
||||
from mineru.parser import parse
|
||||
|
||||
result = parse("document.pdf", tier="standard") # 使用全局 model.vlm
|
||||
local_result = parse("document.pdf", vlm_config=VlmConfig()) # 本次使用本地 VLM
|
||||
```
|
||||
|
||||
远程模式不会自动回退本地 VLM。旧底层环境变量 `MINERU_VL_API_KEY`、
|
||||
`MINERU_VL_MODEL_NAME` 若与有效的新配置冲突,会明确报错;应清除旧变量并使用上述
|
||||
`MINERU_MODEL_VLM_*` 配置。未配置远程地址时,旧 `MINERU_VL_SERVER` 不会启用远程 VLM。
|
||||
|
||||
Markdown render 同样读取启动前 `config.yaml`,当前支持以下配置:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -62,6 +62,8 @@ Advanced 仍是公开请求 tier,但不是启动 tier;Standard 的依赖和
|
||||
4. 未传 `--tier` 等价于 `--tier standard`
|
||||
5. `--no-flash` 从能力中移除 Flash,并禁止该进程执行 Flash backend
|
||||
6. `--tier flash --no-flash` 启动失败
|
||||
7. `--no-advanced` 从能力中移除 Advanced,并禁止该进程接受 Advanced 请求
|
||||
8. `--no-flash --no-advanced` 可组合使用;Standard server 此时只发布 Basic 与 Standard
|
||||
|
||||
### 4. backend 的公开边界
|
||||
|
||||
@@ -84,12 +86,12 @@ backend 不是普通 API 协议层字段。
|
||||
- `--port`
|
||||
- `--tier`
|
||||
- `--no-flash`
|
||||
- `--no-advanced`
|
||||
- `--api-key`
|
||||
|
||||
#### 稳定解析参数
|
||||
|
||||
- `--language`
|
||||
- `--ocr-mode`
|
||||
- `--disable-image-analysis`
|
||||
- `--concurrency`
|
||||
- `--upload-dir`
|
||||
@@ -97,6 +99,8 @@ backend 不是普通 API 协议层字段。
|
||||
|
||||
### 6. 不进入正式契约的参数
|
||||
|
||||
OCR 模式使用 V1 任务级 `ocr_mode` 参数,省略时默认 `auto`。服务启动参数 `--ocr-mode` 已移除,不保留全局默认值或兼容透传。
|
||||
|
||||
`--reload` 不进入正式命令设计。
|
||||
|
||||
原因:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
状态: Implemented
|
||||
|
||||
CLI 的 `--pages`、Python/Doclib/API 的 `page_range` 和 Gradio 页码输入使用同一套语法。
|
||||
CLI 的 `--pages`、Python/Doclib/API 的 `page_range` 和 Gradio 转换事件的页码参数使用同一套语法;Gradio 界面通过双滑块生成连续选区。
|
||||
页码表示 PDF 文件内的页面顺序,从 1 开始,不使用正文印刷页码或 PDF page label。
|
||||
内部 `page_idx` / `page_indices` 仍从 0 开始,抽页后保留原始页索引。
|
||||
|
||||
@@ -43,10 +43,13 @@ CLI 的 `--pages`、Python/Doclib/API 的 `page_range` 和 Gradio 页码输入
|
||||
| Doclib 内容导出 | 导出已有缓存内容 |
|
||||
| Python `parse` / `parse_async` | 全部 |
|
||||
| V1 Parse Jobs / `MinerUApiParser` | 全部 |
|
||||
| `mineru-kit parse` / Gradio | 全部 |
|
||||
| `mineru-kit parse` | 全部 |
|
||||
| Gradio 非 Flash PDF | 默认全部;配置 `--max-pages M` 后选择前 M 页,不足则全部 |
|
||||
| Gradio Flash | 始终全部,忽略页码参数 |
|
||||
|
||||
显式 `all` 选择全部 PDF 页面;内容读取/导出仍受实际缓存可用性约束。
|
||||
非 PDF 的解析限制不变:不能显式设置页码(包括 `all`);Gradio 对非 PDF 自动清空并禁用此控件。
|
||||
非 PDF 的解析限制不变:不能显式设置页码(包括 `all`);Gradio 对非 PDF 自动清空并隐藏此控件。
|
||||
Gradio 仅对已上传的非 Flash PDF 显示选页控件;配置的页数上限也适用于直接调用 Gradio 转换事件,显式超限选区报 `page_range_invalid`。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class MinerUApiParser(DocumentParser):
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
tier: str | None = None,
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] | None = None,
|
||||
include_images: bool = False,
|
||||
include_model_output: bool = False,
|
||||
) -> None: ...
|
||||
@@ -61,6 +62,7 @@ class MinerUApiParser(DocumentParser):
|
||||
| `api_key` | Bearer token。省略时读取 `MINERU_API_KEY`;Local Parse Server 未启用鉴权时可为空。 |
|
||||
| `tier` | `flash`、`basic`、`standard`、`advanced` 或 `None`。`None` 表示 SDK 在 HTTP 请求中省略 `tier`,让 v1 API 使用默认选择策略;非 PDF/image 文件按 API Server 批量规则归一,见 [ADR-0024](../decisions/0024-file-type-tier-normalization.md)。 |
|
||||
| `include_images` | 是否从 zip 产物读取图片 sidecar,并挂载到 `ParseResult` 图片缓存。 |
|
||||
| `ocr_mode` | 请求级 OCR 模式:`auto` 自动判断、`txt` 使用文本层、`ocr` 强制 OCR。默认 `None`,在 HTTP 请求中省略字段,由服务端按 `auto` 处理;显式设置时同步与异步解析均传入任务顶层。 |
|
||||
| `include_model_output` | 是否请求并保留模型原始输出;开启时通过 zip 产物读取。 |
|
||||
|
||||
## 行为流程
|
||||
|
||||
@@ -22,25 +22,6 @@ from .constants import (
|
||||
)
|
||||
|
||||
|
||||
def _load_vlm_runtime() -> dict[str, Any]:
|
||||
"""按需加载 VLM runtime 组件,确保只有 high/extra_high 路径触发 VLM 依赖。"""
|
||||
from ....model.vlm.runtime import (
|
||||
ModelSingleton,
|
||||
_get_model_async,
|
||||
_maybe_enable_serial_execution,
|
||||
aio_predictor_execution_guard,
|
||||
predictor_execution_guard,
|
||||
)
|
||||
|
||||
return {
|
||||
"ModelSingleton": ModelSingleton,
|
||||
"_get_model_async": _get_model_async,
|
||||
"_maybe_enable_serial_execution": _maybe_enable_serial_execution,
|
||||
"aio_predictor_execution_guard": aio_predictor_execution_guard,
|
||||
"predictor_execution_guard": predictor_execution_guard,
|
||||
}
|
||||
|
||||
|
||||
def _layout_item_to_content_block(layout_item: dict[str, Any], page_size: tuple[int, int]) -> dict | None:
|
||||
"""将本地 layout 小模型检测项转换为 mineru-vl-utils 的 ContentBlock。"""
|
||||
label = layout_item.get("label") or layout_item.get("type")
|
||||
|
||||
@@ -7,12 +7,12 @@ import time
|
||||
from typing import Any, cast
|
||||
|
||||
from ..contracts import AnalysisResult, AnalyzeEffort, ParseMode, ResolvedParseMode
|
||||
from ....config import VlmConfig
|
||||
from ....model.runtime.hybrid import HybridLocalModelContextSingleton
|
||||
from ....model.runtime.memory import clean_memory
|
||||
from ....model.vlm.selector import get_vlm_engine
|
||||
from ....model.vlm.client import get_vlm_predictor
|
||||
from ....model.flash.pdf.document import PDFDocument
|
||||
|
||||
from .layout import _load_vlm_runtime
|
||||
from .normalization import _normalize_pdf_model_list
|
||||
from .window import process_pdf_windows
|
||||
|
||||
@@ -24,6 +24,7 @@ def analyze_pdf(
|
||||
effort: AnalyzeEffort = "high",
|
||||
parse_mode: ParseMode = "auto",
|
||||
image_analysis: bool = True,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> AnalysisResult:
|
||||
"""生产 PDF model-list,并返回最终路由元数据和精确推理耗时。"""
|
||||
# 只在真实 PDF 分析开始时配置 MPS 回退,避免 import backend 修改进程环境。
|
||||
@@ -49,14 +50,7 @@ def analyze_pdf(
|
||||
hybrid_model = hybrid_model_singleton.get_model()
|
||||
|
||||
if effort in ["high", "xhigh"]:
|
||||
vlm_runtime = _load_vlm_runtime()
|
||||
vlm_backend = get_vlm_engine(inference_engine="auto", is_async=False)
|
||||
vlm_predictor = vlm_runtime["ModelSingleton"]().get_model(
|
||||
backend=vlm_backend,
|
||||
model_path=None,
|
||||
server_url=None,
|
||||
)
|
||||
vlm_predictor = vlm_runtime["_maybe_enable_serial_execution"](vlm_predictor, vlm_backend)
|
||||
vlm_predictor, _vlm_backend = get_vlm_predictor(vlm_config)
|
||||
else:
|
||||
vlm_predictor = None
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import cast
|
||||
from loguru import logger
|
||||
|
||||
from .analysis.contracts import AnalyzeEffort, OfficeSuffix, ParseMode
|
||||
from ..config import config
|
||||
from ..config import VlmConfig, config
|
||||
from ..model.flash.html import HtmlSourceContext
|
||||
from ..types import FILE_SUFFIXES, FileSuffix, MiddleJson, ModelJson
|
||||
from ..version import __version__ as mineru_version
|
||||
@@ -34,6 +34,7 @@ def doc_analyze(
|
||||
page_index_map: list[int] | None = None,
|
||||
file_suffix: FileSuffix = "pdf",
|
||||
source_context: HtmlSourceContext | None = None,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> tuple[MiddleJson, ModelJson]:
|
||||
"""生产严格 ModelJson,并在统一边界构造严格 MiddleJson。"""
|
||||
if file_suffix not in FILE_SUFFIXES:
|
||||
@@ -51,6 +52,7 @@ def doc_analyze(
|
||||
effort=effort,
|
||||
parse_mode=parse_mode,
|
||||
image_analysis=image_analysis,
|
||||
vlm_config=vlm_config,
|
||||
)
|
||||
elif file_suffix == "csv":
|
||||
from .analysis.csv import analyze_csv
|
||||
@@ -99,6 +101,7 @@ async def aio_doc_analyze(
|
||||
page_index_map: list[int] | None = None,
|
||||
file_suffix: FileSuffix = "pdf",
|
||||
source_context: HtmlSourceContext | None = None,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> tuple[MiddleJson, ModelJson]:
|
||||
"""在线程中执行统一文档分析,避免阻塞调用方事件循环。"""
|
||||
return await asyncio.to_thread(
|
||||
@@ -110,4 +113,5 @@ async def aio_doc_analyze(
|
||||
page_index_map=page_index_map,
|
||||
file_suffix=file_suffix,
|
||||
source_context=source_context,
|
||||
vlm_config=vlm_config,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import tempfile
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import yaml
|
||||
from filelock import FileLock
|
||||
@@ -422,10 +423,80 @@ class LLMAidedConfig(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class VlmConfig(BaseModel):
|
||||
"""所有本地解析入口共享的远程 VLM 连接配置;空地址表示使用本地引擎。"""
|
||||
|
||||
model_config = {"hide_input_in_errors": True}
|
||||
|
||||
server_url: str = ""
|
||||
api_key: str = Field(default="", repr=False)
|
||||
model: str = ""
|
||||
http_timeout: int = Field(default=600, ge=1)
|
||||
max_concurrency: int = Field(default=100, ge=1)
|
||||
|
||||
@field_validator("api_key", "model")
|
||||
@classmethod
|
||||
def _strip_connection_value(cls, value: str) -> str:
|
||||
"""清理连接字段首尾空白,保持显式空值可覆盖全局配置。"""
|
||||
return value.strip()
|
||||
|
||||
@field_validator("server_url")
|
||||
@classmethod
|
||||
def _normalize_server_url(cls, value: str) -> str:
|
||||
"""保留代理前缀并移除末尾 v1,以尾斜线阻止底层客户端丢弃路径。"""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
valid = (
|
||||
parts.scheme in {"http", "https"}
|
||||
and bool(parts.hostname)
|
||||
and parts.username is None
|
||||
and parts.password is None
|
||||
and not parts.query
|
||||
and not parts.fragment
|
||||
and not any(char.isspace() for char in value)
|
||||
)
|
||||
_ = parts.port
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError("model.vlm.server_url must be an HTTP(S) service URL without credentials, query or fragment")
|
||||
path = parts.path.rstrip("/")
|
||||
if path.endswith("/v1"):
|
||||
path = path[:-3]
|
||||
return urlunsplit((parts.scheme, parts.netloc, path + "/", "", ""))
|
||||
|
||||
@field_validator("http_timeout", "max_concurrency", mode="before")
|
||||
@classmethod
|
||||
def _reject_boolean_limit(cls, value: Any) -> Any:
|
||||
"""拒绝布尔数值,同时允许配置环境变量提供整数文本。"""
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("VLM timeout and concurrency must be positive integers")
|
||||
return value
|
||||
|
||||
def validate_environment(self) -> None:
|
||||
"""远程推理前拒绝会隐式覆盖显式配置的旧环境变量,错误不包含凭据。"""
|
||||
if not self.server_url:
|
||||
return
|
||||
for env_name, expected, field in (
|
||||
("MINERU_VL_API_KEY", self.api_key, "api_key"),
|
||||
("MINERU_VL_MODEL_NAME", self.model, "model"),
|
||||
):
|
||||
# 底层只清理 API Key;模型名按原始环境变量值读取。
|
||||
actual = os.getenv(env_name, "")
|
||||
if field == "api_key":
|
||||
actual = actual.strip()
|
||||
if actual and actual != expected:
|
||||
raise ValueError(f"{env_name} conflicts with model.vlm.{field}; unset the legacy environment variable")
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
base_dir: str = _default_path("models")
|
||||
source: str = "auto"
|
||||
stack: str = "auto" # "auto" | "light" | "full"
|
||||
vlm: VlmConfig = Field(default_factory=VlmConfig)
|
||||
|
||||
|
||||
class DoclibConfig(BaseModel):
|
||||
@@ -493,6 +564,7 @@ __all__ = [
|
||||
"ConfigSource",
|
||||
"LoadedConfig",
|
||||
"ModelConfig",
|
||||
"VlmConfig",
|
||||
"ModelSource",
|
||||
"config",
|
||||
"Config",
|
||||
|
||||
@@ -37,6 +37,7 @@ def api_server_cmd(
|
||||
help="Server capability tier: flash, basic, or standard",
|
||||
),
|
||||
no_flash: bool = typer.Option(False, "--no-flash", help="Disable Flash tier advertisement and execution"),
|
||||
no_advanced: bool = typer.Option(False, "--no-advanced", help="Disable Advanced tier advertisement and execution"),
|
||||
concurrency: int = typer.Option(1, "--concurrency", help="Maximum concurrent parse jobs"),
|
||||
url_timeout: int = typer.Option(60, "--url-timeout", help="Timeout for URL source downloads"),
|
||||
allow_local_source: bool = typer.Option(False, "--allow-local-source", help="Allow local source paths"),
|
||||
@@ -47,18 +48,38 @@ def api_server_cmd(
|
||||
"--language",
|
||||
help="Hybrid medium OCR language hint; accepted by other efforts for compatibility",
|
||||
),
|
||||
ocr_mode: str = typer.Option("auto", "--ocr-mode", help="OCR mode: auto, txt, ocr"),
|
||||
disable_image_analysis: bool = typer.Option(False, "--disable-image-analysis", help="Disable image analysis"),
|
||||
preload_models: bool = typer.Option(False, "--preload-models", help="Load local models during server startup"),
|
||||
preload_models: bool = typer.Option(
|
||||
False, "--preload-models", help="Initialize VLM client and local Hybrid models at startup"
|
||||
),
|
||||
api_key: str | None = typer.Option(None, "--api-key", help="Optional fixed API key"),
|
||||
vlm_server_url: str | None = typer.Option(None, "--vlm-server-url", help="Remote VLM URL; empty value selects local VLM"),
|
||||
vlm_api_key: str | None = typer.Option(None, "--vlm-api-key", help="Bearer key for the remote VLM server"),
|
||||
vlm_model: str | None = typer.Option(None, "--vlm-model", help="Remote VLM model name; empty value enables discovery"),
|
||||
vlm_http_timeout: int | None = typer.Option(
|
||||
None, "--vlm-http-timeout", min=1, help="VLM HTTP timeout in seconds (default: 600)"
|
||||
),
|
||||
vlm_max_concurrency: int | None = typer.Option(
|
||||
None, "--vlm-max-concurrency", min=1, help="VLM inference concurrency (default: 100)"
|
||||
),
|
||||
) -> None:
|
||||
"""Start the self-hosted MinerU parse API server."""
|
||||
"""转发显式启动参数,启动 self-hosted MinerU 解析 API 服务。"""
|
||||
try:
|
||||
normalized_language = validate_public_ocr_lang(language)
|
||||
except ValueError as exc:
|
||||
exit_with_message("invalid_request", str(exc), "language")
|
||||
tier_value = _normalize_tier_option(tier)
|
||||
tier_args = ["--tier", tier_value] if tier_value is not None else []
|
||||
vlm_args: list[str] = []
|
||||
for option, value in (
|
||||
("--vlm-server-url", vlm_server_url),
|
||||
("--vlm-api-key", vlm_api_key),
|
||||
("--vlm-model", vlm_model),
|
||||
("--vlm-http-timeout", vlm_http_timeout),
|
||||
("--vlm-max-concurrency", vlm_max_concurrency),
|
||||
):
|
||||
if value is not None:
|
||||
vlm_args.extend([option, str(value)])
|
||||
try:
|
||||
parser_api_server.main.main(
|
||||
args=[
|
||||
@@ -74,10 +95,10 @@ def api_server_cmd(
|
||||
str(max_inline_bytes),
|
||||
"--language",
|
||||
normalized_language,
|
||||
"--ocr-mode",
|
||||
ocr_mode,
|
||||
*tier_args,
|
||||
*vlm_args,
|
||||
*(["--no-flash"] if no_flash else []),
|
||||
*(["--no-advanced"] if no_advanced else []),
|
||||
*(["--allow-local-source"] if allow_local_source else []),
|
||||
*(["--allow-http-source"] if allow_http_source else []),
|
||||
*(["--upload-dir", upload_dir] if upload_dir else []),
|
||||
|
||||
@@ -57,22 +57,19 @@ def gradio_cmd(
|
||||
None, "--server-port", help="Gradio bind port; omitted: auto-select from 7860 or GRADIO_SERVER_PORT"
|
||||
),
|
||||
output_dir: str = typer.Option("./output", "--output-dir", help="Directory for Gradio artifacts"),
|
||||
max_pages: int | None = typer.Option(
|
||||
None, "--max-pages", help="Maximum pages per non-Flash PDF conversion; omitted: unlimited"
|
||||
),
|
||||
enable_example: bool = typer.Option(True, "--enable-example/--no-enable-example", help="Show local examples"),
|
||||
enable_api: bool = typer.Option(True, "--enable-api/--no-enable-api", help="Expose the Gradio event API"),
|
||||
latex_delimiters_type: Literal["a", "b", "all"] = typer.Option(
|
||||
"all", "--latex-delimiters-type", help="LaTeX delimiters used by the Markdown preview"
|
||||
),
|
||||
api_server_tier: str = typer.Option("standard", "--api-server-tier", "--tier", help="Managed API server capability tier"),
|
||||
api_server_no_flash: bool = typer.Option(
|
||||
False, "--api-server-no-flash/--no-flash", help="Disable Flash on the managed server"
|
||||
),
|
||||
api_server_concurrency: int = typer.Option(
|
||||
1, "--api-server-concurrency", "--concurrency", help="Managed server job concurrency"
|
||||
),
|
||||
api_server_language: str = typer.Option("ch", "--api-server-language", "--language", help="Managed server OCR language"),
|
||||
api_server_ocr_mode: Literal["auto", "txt", "ocr"] = typer.Option(
|
||||
"auto", "--api-server-ocr-mode", "--ocr-mode", help="Managed server OCR mode"
|
||||
),
|
||||
api_server_disable_image_analysis: bool = typer.Option(
|
||||
False,
|
||||
"--api-server-disable-image-analysis/--disable-image-analysis",
|
||||
@@ -86,6 +83,8 @@ def gradio_cmd(
|
||||
_require_gradio_dependencies()
|
||||
if server_port is not None:
|
||||
_validate_server_port(server_port)
|
||||
if max_pages is not None:
|
||||
_validate_positive_option(max_pages, name="max_pages")
|
||||
_validate_positive_option(api_server_concurrency, name="api_server_concurrency")
|
||||
normalized_tier = _validate_server_tier(api_server_tier)
|
||||
try:
|
||||
@@ -103,14 +102,13 @@ def gradio_cmd(
|
||||
server_name=server_name,
|
||||
server_port=server_port,
|
||||
output_dir=output_dir,
|
||||
max_pages=max_pages,
|
||||
enable_example=enable_example,
|
||||
enable_api=enable_api,
|
||||
latex_delimiters_type=latex_delimiters_type,
|
||||
api_server_tier=normalized_tier,
|
||||
api_server_no_flash=api_server_no_flash,
|
||||
api_server_concurrency=api_server_concurrency,
|
||||
api_server_language=normalized_language,
|
||||
api_server_ocr_mode=api_server_ocr_mode,
|
||||
api_server_disable_image_analysis=api_server_disable_image_analysis,
|
||||
api_server_preload_models=api_server_preload_models,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""MinerU Kit Gradio 实现。"""
|
||||
|
||||
from .app import build_gradio_app, launch_gradio
|
||||
from .client import ManagedLocalApiServer, V1ArtifactClient, V1ArtifactError, V1ServerCapabilities
|
||||
from .client import GradioArtifactClient, ManagedLocalApiServer, V1ArtifactClient, V1ArtifactError, V1ServerCapabilities
|
||||
|
||||
__all__ = [
|
||||
"GradioArtifactClient",
|
||||
"ManagedLocalApiServer",
|
||||
"V1ArtifactClient",
|
||||
"V1ArtifactError",
|
||||
|
||||
+384
-199
@@ -6,24 +6,35 @@ import asyncio
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextlib import aclosing
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from ...errors import MineruError
|
||||
from ...filetypes import IMAGE_EXTENSIONS, OFFICE_EXTENSIONS, PARSEABLE_EXTENSIONS, PDF_EXTENSIONS
|
||||
from ...parser.page_range import normalize_page_range_input
|
||||
from ...filetypes import FLASH_ONLY_PARSE_EXTENSIONS, IMAGE_EXTENSIONS, OFFICE_EXTENSIONS, PARSEABLE_EXTENSIONS, PDF_EXTENSIONS
|
||||
from ...types import TIERS, Tier
|
||||
from ...utils.stdio import configure_standard_streams
|
||||
from .artifacts import RunArtifacts, markdown_for_gradio, persist_parse_result, render_download
|
||||
from .client import (
|
||||
GradioArtifactClient,
|
||||
ManagedLocalApiServer,
|
||||
V1ArtifactClient,
|
||||
V1ServerCapabilities,
|
||||
)
|
||||
from .page_range import effective_page_range as _effective_page_range
|
||||
from .page_range import pdf_page_metadata, validate_max_pages
|
||||
from .status import (
|
||||
DEFAULT_STATUS as _DEFAULT_STATUS,
|
||||
STATUS_COMPLETED,
|
||||
STATUS_PREPARING_REQUEST,
|
||||
STATUS_PROCESSING_OUTPUT,
|
||||
V1ArtifactClient,
|
||||
V1ServerCapabilities,
|
||||
STATUS_QUEUED_LOCALLY,
|
||||
StatusPanelState,
|
||||
status_html as _status_html,
|
||||
stream_status_updates,
|
||||
)
|
||||
|
||||
_DOWNLOAD_FORMATS: tuple[tuple[str, str], ...] = (
|
||||
@@ -35,16 +46,6 @@ _DOWNLOAD_FORMATS: tuple[tuple[str, str], ...] = (
|
||||
("pdf", "PDF"),
|
||||
)
|
||||
_DEFAULT_TIER = "standard"
|
||||
_DEFAULT_STATUS = "Upload a file and start conversion."
|
||||
_STATUS_STEPS = (
|
||||
("prepare", "准备"),
|
||||
("check", "检查服务"),
|
||||
("submit", "提交任务"),
|
||||
("process", "服务端解析"),
|
||||
("download", "下载结果"),
|
||||
("output", "整理输出"),
|
||||
("done", "完成"),
|
||||
)
|
||||
_LATEX_DELIMITERS_A = [
|
||||
{"left": "$$", "right": "$$", "display": True},
|
||||
{"left": "$", "right": "$", "display": False},
|
||||
@@ -109,17 +110,9 @@ _KIT_MENU_CSS = """
|
||||
border: 0; border-radius: 6px; background: transparent; box-shadow: none; text-align: left;
|
||||
}
|
||||
.mineru-kit-download-options :is(button, a):hover { background: var(--background-fill-secondary, #f3f4f6); }
|
||||
.mineru-kit-status { min-height: 84px; }
|
||||
.mineru-kit-status-steps { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||||
.mineru-kit-status-steps .status-step {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
color: var(--body-text-color-subdued, #6b7280); font-size: 12px;
|
||||
}
|
||||
.mineru-kit-status-steps .status-step i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .35; }
|
||||
.mineru-kit-status-steps .status-step.is-active { color: var(--mineru-accent, #f97316); font-weight: 600; }
|
||||
.mineru-kit-status-steps .status-step.is-active i, .mineru-kit-status-steps .status-step.is-done i { opacity: 1; }
|
||||
.mineru-kit-status-steps .status-step.is-error { color: #dc2626; font-weight: 600; }
|
||||
.mineru-kit-empty-preview { min-height: 160px; display: grid; place-items: center; opacity: .65; }
|
||||
/* gradio-pdf 0.0.22 的内部容器不识别 hidden,显式隐藏空组件,同时保持挂载。 */
|
||||
.mineru-kit-pdf-preview.mineru-kit-pdf-empty { display: none !important; }
|
||||
.mineru-kit-image-preview img { max-height: var(--mineru-pdf-page-height, 720px); object-fit: contain; }
|
||||
@media (max-width: 900px) {
|
||||
.mineru-kit-workspace { flex-direction: column !important; }
|
||||
@@ -182,64 +175,13 @@ def _tier_for_position(position: int | float, tier_choices: list[Tier]) -> Tier:
|
||||
return tier_choices[int(position)]
|
||||
|
||||
|
||||
def _status_html(message: str) -> str:
|
||||
"""生成不解释协议细节、只显示当前阶段的安全状态片段。"""
|
||||
raw_message = str(message or _DEFAULT_STATUS)
|
||||
safe_message = html.escape(raw_message)
|
||||
lowered = raw_message.lower()
|
||||
if raw_message.startswith("Failed:"):
|
||||
current_index = len(_STATUS_STEPS) - 1
|
||||
state_for_index = "error"
|
||||
elif "downloading" in lowered or "downloaded" in lowered:
|
||||
current_index, state_for_index = 4, "active"
|
||||
elif "completed" in lowered:
|
||||
current_index = len(_STATUS_STEPS) - 1
|
||||
state_for_index = "done"
|
||||
elif "preparing outputs" in lowered:
|
||||
current_index, state_for_index = 5, "active"
|
||||
elif "preparing" in lowered:
|
||||
current_index, state_for_index = 0, "active"
|
||||
elif "checking" in lowered:
|
||||
current_index, state_for_index = 1, "active"
|
||||
elif "submitting" in lowered:
|
||||
current_index, state_for_index = 2, "active"
|
||||
elif "processing" in lowered:
|
||||
current_index, state_for_index = 3, "active"
|
||||
else:
|
||||
current_index, state_for_index = -1, "pending"
|
||||
items: list[str] = []
|
||||
for index, (_key, label) in enumerate(_STATUS_STEPS):
|
||||
if state_for_index == "error" and index == current_index:
|
||||
state = "is-error"
|
||||
elif state_for_index == "done" or index < current_index:
|
||||
state = "is-done"
|
||||
elif index == current_index:
|
||||
state = "is-active"
|
||||
else:
|
||||
state = "is-pending"
|
||||
items.append(f'<span class="status-step {state}"><i></i>{label}</span>')
|
||||
return (
|
||||
'<div class="mineru-kit-status">'
|
||||
f'<div class="mineru-kit-status-steps">{"".join(items)}</div>'
|
||||
f"<strong>{safe_message}</strong>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _file_suffix(path_value: str | Path | None) -> str:
|
||||
"""读取上传文件的小写后缀,供 UI 预览和页码控件判断。"""
|
||||
"""读取上传文件的小写后缀,供预览、选页与 OCR 控件判断。"""
|
||||
if not path_value:
|
||||
return ""
|
||||
return Path(path_value).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _effective_page_range(path_value: str | Path | None, raw_page_range: str | None) -> str:
|
||||
"""仅为 PDF 保留用户输入的 V1 page_range,其它格式统一返回空字符串。"""
|
||||
if _file_suffix(path_value) not in PDF_EXTENSIONS:
|
||||
return ""
|
||||
return normalize_page_range_input(raw_page_range)
|
||||
|
||||
|
||||
def _is_pdf_or_image(path_value: str | Path | None) -> bool:
|
||||
"""判断上传文件是否可以在 PDF 预览组件中展示。"""
|
||||
suffix = _file_suffix(path_value)
|
||||
@@ -256,9 +198,17 @@ def _preview_update(gr: Any, value: object, *, visible: bool) -> Any:
|
||||
return gr.update(value=value, visible=visible)
|
||||
|
||||
|
||||
def _download_updates(gr: Any, *, interactive: bool) -> tuple[Any, ...]:
|
||||
"""清空全部下载按钮的旧文件,并统一切换交互状态。"""
|
||||
return tuple(gr.update(value=None, interactive=interactive) for _format_name, _label in _DOWNLOAD_FORMATS)
|
||||
def _pdf_preview_update(gr: Any, value: str | None) -> Any:
|
||||
"""让空 PDF 保持挂载,并兼容旧版自定义组件不识别 hidden 的视觉隐藏行为。"""
|
||||
classes = ["mineru-kit-pdf-preview"]
|
||||
if not value:
|
||||
classes.append("mineru-kit-pdf-empty")
|
||||
return gr.update(value=value, visible=True if value else "hidden", elem_classes=classes)
|
||||
|
||||
|
||||
def _download_updates(gr: Any, *, interactive: bool, run_id: str = "") -> tuple[Any, ...]:
|
||||
"""同步当前结果标识,并恢复全部下载按钮的标签与交互状态。"""
|
||||
return (run_id, *(gr.update(value=label, interactive=interactive) for _format_name, label in _DOWNLOAD_FORMATS))
|
||||
|
||||
|
||||
def _latex_delimiters(delimiters_type: Literal["a", "b", "all"]) -> list[dict[str, Any]]:
|
||||
@@ -271,26 +221,35 @@ def _latex_delimiters(delimiters_type: Literal["a", "b", "all"]) -> list[dict[st
|
||||
|
||||
|
||||
def build_gradio_app(
|
||||
client: V1ArtifactClient,
|
||||
client: V1ArtifactClient | GradioArtifactClient,
|
||||
capabilities: V1ServerCapabilities,
|
||||
*,
|
||||
output_root: Path,
|
||||
enable_example: bool = True,
|
||||
enable_api: bool = True,
|
||||
latex_delimiters_type: Literal["a", "b", "all"] = "all",
|
||||
max_pages: int | None = None,
|
||||
) -> Any:
|
||||
"""构建不启动监听端口的 Gradio Blocks 应用,便于单元测试和外部托管。"""
|
||||
import gradio as gr
|
||||
from gradio_pdf import PDF
|
||||
|
||||
validate_max_pages(max_pages)
|
||||
tier_choices = [tier for tier in TIERS if tier in capabilities.tiers]
|
||||
if not tier_choices:
|
||||
raise ValueError("V1 API server did not advertise any parsing tier")
|
||||
preferred_tier = _default_tier(capabilities)
|
||||
file_types = _supported_file_types()
|
||||
markdown_copy_kwargs = {"buttons": ["copy"]} if _gradio_major_version(gr) >= 6 else {"show_copy_button": True}
|
||||
app_css = _resource_text("gradio_app.css") + _KIT_MENU_CSS
|
||||
app_js = _resource_text("gradio_app.js")
|
||||
# Gradio 5 在 Blocks 构造时接收静态资源,6 则在 launch 时接收。
|
||||
blocks_kwargs = {"css": app_css, "js": app_js} if _gradio_major_version(gr) < 6 else {}
|
||||
# 等待限制放在生成器内部,使其他会话也能立即显示本地排队状态。
|
||||
conversion_slot = asyncio.Semaphore(1)
|
||||
session_tasks: dict[str, set[asyncio.Task[tuple[Any, ...]]]] = {}
|
||||
|
||||
with gr.Blocks() as demo:
|
||||
with gr.Blocks(**blocks_kwargs) as demo:
|
||||
gr.HTML(
|
||||
_render_header(gradio_major_version=_gradio_major_version(gr)),
|
||||
elem_classes=["mineru-header-html"],
|
||||
@@ -322,25 +281,64 @@ def build_gradio_app(
|
||||
show_label=False,
|
||||
elem_classes=["mineru-tier-slider"],
|
||||
)
|
||||
# 独立保存会话的未锁定档位,不随文件和页码选区一起重置。
|
||||
tier_selection = gr.Textbox(value=json.dumps({"tier": preferred_tier, "locked": False}), visible=False)
|
||||
force_ocr = gr.Checkbox(
|
||||
value=False,
|
||||
label="强制 OCR",
|
||||
info="忽略 PDF 文本层并进行 OCR;关闭时自动判断。",
|
||||
visible=False,
|
||||
interactive=True,
|
||||
elem_classes=["mineru-force-ocr"],
|
||||
)
|
||||
page_range = gr.Textbox(
|
||||
value="",
|
||||
label="页码范围",
|
||||
placeholder="例如 1-5,8,r1 或 all;留空表示全部",
|
||||
interactive=False,
|
||||
elem_classes=["mineru-kit-page-range"],
|
||||
visible=False,
|
||||
)
|
||||
# 用 JSON 文本承载内部状态,避开 Gradio 5/6 JSON 组件的序列化差异。
|
||||
page_metadata = gr.Textbox(value="{}", visible=False)
|
||||
page_selection = gr.Textbox(value="{}", visible=False)
|
||||
# 布局容器在两个主版本的纯 JS 属性更新行为不同;显隐由子 HTML 的明确状态控制。
|
||||
with gr.Column(min_width=0, elem_classes=["mineru-kit-page-range"]):
|
||||
page_summary = gr.HTML(value="", elem_classes=["mineru-page-summary"])
|
||||
with gr.Row(elem_classes=["mineru-page-sliders"]):
|
||||
page_handle_a = gr.Slider(
|
||||
minimum=1,
|
||||
maximum=1,
|
||||
value=1,
|
||||
step=1,
|
||||
precision=0,
|
||||
label="起始页",
|
||||
interactive=False,
|
||||
container=False,
|
||||
elem_classes=["mineru-page-handle-a"],
|
||||
)
|
||||
page_handle_b = gr.Slider(
|
||||
minimum=1,
|
||||
maximum=1,
|
||||
value=1,
|
||||
step=1,
|
||||
precision=0,
|
||||
label="结束页",
|
||||
interactive=False,
|
||||
container=False,
|
||||
elem_classes=["mineru-page-handle-b"],
|
||||
)
|
||||
page_notice = gr.HTML(value="", visible=False, elem_classes=["mineru-page-notice"])
|
||||
with gr.Row(elem_classes=["mineru-actions"]):
|
||||
convert_button = gr.Button("转换", variant="primary", scale=1, min_width=0)
|
||||
convert_button = gr.Button("转换", variant="primary", scale=1, min_width=0, interactive=False)
|
||||
clear_button = gr.ClearButton(value="清除", scale=1, min_width=1)
|
||||
status_panel = gr.HTML(_status_html(_DEFAULT_STATUS), elem_classes=["mineru-kit-status"])
|
||||
status_panel = gr.HTML(_status_html(), elem_classes=["mineru-status-panel"])
|
||||
|
||||
with gr.Column(scale=4, min_width=340, elem_classes=["mineru-kit-preview", "mineru-preview-pane"]):
|
||||
pdf_preview = PDF(
|
||||
label="文档预览",
|
||||
interactive=False,
|
||||
visible=False,
|
||||
# 预先挂载空组件,避免 gradio-pdf 首次带值挂载时漏掉文件加载。
|
||||
visible="hidden",
|
||||
height=720,
|
||||
elem_classes=["mineru-kit-pdf-preview"],
|
||||
elem_classes=["mineru-kit-pdf-preview", "mineru-kit-pdf-empty"],
|
||||
)
|
||||
image_preview = gr.Image(
|
||||
label="文档预览",
|
||||
@@ -398,13 +396,14 @@ def build_gradio_app(
|
||||
):
|
||||
download_buttons: dict[str, Any] = {}
|
||||
for format_name, label in _DOWNLOAD_FORMATS:
|
||||
download_buttons[format_name] = gr.DownloadButton(
|
||||
download_buttons[format_name] = gr.Button(
|
||||
label,
|
||||
visible=True,
|
||||
interactive=False,
|
||||
size="sm",
|
||||
elem_classes=[f"mineru-kit-download-{format_name}"],
|
||||
)
|
||||
download_notice = gr.HTML(value="", elem_classes=["mineru-kit-download-notice"])
|
||||
|
||||
if enable_example:
|
||||
examples = _example_files(file_types)
|
||||
@@ -412,10 +411,15 @@ def build_gradio_app(
|
||||
gr.Examples(examples=examples, inputs=input_file, label="示例", elem_id="mineru-kit-examples")
|
||||
|
||||
artifact_state = gr.State(value=None)
|
||||
active_run_id = gr.Textbox(value="", visible=False)
|
||||
download_files = {name: gr.File(visible=False, interactive=False) for name, _label in _DOWNLOAD_FORMATS}
|
||||
download_requests = {name: gr.Textbox(value="", visible=False) for name, _label in _DOWNLOAD_FORMATS}
|
||||
download_receipts = {name: gr.Textbox(value="", visible=False) for name, _label in _DOWNLOAD_FORMATS}
|
||||
clear_button.add(
|
||||
[
|
||||
input_file,
|
||||
page_range,
|
||||
force_ocr,
|
||||
markdown_output,
|
||||
markdown_source,
|
||||
structured_source,
|
||||
@@ -432,19 +436,16 @@ def build_gradio_app(
|
||||
reset_result = (_status_html(_DEFAULT_STATUS), "", "", "", None, *_download_updates(gr, interactive=False))
|
||||
if not file_path:
|
||||
return (
|
||||
gr.update(value="", interactive=False),
|
||||
_preview_update(gr, None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
_preview_update(gr, None, visible=False),
|
||||
_preview_update(gr, "", visible=False),
|
||||
_preview_update(gr, '<div class="mineru-kit-empty-preview">暂无源文档预览</div>', visible=True),
|
||||
*reset_result,
|
||||
)
|
||||
suffix = _file_suffix(file_path)
|
||||
page_update = gr.update(interactive=suffix in PDF_EXTENSIONS, value="")
|
||||
if suffix in PDF_EXTENSIONS:
|
||||
return (
|
||||
page_update,
|
||||
_preview_update(gr, file_path, visible=True),
|
||||
_pdf_preview_update(gr, file_path),
|
||||
_preview_update(gr, None, visible=False),
|
||||
_preview_update(gr, "", visible=False),
|
||||
_preview_update(gr, '<div class="mineru-kit-empty-preview">源文档预览</div>', visible=False),
|
||||
@@ -452,8 +453,7 @@ def build_gradio_app(
|
||||
)
|
||||
if suffix in IMAGE_EXTENSIONS:
|
||||
return (
|
||||
page_update,
|
||||
_preview_update(gr, None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
_preview_update(gr, file_path, visible=True),
|
||||
_preview_update(gr, "", visible=False),
|
||||
_preview_update(gr, '<div class="mineru-kit-empty-preview">源文档预览</div>', visible=False),
|
||||
@@ -461,8 +461,7 @@ def build_gradio_app(
|
||||
)
|
||||
if _is_office(file_path):
|
||||
return (
|
||||
page_update,
|
||||
_preview_update(gr, None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
_preview_update(gr, None, visible=False),
|
||||
_preview_update(
|
||||
gr,
|
||||
@@ -473,8 +472,7 @@ def build_gradio_app(
|
||||
*reset_result,
|
||||
)
|
||||
return (
|
||||
page_update,
|
||||
_preview_update(gr, None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
_preview_update(gr, None, visible=False),
|
||||
_preview_update(gr, "", visible=False),
|
||||
_preview_update(gr, '<div class="mineru-kit-empty-preview">该格式暂无源文档预览</div>', visible=True),
|
||||
@@ -482,34 +480,116 @@ def build_gradio_app(
|
||||
)
|
||||
|
||||
private_event_kwargs = _private_event_kwargs(gr)
|
||||
tier.input(
|
||||
|
||||
download_script = _resource_text("gradio_download.js")
|
||||
|
||||
def download_js(action: str, format_name: str = "", label: str = "") -> str:
|
||||
"""为下载事件绑定明确的动作与格式,复用同一份前端状态处理脚本。"""
|
||||
arguments = ", ".join(json.dumps(value) for value in (action, _DOWNLOAD_FORMATS, format_name, label))
|
||||
return f"(...args) => ({download_script})({arguments}, ...args)"
|
||||
|
||||
def reset_download_ui() -> tuple[Any, ...]:
|
||||
"""返回下载组件的初始状态,为转换提供可可靠串联的完成事件。"""
|
||||
count = len(_DOWNLOAD_FORMATS)
|
||||
return ("", *((None,) * count), *(("",) * count * 2), *_download_updates(gr, interactive=False)[1:], "")
|
||||
|
||||
download_reset_outputs = [
|
||||
active_run_id,
|
||||
*download_files.values(),
|
||||
*download_requests.values(),
|
||||
*download_receipts.values(),
|
||||
*download_buttons.values(),
|
||||
download_notice,
|
||||
]
|
||||
|
||||
# 先在前端失效旧请求,再等待上传/清除/转换的 Python 回调,防止迟到的下载被触发。
|
||||
gr.on(
|
||||
triggers=[input_file.change, clear_button.click],
|
||||
fn=None,
|
||||
inputs=tier,
|
||||
outputs=tier_label,
|
||||
js="""(position) => {
|
||||
// 在浏览器端即时显示当前档位;实际解析独立读取滑块位置。
|
||||
const tiers = %s;
|
||||
return ["解析 tier:" + tiers[position]];
|
||||
}"""
|
||||
% json.dumps(tier_choices),
|
||||
inputs=[],
|
||||
outputs=download_reset_outputs,
|
||||
js=download_js("reset"),
|
||||
**private_event_kwargs,
|
||||
)
|
||||
active_run_id.change(fn=None, inputs=active_run_id, outputs=[], js=download_js("activate"), **private_event_kwargs)
|
||||
|
||||
def update_ocr_control(file_path: str | None) -> Any:
|
||||
"""仅为原始 PDF 显示开关,并在更换或清除文件时重置为自动判断。"""
|
||||
return gr.update(value=False, visible=_file_suffix(file_path) in PDF_EXTENSIONS)
|
||||
|
||||
input_file.change(
|
||||
fn=update_file_preview,
|
||||
fn=update_ocr_control,
|
||||
inputs=input_file,
|
||||
outputs=[
|
||||
page_range,
|
||||
pdf_preview,
|
||||
image_preview,
|
||||
office_preview,
|
||||
generic_preview,
|
||||
status_panel,
|
||||
markdown_output,
|
||||
markdown_source,
|
||||
structured_source,
|
||||
artifact_state,
|
||||
*download_buttons.values(),
|
||||
],
|
||||
outputs=force_ocr,
|
||||
trigger_mode="always_last",
|
||||
**private_event_kwargs,
|
||||
)
|
||||
|
||||
async def cancel_session_conversion(request: object | None = None) -> None:
|
||||
"""主动回收当前会话的任务,不依赖 Gradio 5/6 对异步生成器的关闭实现。"""
|
||||
session_hash = getattr(request, "session_hash", None)
|
||||
tasks = session_tasks.pop(session_hash, set())
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
cancel_session_conversion.__annotations__["request"] = gr.Request
|
||||
# 显式注册加载事件,确保两个主版本都会执行前端初始化函数而不只加载其文本。
|
||||
demo.load(fn=None, js=app_js, **private_event_kwargs)
|
||||
preview_outputs = [
|
||||
pdf_preview,
|
||||
image_preview,
|
||||
office_preview,
|
||||
generic_preview,
|
||||
status_panel,
|
||||
markdown_output,
|
||||
markdown_source,
|
||||
structured_source,
|
||||
artifact_state,
|
||||
active_run_id,
|
||||
*download_buttons.values(),
|
||||
]
|
||||
|
||||
# 文件页数只在上传后读取;前端缓存元数据,tier 切换和拖动不发起 Python 请求。
|
||||
range_inputs = [input_file, tier, page_metadata, page_selection, page_handle_a, page_handle_b, tier_selection]
|
||||
range_outputs = [
|
||||
page_handle_a,
|
||||
page_handle_b,
|
||||
page_summary,
|
||||
page_range,
|
||||
page_selection,
|
||||
convert_button,
|
||||
page_notice,
|
||||
tier,
|
||||
tier_label,
|
||||
tier_selection,
|
||||
]
|
||||
range_script = _resource_text("gradio_page_range.js")
|
||||
# 共用一个 always_last 事件流,避免文件、元数据、清除与拖动的并行回调互相覆盖。
|
||||
gr.on(
|
||||
triggers=[input_file.change, tier.input, page_metadata.change, page_handle_a.input, page_handle_b.input],
|
||||
fn=None,
|
||||
inputs=range_inputs,
|
||||
outputs=range_outputs,
|
||||
js=(
|
||||
f"(...args) => ({range_script})({json.dumps(tier_choices)}, "
|
||||
f"{json.dumps(sorted(FLASH_ONLY_PARSE_EXTENSIONS))}, {json.dumps(max_pages)}, ...args)"
|
||||
),
|
||||
trigger_mode="always_last",
|
||||
**private_event_kwargs,
|
||||
)
|
||||
|
||||
def read_page_metadata(file_path: str | None) -> str:
|
||||
"""把页数元数据编码为稳定的 JSON 文本,供两个 Gradio 主版本共用。"""
|
||||
return json.dumps(pdf_page_metadata(file_path), ensure_ascii=False)
|
||||
|
||||
input_file.change(
|
||||
fn=read_page_metadata,
|
||||
inputs=input_file,
|
||||
outputs=page_metadata,
|
||||
trigger_mode="always_last",
|
||||
show_progress="hidden",
|
||||
**private_event_kwargs,
|
||||
)
|
||||
|
||||
@@ -517,6 +597,7 @@ def build_gradio_app(
|
||||
file_path: str | None,
|
||||
tier_position: int | float,
|
||||
raw_page_range: str,
|
||||
force_ocr: bool = False,
|
||||
request: object | None = None,
|
||||
) -> Any:
|
||||
"""执行单文件 V1 解析并流式更新状态与三个结果标签。"""
|
||||
@@ -525,7 +606,7 @@ def build_gradio_app(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
gr.update(value=None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
gr.update(value=None, visible=False),
|
||||
gr.update(value="", visible=False),
|
||||
gr.update(value='<div class="mineru-kit-empty-preview">暂无源文档预览</div>', visible=True),
|
||||
@@ -548,69 +629,104 @@ def build_gradio_app(
|
||||
except ValueError as exc:
|
||||
yield (_status_html(f"Failed: {exc}"), "", "", "", *empty_result[4:])
|
||||
return
|
||||
if suffix in FLASH_ONLY_PARSE_EXTENSIONS:
|
||||
# 提交端独立约束有效档位,避免事件 API 或前端残留值绕过 Flash 锁定。
|
||||
if "flash" not in tier_choices:
|
||||
message = "Failed: tier_unavailable: 该格式仅支持 Flash,当前服务不可用"
|
||||
yield (_status_html(message), "", "", "", *empty_result[4:])
|
||||
return
|
||||
selected_tier = "flash"
|
||||
try:
|
||||
page_text = _effective_page_range(source_path, raw_page_range)
|
||||
page_text = await asyncio.to_thread(
|
||||
_effective_page_range, source_path, raw_page_range, tier=selected_tier, max_pages=max_pages
|
||||
)
|
||||
except MineruError as exc:
|
||||
yield (_status_html(f"Failed: {exc.code}: {exc}"), "", "", "", *empty_result[4:])
|
||||
return
|
||||
yield (_status_html(STATUS_PREPARING_REQUEST), "", "", "", *empty_result[4:])
|
||||
status_queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
state = StatusPanelState()
|
||||
state.append(STATUS_PREPARING_REQUEST)
|
||||
yield (state.render(), *empty_result[1:])
|
||||
status_queue: asyncio.Queue[tuple[str, float]] = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def emit(message: str) -> None:
|
||||
"""把同步 client 状态安全投递到当前 Gradio 事件循环。"""
|
||||
loop.call_soon_threadsafe(status_queue.put_nowait, message)
|
||||
"""记录通知时刻,避免队列消费延迟被误算为服务端解析耗时。"""
|
||||
loop.call_soon_threadsafe(status_queue.put_nowait, (message, time.monotonic()))
|
||||
|
||||
task = asyncio.create_task(
|
||||
client.parse_file(
|
||||
source_path,
|
||||
tier=selected_tier,
|
||||
page_range=page_text,
|
||||
status_callback=emit,
|
||||
)
|
||||
)
|
||||
async def run_conversion() -> tuple[Any, ...]:
|
||||
"""在单任务槽内解析和整理结果;取消或失败均自动释放等待位置。"""
|
||||
if conversion_slot.locked():
|
||||
emit(STATUS_QUEUED_LOCALLY)
|
||||
async with conversion_slot:
|
||||
result = await client.parse_file(
|
||||
source_path,
|
||||
tier=selected_tier,
|
||||
page_range=page_text,
|
||||
# 再次检查源文件类型,避免事件 API 或隐藏控件残留值强制处理非 PDF。
|
||||
ocr_mode="ocr" if suffix in PDF_EXTENSIONS and force_ocr else "auto",
|
||||
status_callback=emit,
|
||||
)
|
||||
emit(STATUS_PROCESSING_OUTPUT)
|
||||
artifacts = await asyncio.to_thread(
|
||||
persist_parse_result,
|
||||
result,
|
||||
source_path,
|
||||
output_root=output_root,
|
||||
page_range=page_text,
|
||||
)
|
||||
markdown_text = artifacts.markdown_path.read_text(encoding="utf-8")
|
||||
structured_text = artifacts.structured_content_path.read_text(encoding="utf-8")
|
||||
preview_path = artifacts.layout_pdf_path or artifacts.origin_pdf_path
|
||||
office_html = _build_office_result_html(artifacts, request) if _is_office(source_path) else ""
|
||||
generic_html = (
|
||||
"" if preview_path or office_html else '<div class="mineru-kit-empty-preview">结果已生成</div>'
|
||||
)
|
||||
show_image_preview = suffix in IMAGE_EXTENSIONS and preview_path is None
|
||||
return (
|
||||
markdown_for_gradio(markdown_text, artifacts),
|
||||
markdown_text,
|
||||
structured_text,
|
||||
_pdf_preview_update(gr, str(preview_path) if preview_path else None),
|
||||
gr.update(value=str(source_path) if show_image_preview else None, visible=show_image_preview),
|
||||
gr.update(value=office_html, visible=bool(office_html)),
|
||||
gr.update(value=generic_html, visible=bool(generic_html)),
|
||||
artifacts.as_state(),
|
||||
*_download_updates(gr, interactive=True, run_id=artifacts.root.name),
|
||||
)
|
||||
|
||||
task = asyncio.create_task(run_conversion())
|
||||
session_hash = getattr(request, "session_hash", None)
|
||||
if session_hash:
|
||||
session_tasks.setdefault(session_hash, set()).add(task)
|
||||
|
||||
def forget_task(done_task: asyncio.Task[tuple[Any, ...]]) -> None:
|
||||
"""任务结束即释放会话索引,即使前端已丢弃生成器也不保留任务引用。"""
|
||||
tasks = session_tasks.get(session_hash)
|
||||
if tasks is not None:
|
||||
tasks.discard(done_task)
|
||||
if not tasks:
|
||||
session_tasks.pop(session_hash, None)
|
||||
|
||||
task.add_done_callback(forget_task)
|
||||
try:
|
||||
while not task.done():
|
||||
try:
|
||||
message = await asyncio.wait_for(status_queue.get(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
yield (_status_html(message), "", "", "", *empty_result[4:])
|
||||
while not status_queue.empty():
|
||||
yield (_status_html(status_queue.get_nowait()), "", "", "", *empty_result[4:])
|
||||
result = await task
|
||||
emit(STATUS_PROCESSING_OUTPUT)
|
||||
yield (_status_html(STATUS_PROCESSING_OUTPUT), "", "", "", *empty_result[4:])
|
||||
artifacts = await asyncio.to_thread(
|
||||
persist_parse_result,
|
||||
result,
|
||||
source_path,
|
||||
output_root=output_root,
|
||||
page_range=page_text,
|
||||
)
|
||||
markdown_text = artifacts.markdown_path.read_text(encoding="utf-8")
|
||||
structured_text = artifacts.structured_content_path.read_text(encoding="utf-8")
|
||||
preview_path = artifacts.layout_pdf_path or artifacts.origin_pdf_path
|
||||
office_html = _build_office_result_html(artifacts, request) if _is_office(source_path) else ""
|
||||
generic_html = "" if preview_path or office_html else '<div class="mineru-kit-empty-preview">结果已生成</div>'
|
||||
show_image_preview = suffix in IMAGE_EXTENSIONS and preview_path is None
|
||||
yield (
|
||||
_status_html(STATUS_COMPLETED),
|
||||
markdown_for_gradio(markdown_text, artifacts),
|
||||
markdown_text,
|
||||
structured_text,
|
||||
gr.update(value=str(preview_path) if preview_path else None, visible=preview_path is not None),
|
||||
gr.update(value=str(source_path) if show_image_preview else None, visible=show_image_preview),
|
||||
gr.update(value=office_html, visible=bool(office_html)),
|
||||
gr.update(value=generic_html, visible=bool(generic_html)),
|
||||
artifacts.as_state(),
|
||||
*_download_updates(gr, interactive=True),
|
||||
)
|
||||
async with aclosing(stream_status_updates(task, status_queue, state)) as updates:
|
||||
async for status in updates:
|
||||
# 动画只更新状态卡片,避免反复重建预览和清空结果组件。
|
||||
yield (status, *(gr.skip() for _ in empty_result[1:]))
|
||||
result_outputs = await task
|
||||
state.append(STATUS_COMPLETED)
|
||||
yield (state.render(), *result_outputs)
|
||||
except asyncio.CancelledError:
|
||||
# 会话重置后静默结束旧流,避免把取消异常或旧状态写回新界面。
|
||||
return
|
||||
except Exception as exc:
|
||||
state.append(f"Failed: {exc}")
|
||||
yield (state.render(), *empty_result[1:])
|
||||
finally:
|
||||
# 清除、换文件或断开流时仅取消本地等待,不发送远端取消请求。
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
yield (_status_html(f"Failed: {exc}"), "", "", "", *empty_result[4:])
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
convert_outputs = [
|
||||
status_panel,
|
||||
@@ -622,21 +738,38 @@ def build_gradio_app(
|
||||
office_preview,
|
||||
generic_preview,
|
||||
artifact_state,
|
||||
active_run_id,
|
||||
*download_buttons.values(),
|
||||
]
|
||||
# Gradio 在读取函数签名时需要真实的 Request 类型对象;注解在运行时补回以保持延迟导入。
|
||||
convert_handler.__annotations__["request"] = gr.Request
|
||||
event_kwargs: dict[str, Any] = {"queue": True, "show_progress": "hidden"}
|
||||
event_kwargs: dict[str, Any] = {"queue": True, "show_progress": "hidden", "concurrency_limit": None}
|
||||
if _gradio_major_version(gr) >= 6:
|
||||
event_kwargs["api_visibility"] = "public" if enable_api else "private"
|
||||
else:
|
||||
event_kwargs["api_name"] = "to_markdown" if enable_api else False
|
||||
convert_button.click(
|
||||
# 重置与转换必须显式串联;独立 click 监听可能在转换完成后才执行重置。
|
||||
begin_conversion = convert_button.click(
|
||||
fn=reset_download_ui,
|
||||
inputs=[],
|
||||
outputs=download_reset_outputs,
|
||||
js=f"() => {{ ({download_js('reset')})(); return []; }}",
|
||||
**private_event_kwargs,
|
||||
)
|
||||
convert_event = begin_conversion.then(
|
||||
fn=convert_handler,
|
||||
inputs=[input_file, tier, page_range],
|
||||
inputs=[input_file, tier, page_range, force_ocr],
|
||||
outputs=convert_outputs,
|
||||
**event_kwargs,
|
||||
)
|
||||
input_file.change(
|
||||
fn=update_file_preview,
|
||||
inputs=input_file,
|
||||
outputs=preview_outputs,
|
||||
cancels=[convert_event],
|
||||
**private_event_kwargs,
|
||||
)
|
||||
input_file.change(fn=cancel_session_conversion, inputs=[], outputs=[], **private_event_kwargs)
|
||||
|
||||
reset_outputs = [
|
||||
status_panel,
|
||||
@@ -648,6 +781,7 @@ def build_gradio_app(
|
||||
office_preview,
|
||||
generic_preview,
|
||||
artifact_state,
|
||||
active_run_id,
|
||||
*download_buttons.values(),
|
||||
]
|
||||
|
||||
@@ -658,7 +792,7 @@ def build_gradio_app(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
gr.update(value=None, visible=False),
|
||||
_pdf_preview_update(gr, None),
|
||||
gr.update(value=None, visible=False),
|
||||
gr.update(value="", visible=False),
|
||||
gr.update(value='<div class="mineru-kit-empty-preview">暂无源文档预览</div>', visible=True),
|
||||
@@ -666,19 +800,44 @@ def build_gradio_app(
|
||||
*_download_updates(gr, interactive=False),
|
||||
)
|
||||
|
||||
clear_button.click(fn=reset_ui, inputs=[], outputs=reset_outputs, queue=False)
|
||||
clear_button.click(fn=reset_ui, inputs=[], outputs=reset_outputs, cancels=[convert_event], queue=False)
|
||||
clear_button.click(fn=cancel_session_conversion, inputs=[], outputs=[], **private_event_kwargs)
|
||||
|
||||
for format_name, _label in _DOWNLOAD_FORMATS:
|
||||
download_buttons[format_name].click(
|
||||
for format_name, label in _DOWNLOAD_FORMATS:
|
||||
begin_download = download_buttons[format_name].click(
|
||||
fn=_download_request_handler,
|
||||
inputs=active_run_id,
|
||||
outputs=download_requests[format_name],
|
||||
js=f"(...args) => [({download_js('begin', format_name, label)})(...args)[0]]",
|
||||
**private_event_kwargs,
|
||||
)
|
||||
# 开始回执也必须经过前端令牌校验,避免慢请求在清除后重新显示旧的“准备中”。
|
||||
begin_download.success(
|
||||
fn=None,
|
||||
inputs=[download_requests[format_name], active_run_id],
|
||||
outputs=[download_buttons[format_name], download_notice],
|
||||
js=download_js("busy", format_name, label),
|
||||
**private_event_kwargs,
|
||||
)
|
||||
prepare_download = begin_download.then(
|
||||
fn=_download_handler(format_name, output_root),
|
||||
inputs=artifact_state,
|
||||
outputs=download_buttons[format_name],
|
||||
inputs=[artifact_state, download_requests[format_name]],
|
||||
outputs=[download_files[format_name], download_receipts[format_name]],
|
||||
queue=True,
|
||||
show_progress="hidden",
|
||||
api_name=False,
|
||||
)
|
||||
prepare_download.success(
|
||||
fn=None,
|
||||
inputs=[download_files[format_name], download_receipts[format_name], active_run_id],
|
||||
outputs=[download_buttons[format_name], download_notice],
|
||||
js=download_js("complete", format_name, label),
|
||||
**private_event_kwargs,
|
||||
)
|
||||
|
||||
demo._mineru_kit_css = _resource_text("gradio_app.css") + _KIT_MENU_CSS
|
||||
demo._mineru_kit_js = _resource_text("gradio_app.js")
|
||||
demo._mineru_kit_css = app_css
|
||||
demo._mineru_kit_js = app_js
|
||||
demo._mineru_kit_launch_kwargs = {"css": app_css, "js": app_js} if _gradio_major_version(gr) >= 6 else {}
|
||||
demo.queue(default_concurrency_limit=1)
|
||||
return demo
|
||||
|
||||
@@ -694,15 +853,15 @@ def launch_gradio(
|
||||
enable_api: bool,
|
||||
latex_delimiters_type: Literal["a", "b", "all"],
|
||||
api_server_tier: str,
|
||||
api_server_no_flash: bool,
|
||||
api_server_concurrency: int,
|
||||
api_server_language: str,
|
||||
api_server_ocr_mode: Literal["auto", "txt", "ocr"],
|
||||
api_server_disable_image_analysis: bool,
|
||||
api_server_preload_models: bool,
|
||||
max_pages: int | None = None,
|
||||
) -> None:
|
||||
"""启动 Gradio;未指定端口时自动选择,未指定外部 URL 时托管本地 V1 API server。"""
|
||||
"""启动 Gradio,并在外部服务缺少 Flash 时托管本地 Flash V1 服务。"""
|
||||
configure_standard_streams()
|
||||
validate_max_pages(max_pages)
|
||||
resolved_api_key = api_key if api_key is not None else os.environ.get("MINERU_API_KEY")
|
||||
output_root = Path(output_dir).expanduser().resolve()
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -712,10 +871,8 @@ def launch_gradio(
|
||||
if resolved_api_url is None:
|
||||
managed_server = ManagedLocalApiServer(
|
||||
tier=api_server_tier, # type: ignore[arg-type]
|
||||
no_flash=api_server_no_flash,
|
||||
concurrency=api_server_concurrency,
|
||||
language=api_server_language,
|
||||
ocr_mode=api_server_ocr_mode,
|
||||
disable_image_analysis=api_server_disable_image_analysis,
|
||||
preload_models=api_server_preload_models,
|
||||
api_key=resolved_api_key,
|
||||
@@ -723,20 +880,33 @@ def launch_gradio(
|
||||
resolved_api_url = managed_server.start()
|
||||
client = V1ArtifactClient(api_url=resolved_api_url, api_key=resolved_api_key)
|
||||
capabilities = asyncio.run(client.discover())
|
||||
local_flash: V1ArtifactClient | None = None
|
||||
if api_url is not None and "flash" not in capabilities.tiers:
|
||||
managed_server = ManagedLocalApiServer(
|
||||
tier="flash",
|
||||
concurrency=api_server_concurrency,
|
||||
language=api_server_language,
|
||||
disable_image_analysis=api_server_disable_image_analysis,
|
||||
preload_models=api_server_preload_models,
|
||||
api_key="",
|
||||
)
|
||||
local_flash = V1ArtifactClient(api_url=managed_server.start(), api_key="")
|
||||
asyncio.run(local_flash.discover())
|
||||
routed_client = GradioArtifactClient(client, local_flash=local_flash)
|
||||
demo = build_gradio_app(
|
||||
client,
|
||||
capabilities,
|
||||
routed_client,
|
||||
routed_client.capabilities,
|
||||
output_root=output_root,
|
||||
enable_example=enable_example,
|
||||
enable_api=enable_api,
|
||||
latex_delimiters_type=latex_delimiters_type,
|
||||
max_pages=max_pages,
|
||||
)
|
||||
demo.launch(
|
||||
server_name=server_name,
|
||||
server_port=server_port,
|
||||
allowed_paths=[str(output_root)],
|
||||
css=getattr(demo, "_mineru_kit_css", None),
|
||||
js=getattr(demo, "_mineru_kit_js", None),
|
||||
**demo._mineru_kit_launch_kwargs,
|
||||
)
|
||||
finally:
|
||||
if managed_server is not None:
|
||||
@@ -794,12 +964,27 @@ def _private_event_kwargs(gr: Any) -> dict[str, Any]:
|
||||
return {"queue": False, "api_name": False}
|
||||
|
||||
|
||||
def _download_handler(format_name: str, output_root: Path) -> Callable[[object], str]:
|
||||
def _download_request_handler(request_token: str) -> str:
|
||||
"""传回前端下载令牌以可靠触发后续事件,不直接修改可能已属于新文档的按钮。"""
|
||||
return request_token if isinstance(request_token, str) else ""
|
||||
|
||||
|
||||
def _download_handler(format_name: str, output_root: Path) -> Callable[[object, str], tuple[str | None, str]]:
|
||||
"""创建一个绑定格式和 output root 的 Gradio 下载回调。"""
|
||||
|
||||
def handler(state: object) -> str:
|
||||
"""从当前任务 State 生成或读取指定格式的下载文件。"""
|
||||
return render_download(state, format_name, allowed_root=output_root)
|
||||
def handler(state: object, request_token: str) -> tuple[str | None, str]:
|
||||
"""校验请求所属结果,返回文件与回执;生成失败也交给前端按请求标识恢复按钮。"""
|
||||
receipt = {"request": request_token, "error": ""}
|
||||
try:
|
||||
request = json.loads(request_token)
|
||||
artifacts = RunArtifacts.from_state(state)
|
||||
if request["run_id"] != artifacts.root.name:
|
||||
raise ValueError("解析结果已变更,请重新下载。")
|
||||
path = render_download(state, format_name, allowed_root=output_root)
|
||||
return path, json.dumps(receipt, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
receipt["error"] = str(exc)
|
||||
return None, json.dumps(receipt, ensure_ascii=False)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
+70
-21
@@ -10,26 +10,28 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from ...parser.api_client import MinerUApiParser, should_trust_env_for_url
|
||||
from ...parser.api_client import ApiJobStatus, MinerUApiParser, should_trust_env_for_url
|
||||
from ...parser.base import ParseResult
|
||||
from ...parser.page_range import normalize_page_range_input
|
||||
from ...parser.process_control import ManagedProcessControl
|
||||
from ...types import SERVER_TIERS, TIERS, ServerTier, Tier
|
||||
from ...utils.stdio import utf8_subprocess_env
|
||||
|
||||
STATUS_PREPARING_REQUEST = "Preparing request..."
|
||||
STATUS_CHECKING_SERVER = "Checking server status..."
|
||||
STATUS_SUBMITTING_TASK = "Submitting task..."
|
||||
STATUS_PROCESSING_ON_SERVER = "Processing on server..."
|
||||
STATUS_DOWNLOADING_RESULT = "Result archive downloaded."
|
||||
STATUS_PROCESSING_OUTPUT = "Preparing outputs..."
|
||||
STATUS_COMPLETED = "Completed"
|
||||
from .status import (
|
||||
STATUS_CHECKING_SERVER,
|
||||
STATUS_COMPLETED,
|
||||
STATUS_DOWNLOADING_RESULT,
|
||||
STATUS_PREPARING_REQUEST,
|
||||
STATUS_PROCESSING_ON_SERVER,
|
||||
STATUS_PROCESSING_OUTPUT,
|
||||
STATUS_QUEUED_ON_SERVER,
|
||||
STATUS_SUBMITTING_TASK,
|
||||
)
|
||||
|
||||
|
||||
class V1ArtifactError(RuntimeError):
|
||||
@@ -181,6 +183,7 @@ class V1ArtifactClient:
|
||||
*,
|
||||
tier: str,
|
||||
page_range: str,
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] = "auto",
|
||||
status_callback: Callable[[str], None] | None = None,
|
||||
) -> ParseResult:
|
||||
"""通过 V1 API 解析单个文件,并返回带图片/模型输出的 `ParseResult`。"""
|
||||
@@ -203,18 +206,30 @@ class V1ArtifactClient:
|
||||
api_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
tier=cast(Tier, tier),
|
||||
ocr_mode=ocr_mode,
|
||||
include_images=True,
|
||||
include_model_output=True,
|
||||
)
|
||||
emit(STATUS_PROCESSING_ON_SERVER)
|
||||
|
||||
def on_job_status(status: ApiJobStatus) -> None:
|
||||
"""将真实 V1 状态映射到卡片阶段,服务端完成仅代表开始下载。"""
|
||||
messages: dict[ApiJobStatus, str] = {
|
||||
"queued": STATUS_QUEUED_ON_SERVER,
|
||||
"running": STATUS_PROCESSING_ON_SERVER,
|
||||
"completed": STATUS_DOWNLOADING_RESULT,
|
||||
"partial": STATUS_DOWNLOADING_RESULT,
|
||||
"failed": "Failed: server task failed",
|
||||
"canceled": "Failed: server task canceled",
|
||||
}
|
||||
emit(messages[status])
|
||||
|
||||
try:
|
||||
result = await parser.parse_async(path, page_range=page_range)
|
||||
result = await parser.parse_async(path, page_range=page_range, status_callback=on_job_status)
|
||||
except Exception as exc:
|
||||
emit(f"Failed: {exc}")
|
||||
if isinstance(exc, (FileNotFoundError, V1ArtifactError)):
|
||||
raise
|
||||
raise V1ArtifactError(str(exc), code=getattr(exc, "code", "parse_failed")) from exc
|
||||
emit(STATUS_DOWNLOADING_RESULT)
|
||||
return result
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
@@ -224,6 +239,46 @@ class V1ArtifactClient:
|
||||
return {"Authorization": f"Bearer {self.api_key}"}
|
||||
|
||||
|
||||
class GradioArtifactClient:
|
||||
"""合并已发现的服务能力,并将缺失的 Flash 请求路由到本地 V1 服务。"""
|
||||
|
||||
def __init__(self, primary: V1ArtifactClient, *, local_flash: V1ArtifactClient | None = None) -> None:
|
||||
"""根据独立能力快照建立明确的档位映射,不修改任何服务声明。"""
|
||||
capabilities = primary.capabilities
|
||||
if capabilities is None:
|
||||
raise ValueError("Discover the primary V1 API capabilities before constructing the Gradio client")
|
||||
self._clients_by_tier = dict.fromkeys(capabilities.tiers, primary)
|
||||
if local_flash is not None and "flash" not in self._clients_by_tier:
|
||||
if local_flash.capabilities is None or "flash" not in local_flash.capabilities.tiers:
|
||||
raise V1ArtifactError("The local V1 API server did not advertise Flash", code="tier_unavailable")
|
||||
self._clients_by_tier["flash"] = local_flash
|
||||
self._capabilities = replace(capabilities, tiers=tuple(tier for tier in TIERS if tier in self._clients_by_tier))
|
||||
|
||||
@property
|
||||
def capabilities(self) -> V1ServerCapabilities:
|
||||
"""返回供界面使用的有效档位集合,主服务地址和协议能力保持原值。"""
|
||||
return self._capabilities
|
||||
|
||||
async def parse_file(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
tier: str,
|
||||
page_range: str,
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] = "auto",
|
||||
status_callback: Callable[[str], None] | None = None,
|
||||
) -> ParseResult:
|
||||
"""仅向该档位对应的服务提交文件,复用原有状态、取消和产物流程。"""
|
||||
if tier not in TIERS:
|
||||
raise V1ArtifactError(f"Unsupported parse tier: {tier}", code="invalid_request")
|
||||
client = self._clients_by_tier.get(tier)
|
||||
if client is None:
|
||||
raise V1ArtifactError(f"Tier '{tier}' is not available in Gradio", code="tier_unavailable")
|
||||
return await client.parse_file(
|
||||
path, tier=tier, page_range=page_range, ocr_mode=ocr_mode, status_callback=status_callback
|
||||
)
|
||||
|
||||
|
||||
def _string_tuple(value: object, field_name: str) -> tuple[str, ...]:
|
||||
"""把能力字段严格收敛为字符串元组。"""
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
||||
@@ -245,10 +300,8 @@ class ManagedLocalApiServer:
|
||||
self,
|
||||
*,
|
||||
tier: ServerTier = "standard",
|
||||
no_flash: bool = False,
|
||||
concurrency: int = 1,
|
||||
language: str = "ch",
|
||||
ocr_mode: str = "auto",
|
||||
disable_image_analysis: bool = False,
|
||||
preload_models: bool = False,
|
||||
api_key: str | None = None,
|
||||
@@ -259,10 +312,8 @@ class ManagedLocalApiServer:
|
||||
if concurrency <= 0:
|
||||
raise ValueError("API server concurrency must be positive")
|
||||
self.tier = tier
|
||||
self.no_flash = no_flash
|
||||
self.concurrency = concurrency
|
||||
self.language = language
|
||||
self.ocr_mode = ocr_mode
|
||||
self.disable_image_analysis = disable_image_analysis
|
||||
self.preload_models = preload_models
|
||||
self.api_key = api_key
|
||||
@@ -373,9 +424,6 @@ class ManagedLocalApiServer:
|
||||
str(self.concurrency),
|
||||
"--language",
|
||||
self.language,
|
||||
"--ocr-mode",
|
||||
self.ocr_mode,
|
||||
*(["--no-flash"] if self.no_flash else []),
|
||||
*(["--disable-image-analysis"] if self.disable_image_analysis else []),
|
||||
*(["--preload-models"] if self.preload_models else []),
|
||||
*(["--api-key", self.api_key] if self.api_key else []),
|
||||
@@ -432,6 +480,7 @@ def _new_session_kwargs() -> dict[str, Any]:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GradioArtifactClient",
|
||||
"ManagedLocalApiServer",
|
||||
"STATUS_CHECKING_SERVER",
|
||||
"STATUS_COMPLETED",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Gradio 专用的 PDF 页数预检与单次选页限制,不改变通用解析接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from ...errors import InvalidRequestError
|
||||
from ...parser.page_range import count_pages_in_range, expand_page_range, normalize_page_range_input
|
||||
from ...types import Tier
|
||||
|
||||
|
||||
class PdfPageMetadata(TypedDict):
|
||||
"""只传递可序列化的文件标识、页数和错误,不保留 PDFium 对象。"""
|
||||
|
||||
path: str
|
||||
page_count: int
|
||||
error: str
|
||||
|
||||
|
||||
def validate_max_pages(max_pages: int | None) -> None:
|
||||
"""校验启动配置,None 表示不限制,显式值只接受正整数。"""
|
||||
if max_pages is not None and (type(max_pages) is not int or max_pages <= 0):
|
||||
raise ValueError("max_pages must be a positive integer or None")
|
||||
|
||||
|
||||
def read_pdf_page_count(path: str | Path) -> int:
|
||||
"""在共享 PDFium 锁内读取页数,并在成功或异常时及时释放文档。"""
|
||||
import pypdfium2 as pdfium
|
||||
|
||||
from ...model.flash.pdf.pdfium import pdfium_guard
|
||||
|
||||
try:
|
||||
with pdfium_guard():
|
||||
document = pdfium.PdfDocument(str(path))
|
||||
try:
|
||||
page_count = len(document)
|
||||
finally:
|
||||
document.close()
|
||||
if page_count <= 0:
|
||||
raise ValueError("document has no available pages")
|
||||
except Exception as exc:
|
||||
raise InvalidRequestError(
|
||||
"page_range_invalid",
|
||||
"无法读取 PDF 页数,请检查文件是否损坏或需要密码。",
|
||||
"page_range",
|
||||
) from exc
|
||||
return page_count
|
||||
|
||||
|
||||
def pdf_page_metadata(file_path: str | None) -> PdfPageMetadata:
|
||||
"""为上传事件读取 PDF 页数;保留文件标识,供前端丢弃过期响应。"""
|
||||
metadata: PdfPageMetadata = {"path": str(file_path or ""), "page_count": 0, "error": ""}
|
||||
if file_path and Path(file_path).suffix.lower() == ".pdf":
|
||||
try:
|
||||
metadata["page_count"] = read_pdf_page_count(file_path)
|
||||
except InvalidRequestError as exc:
|
||||
metadata["error"] = str(exc)
|
||||
return metadata
|
||||
|
||||
|
||||
def effective_page_range(
|
||||
path: str | Path | None,
|
||||
raw_page_range: str | None,
|
||||
*,
|
||||
tier: Tier,
|
||||
max_pages: int | None = None,
|
||||
) -> str:
|
||||
"""按真实 PDF 页数校验 Gradio 请求,防止绕过前端提交超限范围。"""
|
||||
validate_max_pages(max_pages)
|
||||
if tier == "flash" or not path or Path(path).suffix.lower() != ".pdf":
|
||||
return ""
|
||||
normalized = normalize_page_range_input(raw_page_range)
|
||||
page_count = read_pdf_page_count(path)
|
||||
if not normalized and max_pages is not None:
|
||||
normalized = normalize_page_range_input(f"1-{min(page_count, max_pages)}")
|
||||
resolved = expand_page_range(normalized, page_count)
|
||||
selected_count = count_pages_in_range(resolved)
|
||||
if max_pages is not None and selected_count > max_pages:
|
||||
raise InvalidRequestError(
|
||||
"page_range_invalid",
|
||||
f"单次最多解析 {max_pages} 页,当前选择了 {selected_count} 页。",
|
||||
"page_range",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = ["PdfPageMetadata", "effective_page_range", "pdf_page_metadata", "read_pdf_page_count", "validate_max_pages"]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Gradio 步骤卡片、单次任务计时与流式状态等待。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_STATUS = "Upload a file and start conversion."
|
||||
STATUS_PREPARING_REQUEST = "Preparing request..."
|
||||
STATUS_CHECKING_SERVER = "Checking server status..."
|
||||
STATUS_SUBMITTING_TASK = "Submitting task..."
|
||||
STATUS_QUEUED_LOCALLY = "Queued locally"
|
||||
STATUS_QUEUED_ON_SERVER = "Queued on server"
|
||||
STATUS_PROCESSING_ON_SERVER = "Processing on server..."
|
||||
STATUS_DOWNLOADING_RESULT = "Task completed, downloading result..."
|
||||
STATUS_PROCESSING_OUTPUT = "Preparing outputs..."
|
||||
STATUS_COMPLETED = "Completed"
|
||||
|
||||
_STEPS = (
|
||||
("prepare", "Prepare", "准备请求"),
|
||||
("check", "Check service", "检查服务"),
|
||||
("submit", "Submit", "提交任务"),
|
||||
("queue", "Queue", "排队"),
|
||||
("process", "Parse", "解析中"),
|
||||
("download", "Download", "下载结果"),
|
||||
("outputs", "Build outputs", "整理输出"),
|
||||
("done", "Done", "完成"),
|
||||
)
|
||||
_MESSAGE_STEPS = {
|
||||
STATUS_PREPARING_REQUEST: 0,
|
||||
STATUS_CHECKING_SERVER: 1,
|
||||
STATUS_SUBMITTING_TASK: 2,
|
||||
STATUS_QUEUED_LOCALLY: 3,
|
||||
STATUS_QUEUED_ON_SERVER: 3,
|
||||
STATUS_PROCESSING_ON_SERVER: 4,
|
||||
STATUS_DOWNLOADING_RESULT: 5,
|
||||
STATUS_PROCESSING_OUTPUT: 6,
|
||||
STATUS_COMPLETED: 7,
|
||||
}
|
||||
|
||||
|
||||
def _localized_text(key: str, english: str, chinese: str) -> str:
|
||||
"""输出中英文属性,复用页面现有的浏览器语言本地化逻辑。"""
|
||||
return (
|
||||
f'<span data-mineru-i18n-key="{html.escape(key, quote=True)}"'
|
||||
f' data-mineru-i18n-en="{html.escape(english, quote=True)}"'
|
||||
f' data-mineru-i18n-zh="{html.escape(chinese, quote=True)}">{html.escape(chinese)}</span>'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusPanelState:
|
||||
"""保存一次转换的阶段和单调时钟,不在会话之间共享状态。"""
|
||||
|
||||
clock: Callable[[], float] = field(default=time.monotonic, repr=False)
|
||||
message: str = DEFAULT_STATUS
|
||||
step_index: int = -1
|
||||
processing_elapsed: float | None = None
|
||||
_processing_started: float | None = None
|
||||
_queue_started: float | None = None
|
||||
|
||||
def append(self, message: str, *, at: float | None = None) -> bool:
|
||||
"""接收真实阶段变化;重复通知不会重置解析计时或排队动画。"""
|
||||
if not message or message == self.message:
|
||||
return False
|
||||
now = self.clock() if at is None else at
|
||||
if self._processing_started is not None:
|
||||
self.processing_elapsed = max(0.0, now - self._processing_started)
|
||||
self._processing_started = None
|
||||
if message == STATUS_PROCESSING_ON_SERVER:
|
||||
self._processing_started = now
|
||||
self.processing_elapsed = 0.0
|
||||
if message in (STATUS_QUEUED_LOCALLY, STATUS_QUEUED_ON_SERVER):
|
||||
if self._queue_started is None:
|
||||
self._queue_started = now
|
||||
else:
|
||||
self._queue_started = None
|
||||
self.message = message
|
||||
# 本地等待可能发生在上传之前;已展示的步骤不因后续准备通知倒退。
|
||||
self.step_index = max(self.step_index, _MESSAGE_STEPS.get(message, -1))
|
||||
if message.startswith("Failed:"):
|
||||
self.step_index = len(_STEPS) - 1
|
||||
return True
|
||||
|
||||
@property
|
||||
def refresh_interval(self) -> float | None:
|
||||
"""只在解析或排队期间启用旧版刷新频率。"""
|
||||
if self._processing_started is not None:
|
||||
return 0.1
|
||||
if self._queue_started is not None:
|
||||
return 1.0
|
||||
return None
|
||||
|
||||
def render(self) -> str:
|
||||
"""按 3.4.5 的两列卡片结构渲染当前状态,并转义外部错误文本。"""
|
||||
now = self.clock()
|
||||
failed = self.message.startswith("Failed:")
|
||||
completed = self.message == STATUS_COMPLETED
|
||||
items: list[str] = []
|
||||
for index, (key, english, chinese) in enumerate(_STEPS):
|
||||
if failed and index == self.step_index:
|
||||
state = "is-active is-error"
|
||||
key, english, chinese = "failed", "Failed", "失败"
|
||||
elif completed or index < self.step_index:
|
||||
state = "is-done"
|
||||
elif index == self.step_index:
|
||||
state = "is-active"
|
||||
else:
|
||||
state = "is-pending"
|
||||
items.append(
|
||||
f'<div class="status-step {state}"><span class="status-dot"></span>'
|
||||
f'<span class="status-label">{_localized_text("status_step_" + key, english, chinese)}</span></div>'
|
||||
)
|
||||
latest = self.message
|
||||
if self._processing_started is not None:
|
||||
elapsed = max(0.0, now - self._processing_started)
|
||||
latest = f"Processing on server ({elapsed:.1f}s)"
|
||||
elif self._queue_started is not None:
|
||||
latest += "." * (int(max(0.0, now - self._queue_started)) % 10 + 1)
|
||||
elif completed and self.processing_elapsed is not None:
|
||||
latest = f"{STATUS_COMPLETED} ({self.processing_elapsed:.1f}s)"
|
||||
if self.message == DEFAULT_STATUS:
|
||||
title = _localized_text("status_idle_title", "Waiting", "等待任务")
|
||||
latest_html = _localized_text("status_idle_hint", DEFAULT_STATUS, "上传文件后开始转换。")
|
||||
else:
|
||||
title = _localized_text("status_latest", "Latest status", "最新状态")
|
||||
latest_html = html.escape(latest)
|
||||
return (
|
||||
'<div class="status-steps-panel">'
|
||||
f'<div class="status-panel-title">{title}</div>'
|
||||
f'<div class="status-steps-list">{"".join(items)}</div>'
|
||||
f'<div class="status-latest">{latest_html}</div></div>'
|
||||
)
|
||||
|
||||
|
||||
def status_html(message: str = DEFAULT_STATUS) -> str:
|
||||
"""为初始化、输入校验和重置生成没有历史计时的状态卡片。"""
|
||||
state = StatusPanelState()
|
||||
state.append(message)
|
||||
return state.render()
|
||||
|
||||
|
||||
async def stream_status_updates(
|
||||
task: asyncio.Task[Any],
|
||||
events: asyncio.Queue[tuple[str, float]],
|
||||
state: StatusPanelState,
|
||||
) -> AsyncIterator[str]:
|
||||
"""同时等待任务、状态通知和本地动画时钟,并及时回收临时等待任务。"""
|
||||
while True:
|
||||
while not events.empty():
|
||||
message, at = events.get_nowait()
|
||||
if state.append(message, at=at):
|
||||
yield state.render()
|
||||
if task.done():
|
||||
return
|
||||
waiter = asyncio.create_task(events.get())
|
||||
updated_html: str | None = None
|
||||
try:
|
||||
done, _ = await asyncio.wait({task, waiter}, timeout=state.refresh_interval, return_when=asyncio.FIRST_COMPLETED)
|
||||
if waiter in done:
|
||||
message, at = waiter.result()
|
||||
if state.append(message, at=at):
|
||||
updated_html = state.render()
|
||||
elif not done:
|
||||
updated_html = state.render()
|
||||
finally:
|
||||
if not waiter.done():
|
||||
waiter.cancel()
|
||||
await asyncio.gather(waiter, return_exceptions=True)
|
||||
# 向 Gradio 交回控制权前已回收临时 waiter,避免暂停在 yield 时残留后台等待。
|
||||
if updated_html is not None:
|
||||
yield updated_html
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STATUS",
|
||||
"STATUS_CHECKING_SERVER",
|
||||
"STATUS_COMPLETED",
|
||||
"STATUS_DOWNLOADING_RESULT",
|
||||
"STATUS_PREPARING_REQUEST",
|
||||
"STATUS_PROCESSING_ON_SERVER",
|
||||
"STATUS_PROCESSING_OUTPUT",
|
||||
"STATUS_QUEUED_LOCALLY",
|
||||
"STATUS_QUEUED_ON_SERVER",
|
||||
"STATUS_SUBMITTING_TASK",
|
||||
"StatusPanelState",
|
||||
"status_html",
|
||||
"stream_status_updates",
|
||||
]
|
||||
+129
-198
@@ -12,12 +12,10 @@ from dataclasses import dataclass, field
|
||||
from io import BytesIO
|
||||
from typing import Any, Iterator, Literal, TypeAlias, cast
|
||||
|
||||
import numpy as np
|
||||
import pypdfium2 as pdfium
|
||||
import pypdfium2.raw as pdfium_c
|
||||
from pdftext.pdf.chars import deduplicate_chars, get_chars
|
||||
from pdftext.pdf.pages import assign_scripts, get_lines, get_spans
|
||||
from pdftext.schema import Bbox, Char, Line
|
||||
from pdftext.pdf.chars import get_chars
|
||||
from pdftext.schema import Char, Line
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from ....types import BBox, PageInfo
|
||||
@@ -26,6 +24,8 @@ from .._shared.hyperlink import sanitize_hyperlink_target
|
||||
from .._shared.image import image_to_bytes
|
||||
from .classify import classify
|
||||
from .pdfium import _pdfium_lock
|
||||
from .pdftext_adapter import _char_bbox_values, _deduplicate_pdftext_chars, _ensure_legacy_chars
|
||||
from .pdftext_adapter import get_lines_from_chars as get_lines_from_chars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,10 +47,6 @@ DRAWING_THIN_RECT_MIN_ASPECT_RATIO = 4.0
|
||||
PDF_IMAGE_FINGERPRINT_MAX_RAW_BYTES = 16 * 1024 * 1024
|
||||
_PDF_EXTERNAL_LINK_SCHEMES = frozenset({"http", "https", "mailto", "tel"})
|
||||
|
||||
try:
|
||||
from pdftext.pdf.chars import PageChars
|
||||
except ImportError:
|
||||
PageChars = None
|
||||
|
||||
# See: pdfium.PdfDocument.METADATA_KEYS
|
||||
PDFMetadataKey: TypeAlias = Literal[
|
||||
@@ -122,6 +118,21 @@ class PDFImageInfo:
|
||||
fingerprint: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PDFPageSnapshot:
|
||||
"""保存单次页面生命周期提取的纯 Python 数据,不持有 PDFium 子对象。"""
|
||||
|
||||
page_size: tuple[float, float]
|
||||
rotation: Literal[0, 90, 180, 270]
|
||||
text_geometry: PDFPageTextGeometry
|
||||
drawing_lines: list[PDFDrawingLine]
|
||||
path_infos: list[PDFPathInfo]
|
||||
image_infos: list[PDFImageInfo]
|
||||
form_bboxes: list[BBox]
|
||||
signature_bboxes: list[BBox]
|
||||
link_annotations: list[PDFLinkAnnotation]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PathSubpath:
|
||||
"""保存一个 PDF Path 子路径的点、直线段和闭合状态。"""
|
||||
@@ -400,6 +411,34 @@ class PDFDocument:
|
||||
"""一次读取字符、tight bbox 和字符原点,避免 Hybrid 重复打开 textpage。"""
|
||||
return self._get_page_text_geometry(page_idx, include_extended_geometry=True)
|
||||
|
||||
def _extract_native_page(self, page_idx: int) -> _PDFPageSnapshot:
|
||||
"""在一次加锁打开中收集 Flash 页面证据,并共享 Path 子路径解码。"""
|
||||
|
||||
with self._open_page(page_idx) as page:
|
||||
page_bbox = _normalize_pdf_page_bbox(page.get_bbox())
|
||||
try:
|
||||
raw_rotation = int(page.get_rotation()) % 360
|
||||
except Exception:
|
||||
raw_rotation = 0
|
||||
rotation = raw_rotation if raw_rotation in {0, 90, 180, 270} else 0
|
||||
drawings, paths = _extract_page_paths_and_lines(page, page_bbox, raw_rotation)
|
||||
return _PDFPageSnapshot(
|
||||
page_size=_drawing_page_size(page_bbox, raw_rotation),
|
||||
rotation=cast(Literal[0, 90, 180, 270], rotation),
|
||||
text_geometry=_extract_page_text_geometry(page, include_extended_geometry=True),
|
||||
drawing_lines=drawings,
|
||||
path_infos=paths,
|
||||
image_infos=_extract_page_image_infos(page, page_bbox, raw_rotation),
|
||||
form_bboxes=_extract_page_form_bboxes(page, page_bbox, raw_rotation),
|
||||
signature_bboxes=_extract_page_signature_bboxes(
|
||||
page,
|
||||
page_bbox,
|
||||
raw_rotation,
|
||||
form_handle=getattr(self._pdf_doc, "formenv", None),
|
||||
),
|
||||
link_annotations=_extract_page_link_annotations(page, self._pdf_doc.raw, page_bbox, raw_rotation),
|
||||
)
|
||||
|
||||
def _get_page_text_geometry(
|
||||
self,
|
||||
page_idx: int,
|
||||
@@ -408,38 +447,7 @@ class PDFDocument:
|
||||
) -> PDFPageTextGeometry:
|
||||
"""在同一 PDFium textpage 生命周期内物化字符及可选扩展几何。"""
|
||||
with self._open_page(page_idx) as page:
|
||||
textpage = None
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
raw_page_bbox: list[float] = list(page.get_bbox())
|
||||
page_bbox = _normalize_pdf_page_bbox(tuple(raw_page_bbox))
|
||||
page_rotation: int = 0
|
||||
try:
|
||||
page_rotation = page.get_rotation()
|
||||
except Exception:
|
||||
pass
|
||||
chars = get_chars(textpage, raw_page_bbox, page_rotation)
|
||||
chars = _deduplicate_pdftext_chars(chars)
|
||||
chars = _ensure_legacy_chars(chars)
|
||||
chars = _restore_pdfium_surrogate_pairs(chars, textpage)
|
||||
chars = _deduplicate_near_identical_chars(chars)
|
||||
if include_extended_geometry:
|
||||
loose_bboxes, tight_bboxes, origins = _extract_page_char_extended_geometry(
|
||||
textpage,
|
||||
chars,
|
||||
page_bbox,
|
||||
page_rotation,
|
||||
)
|
||||
else:
|
||||
loose_bboxes, tight_bboxes, origins = {}, {}, {}
|
||||
finally:
|
||||
_try_close(textpage)
|
||||
return PDFPageTextGeometry(
|
||||
chars=chars,
|
||||
tight_bboxes=tight_bboxes,
|
||||
origins=origins,
|
||||
loose_bboxes=loose_bboxes,
|
||||
)
|
||||
return _extract_page_text_geometry(page, include_extended_geometry=include_extended_geometry)
|
||||
|
||||
def get_page_lines(self, page_idx: int) -> list[Line]:
|
||||
chars = self.get_page_chars(page_idx)
|
||||
@@ -1104,6 +1112,8 @@ def _path_info_from_object(
|
||||
page_rotation: int,
|
||||
form_depth: int,
|
||||
source_index: int,
|
||||
*,
|
||||
subpaths: list[_PathSubpath] | None = None,
|
||||
) -> PDFPathInfo | None:
|
||||
"""将一个原始 Path 转换为页面几何;贝塞尔控制点也纳入保守 bbox。"""
|
||||
|
||||
@@ -1117,7 +1127,7 @@ def _path_info_from_object(
|
||||
fill_visible, stroke_visible = _get_path_visibility(raw_obj)
|
||||
points = [
|
||||
point
|
||||
for raw_subpath in _read_raw_path_subpaths(raw_obj)
|
||||
for raw_subpath in (_read_raw_path_subpaths(raw_obj) if subpaths is None else subpaths)
|
||||
for point in _transform_path_subpath(
|
||||
raw_subpath,
|
||||
matrix,
|
||||
@@ -1265,6 +1275,8 @@ def _extract_path_drawing_lines(
|
||||
matrix: tuple[float, float, float, float, float, float],
|
||||
page_bbox: BBox,
|
||||
page_rotation: int,
|
||||
*,
|
||||
subpaths: list[_PathSubpath] | None = None,
|
||||
) -> list[PDFDrawingLine]:
|
||||
"""从单个 Path 提取可见直线,坏 Path 返回空结果且不影响同页其他对象。"""
|
||||
fill_visible, stroke_visible = _get_path_visibility(raw_obj)
|
||||
@@ -1274,7 +1286,7 @@ def _extract_path_drawing_lines(
|
||||
page_size = _drawing_page_size(page_bbox, page_rotation)
|
||||
raw_stroke_width = _get_raw_stroke_width(raw_obj) if stroke_visible else 0.0
|
||||
drawing_lines: list[PDFDrawingLine] = []
|
||||
for raw_subpath in _read_raw_path_subpaths(raw_obj):
|
||||
for raw_subpath in _read_raw_path_subpaths(raw_obj) if subpaths is None else subpaths:
|
||||
subpath = _transform_path_subpath(raw_subpath, matrix, page_bbox, page_rotation)
|
||||
if fill_visible:
|
||||
filled_line = _get_thin_filled_subpath_line(subpath, page_size)
|
||||
@@ -1937,6 +1949,41 @@ def _extract_page_path_infos(
|
||||
return path_infos
|
||||
|
||||
|
||||
def _extract_page_paths_and_lines(
|
||||
page: pdfium.PdfPage,
|
||||
page_bbox: BBox,
|
||||
page_rotation: int,
|
||||
) -> tuple[list[PDFDrawingLine], list[PDFPathInfo]]:
|
||||
"""一次遍历和解码 Path,分别隔离绘图线与路径信息的派生异常。"""
|
||||
|
||||
drawing_lines: list[PDFDrawingLine] = []
|
||||
path_infos: list[PDFPathInfo] = []
|
||||
for source_index, (raw_obj, matrix, form_depth) in enumerate(_iter_raw_path_objects_with_depth(page)):
|
||||
try:
|
||||
subpaths = _read_raw_path_subpaths(raw_obj)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
drawing_lines.extend(_extract_path_drawing_lines(raw_obj, matrix, page_bbox, page_rotation, subpaths=subpaths))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info = _path_info_from_object(
|
||||
raw_obj,
|
||||
matrix,
|
||||
page_bbox,
|
||||
page_rotation,
|
||||
form_depth,
|
||||
source_index,
|
||||
subpaths=subpaths,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if info is not None:
|
||||
path_infos.append(info)
|
||||
return _merge_collinear_drawing_lines(drawing_lines, _drawing_page_size(page_bbox, page_rotation)), path_infos
|
||||
|
||||
|
||||
def _extract_page_drawing_lines(
|
||||
page: pdfium.PdfPage,
|
||||
page_bbox: BBox,
|
||||
@@ -2119,47 +2166,6 @@ def _deduplicate_near_identical_chars(chars: list[Char]) -> list[Char]:
|
||||
return deduplicated_chars
|
||||
|
||||
|
||||
def _is_pdftext_page_chars(chars: Any) -> bool:
|
||||
"""判断对象是否为 pdftext 0.7 引入的 PageChars 列式字符容器。"""
|
||||
return PageChars is not None and isinstance(chars, PageChars)
|
||||
|
||||
|
||||
def _deduplicate_pdftext_chars(chars: Any) -> Any:
|
||||
"""按当前 pdftext 返回类型调用官方去重,兼容测试或旧版本的 list 字符。"""
|
||||
if _is_pdftext_page_chars(chars) or PageChars is None:
|
||||
return deduplicate_chars(chars)
|
||||
return chars
|
||||
|
||||
|
||||
def _materialize_page_chars(chars: Any) -> list[Char]:
|
||||
"""将 pdftext 0.7 的 PageChars 物化为 MinerU 既有 char dict 列表。"""
|
||||
boxes = chars.boxes.tolist()
|
||||
rotations = chars.rotations.tolist()
|
||||
font_ids = chars.font_ids.tolist()
|
||||
char_indices = chars.char_indices.tolist()
|
||||
|
||||
return [
|
||||
cast(
|
||||
Char,
|
||||
{
|
||||
"bbox": Bbox([float(coord) for coord in boxes[index]]),
|
||||
"char": chars.text[index],
|
||||
"rotation": float(rotations[index]),
|
||||
"font": chars.fonts[int(font_ids[index])],
|
||||
"char_idx": int(char_indices[index]),
|
||||
},
|
||||
)
|
||||
for index in range(len(chars))
|
||||
]
|
||||
|
||||
|
||||
def _ensure_legacy_chars(chars: Any) -> list[Char]:
|
||||
"""统一输出旧版 char dict 列表,隔离 pdftext 0.7 的返回结构变化。"""
|
||||
if _is_pdftext_page_chars(chars):
|
||||
return _materialize_page_chars(chars)
|
||||
return cast(list[Char], chars)
|
||||
|
||||
|
||||
def _restore_pdfium_surrogate_pairs(
|
||||
chars: list[Char],
|
||||
textpage: pdfium.PdfTextPage,
|
||||
@@ -2232,121 +2238,6 @@ def _restore_pdfium_surrogate_pairs(
|
||||
return restored_chars
|
||||
|
||||
|
||||
def _char_bbox_values(bbox: object) -> tuple[float, float, float, float] | None:
|
||||
"""将 tuple/list 或 pdftext Bbox 对象统一转换为四元组坐标。"""
|
||||
if bbox is None:
|
||||
return None
|
||||
if isinstance(bbox, (list, tuple)) and len(bbox) == 4:
|
||||
return tuple(float(value) for value in bbox) # type: ignore[return-value]
|
||||
|
||||
attrs = ("x_start", "y_start", "x_end", "y_end")
|
||||
if all(hasattr(bbox, attr) for attr in attrs):
|
||||
return tuple(float(getattr(bbox, attr)) for attr in attrs) # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
||||
def _get_single_char_text(char: Char) -> str:
|
||||
"""提取单个 PDF 字符文本,异常空值用替换符保证 PageChars 长度一致。"""
|
||||
text = str(char.get("char", ""))
|
||||
if len(text) == 1:
|
||||
return text
|
||||
return text[:1] or "\ufffd"
|
||||
|
||||
|
||||
def _get_char_font_id(
|
||||
char: Char,
|
||||
fonts: list[dict[str, Any]],
|
||||
font_cache: dict[tuple[Any, Any, Any, Any], int],
|
||||
) -> int:
|
||||
"""为旧版字符 font 生成 PageChars 需要的页内 font id。"""
|
||||
font = char.get("font") or {}
|
||||
font_key = (
|
||||
font.get("name"),
|
||||
font.get("flags"),
|
||||
font.get("size"),
|
||||
font.get("weight"),
|
||||
)
|
||||
font_id = font_cache.get(font_key)
|
||||
if font_id is None:
|
||||
font_id = len(fonts)
|
||||
font_cache[font_key] = font_id
|
||||
fonts.append(
|
||||
{
|
||||
"name": font.get("name"),
|
||||
"flags": font.get("flags"),
|
||||
"size": font.get("size"),
|
||||
"weight": font.get("weight"),
|
||||
}
|
||||
)
|
||||
return font_id
|
||||
|
||||
|
||||
def _get_char_index(char: Char, fallback_idx: int) -> int:
|
||||
"""提取旧版字符索引,缺失或为空时回退到当前列表位置。"""
|
||||
char_idx = char.get("char_idx")
|
||||
if char_idx is None:
|
||||
char_idx = fallback_idx
|
||||
return int(char_idx)
|
||||
|
||||
|
||||
def _legacy_chars_to_page_chars(chars: Any) -> Any:
|
||||
"""将旧版 char dict 列表打包回 pdftext 0.7 get_spans 所需的 PageChars。"""
|
||||
if PageChars is None or _is_pdftext_page_chars(chars):
|
||||
return chars
|
||||
|
||||
fonts: list[dict[str, Any]] = []
|
||||
font_cache: dict[tuple[Any, Any, Any, Any], int] = {}
|
||||
text_parts: list[str] = []
|
||||
codes: list[int] = []
|
||||
rotations: list[float] = []
|
||||
boxes: list[tuple[float, float, float, float]] = []
|
||||
font_ids: list[int] = []
|
||||
char_indices: list[int] = []
|
||||
|
||||
for fallback_idx, char in enumerate(cast(list[Char], chars)):
|
||||
char_text = _get_single_char_text(char)
|
||||
bbox_values = _char_bbox_values(char.get("bbox"))
|
||||
if bbox_values is None:
|
||||
bbox_values = (0.0, 0.0, 0.0, 0.0)
|
||||
text_parts.append(char_text)
|
||||
codes.append(ord(char_text))
|
||||
rotations.append(float(char.get("rotation") or 0.0))
|
||||
boxes.append(bbox_values)
|
||||
font_ids.append(_get_char_font_id(char, fonts, font_cache))
|
||||
char_indices.append(_get_char_index(char, fallback_idx))
|
||||
|
||||
return PageChars(
|
||||
"".join(text_parts),
|
||||
np.array(codes, dtype=np.uint32),
|
||||
np.array(rotations, dtype=np.float64),
|
||||
np.array(boxes, dtype=np.float64).reshape((len(boxes), 4)),
|
||||
np.array(font_ids, dtype=np.int32),
|
||||
fonts,
|
||||
np.array(char_indices, dtype=np.int64),
|
||||
)
|
||||
|
||||
|
||||
def get_lines_from_chars(
|
||||
chars: list[Char],
|
||||
superscript_height_threshold: float = 0.7,
|
||||
line_distance_threshold: float = 0.1,
|
||||
) -> list[Line]:
|
||||
"""从已提取的字符构建 pdftext lines,避免重复读取 PDFium textpage。"""
|
||||
chars = _legacy_chars_to_page_chars(chars)
|
||||
spans = get_spans(
|
||||
chars,
|
||||
superscript_height_threshold=superscript_height_threshold,
|
||||
line_distance_threshold=line_distance_threshold,
|
||||
)
|
||||
lines = get_lines(spans)
|
||||
assign_scripts(
|
||||
lines,
|
||||
height_threshold=superscript_height_threshold,
|
||||
line_distance_threshold=line_distance_threshold,
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _page_to_image(page: pdfium.PdfPage, scale: float, max_edge: int) -> PDFPageImage:
|
||||
long_edge_length = max(*page.get_size())
|
||||
if (long_edge_length * scale) > max_edge:
|
||||
@@ -2361,3 +2252,43 @@ def _page_to_image(page: pdfium.PdfPage, scale: float, max_edge: int) -> PDFPage
|
||||
_try_close(bitmap)
|
||||
|
||||
return PDFPageImage(pil_image=pil_image, scale=scale)
|
||||
|
||||
|
||||
def _extract_page_text_geometry(
|
||||
page: pdfium.PdfPage,
|
||||
*,
|
||||
include_extended_geometry: bool,
|
||||
) -> PDFPageTextGeometry:
|
||||
"""在调用方持有的页面和锁内读取字符,使批量提取与独立接口共用实现。"""
|
||||
textpage = None
|
||||
try:
|
||||
textpage = page.get_textpage()
|
||||
raw_page_bbox: list[float] = list(page.get_bbox())
|
||||
page_bbox = _normalize_pdf_page_bbox(tuple(raw_page_bbox))
|
||||
page_rotation: int = 0
|
||||
try:
|
||||
page_rotation = page.get_rotation()
|
||||
except Exception:
|
||||
pass
|
||||
chars = get_chars(textpage, raw_page_bbox, page_rotation)
|
||||
chars = _deduplicate_pdftext_chars(chars)
|
||||
chars = _ensure_legacy_chars(chars)
|
||||
chars = _restore_pdfium_surrogate_pairs(chars, textpage)
|
||||
chars = _deduplicate_near_identical_chars(chars)
|
||||
if include_extended_geometry:
|
||||
loose_bboxes, tight_bboxes, origins = _extract_page_char_extended_geometry(
|
||||
textpage,
|
||||
chars,
|
||||
page_bbox,
|
||||
page_rotation,
|
||||
)
|
||||
else:
|
||||
loose_bboxes, tight_bboxes, origins = {}, {}, {}
|
||||
finally:
|
||||
_try_close(textpage)
|
||||
return PDFPageTextGeometry(
|
||||
chars=chars,
|
||||
tight_bboxes=tight_bboxes,
|
||||
origins=origins,
|
||||
loose_bboxes=loose_bboxes,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""按职责拆分的 Flash PDF 内部实现。"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,113 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""提供行内证据共享的字符和几何规范化原语。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Iterable, cast
|
||||
|
||||
from .....types import BBox
|
||||
from .types import (
|
||||
_LIGATURE_REPLACEMENTS,
|
||||
_PDF_CONTROL_CHAR_RE,
|
||||
_PDF_SEPARATOR_SPACE_CHARS,
|
||||
_PDF_ZERO_WIDTH_CHARS,
|
||||
PDF_TEXT_STYLE_ORDER,
|
||||
PDFTextStyle,
|
||||
PDFTextStyleLine,
|
||||
)
|
||||
|
||||
|
||||
def _style_line_reading_order_key(
|
||||
line: PDFTextStyleLine,
|
||||
) -> tuple[int, float, float]:
|
||||
"""优先使用原生 source_index 排序,重复索引时再以 bbox 保持稳定。"""
|
||||
|
||||
return line.source_index, line.bbox[1], line.bbox[0]
|
||||
|
||||
|
||||
def _coerce_bbox(value: Any) -> BBox | None:
|
||||
"""把 list、tuple 或 pdftext bbox 对象收敛为合法有限 bbox。"""
|
||||
|
||||
raw_bbox = getattr(value, "bbox", value)
|
||||
try:
|
||||
if raw_bbox is None or len(raw_bbox) != 4:
|
||||
return None
|
||||
bbox = tuple(float(item) for item in raw_bbox)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not all(math.isfinite(item) for item in bbox):
|
||||
return None
|
||||
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||||
return None
|
||||
return bbox # type: ignore[return-value]
|
||||
|
||||
|
||||
def _ordered_line_chars(line: Any) -> list[dict[str, Any]]:
|
||||
"""按 char_idx 修复异常乱序字符,同时保留缺少索引时的来源顺序。"""
|
||||
|
||||
chars = [char for char in getattr(line, "chars", []) if isinstance(char, dict)]
|
||||
indexed_chars = [char.get("char_idx") for char in chars]
|
||||
if (
|
||||
chars
|
||||
and all(isinstance(index, int) for index in indexed_chars)
|
||||
and any(first > second for first, second in zip(indexed_chars, indexed_chars[1:]))
|
||||
):
|
||||
return sorted(chars, key=lambda char: int(char["char_idx"]))
|
||||
return chars
|
||||
|
||||
|
||||
def _normalize_match_fragment(value: Any) -> str:
|
||||
"""把单个字符片段规范为忽略排版空白的确定性匹配文本。"""
|
||||
|
||||
output: list[str] = []
|
||||
for char in str(value or ""):
|
||||
if char in _PDF_ZERO_WIDTH_CHARS or char == "\u00ad":
|
||||
continue
|
||||
if char == "\x02":
|
||||
output.append("-")
|
||||
continue
|
||||
if char.isspace() or char in _PDF_SEPARATOR_SPACE_CHARS:
|
||||
continue
|
||||
if _PDF_CONTROL_CHAR_RE.fullmatch(char):
|
||||
continue
|
||||
output.append(_LIGATURE_REPLACEMENTS.get(char, char))
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def _canonical_styles(styles: Iterable[str]) -> tuple[PDFTextStyle, ...]:
|
||||
"""按公开富文本协议顺序过滤、去重并规范样式集合。"""
|
||||
|
||||
style_set = set(styles)
|
||||
return cast(
|
||||
tuple[PDFTextStyle, ...],
|
||||
tuple(style for style in PDF_TEXT_STYLE_ORDER if style in style_set),
|
||||
)
|
||||
|
||||
|
||||
def _bbox_intersection_area(first: BBox, second: BBox) -> float:
|
||||
"""返回两个合法 bbox 的相交面积。"""
|
||||
|
||||
width = max(0.0, min(first[2], second[2]) - max(first[0], second[0]))
|
||||
height = max(0.0, min(first[3], second[3]) - max(first[1], second[1]))
|
||||
return width * height
|
||||
|
||||
|
||||
def _bbox_overlap_ratio(first: BBox, second: BBox) -> float:
|
||||
"""返回 first 面积中落入 second 的比例。"""
|
||||
|
||||
intersection_width = max(0.0, min(first[2], second[2]) - max(first[0], second[0]))
|
||||
intersection_height = max(0.0, min(first[3], second[3]) - max(first[1], second[1]))
|
||||
first_area = max(0.01, (first[2] - first[0]) * (first[3] - first[1]))
|
||||
return intersection_width * intersection_height / first_area
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_style_line_reading_order_key",
|
||||
"_coerce_bbox",
|
||||
"_ordered_line_chars",
|
||||
"_normalize_match_fragment",
|
||||
"_canonical_styles",
|
||||
"_bbox_intersection_area",
|
||||
"_bbox_overlap_ratio",
|
||||
]
|
||||
@@ -0,0 +1,591 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""从字体、绘图线及链接注解提取原生行内证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import statistics
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from ..document import PDFLinkAnnotation
|
||||
from .common import (
|
||||
_bbox_intersection_area,
|
||||
_canonical_styles,
|
||||
_coerce_bbox,
|
||||
_normalize_match_fragment,
|
||||
_ordered_line_chars,
|
||||
_style_line_reading_order_key,
|
||||
)
|
||||
from .types import (
|
||||
_PDF_BOLD_FONT_NAME_RE,
|
||||
_PDF_FONT_SUBSET_PREFIX_RE,
|
||||
_PDF_LIST_MARKER_CHARS,
|
||||
_PDF_TEXT_DECORATION_ORDER,
|
||||
PDF_BOLD_MIN_COMPARABLE_CHAR_COUNT,
|
||||
PDF_BOLD_MIN_WEIGHT,
|
||||
PDF_FONT_FORCE_BOLD_FLAG,
|
||||
PDF_LINK_CHAR_OVERLAP_THRESHOLD,
|
||||
STRIKETHROUGH_CENTER_TOLERANCE_HEIGHT_RATIO,
|
||||
TEXT_DECORATION_ENDPOINT_TOLERANCE_HEIGHT_RATIO,
|
||||
TEXT_DECORATION_MAX_WIDTH_HEIGHT_RATIO,
|
||||
TEXT_DECORATION_MIN_LENGTH_HEIGHT_RATIO,
|
||||
TEXT_DECORATION_MIN_TEXT_COVERAGE_RATIO,
|
||||
UNDERLINE_BOTTOM_TOLERANCE_HEIGHT_RATIO,
|
||||
UNDERLINE_FRACTION_MAX_GAP_HEIGHT_RATIO,
|
||||
UNDERLINE_FRACTION_MIN_LOWER_LINE_COVERAGE,
|
||||
PDFTextDecoration,
|
||||
PDFTextLinkLine,
|
||||
PDFTextLinkRange,
|
||||
PDFTextStyle,
|
||||
PDFTextStyleLine,
|
||||
PDFTextStyleRange,
|
||||
_DrawingMatch,
|
||||
_LineCandidate,
|
||||
_VisibleChar,
|
||||
)
|
||||
|
||||
|
||||
def _pdf_font_metadata(char: dict[str, Any]) -> tuple[str, int, float | None]:
|
||||
"""读取单个字符的规范字体名、FontDescriptor flags 和有效字重。"""
|
||||
|
||||
font = char.get("font")
|
||||
if not isinstance(font, dict):
|
||||
return "", 0, None
|
||||
font_name = _PDF_FONT_SUBSET_PREFIX_RE.sub(
|
||||
"",
|
||||
str(font.get("name") or ""),
|
||||
)
|
||||
try:
|
||||
font_flags = int(font.get("flags") or 0)
|
||||
except (TypeError, ValueError):
|
||||
font_flags = 0
|
||||
try:
|
||||
font_weight = float(font.get("weight"))
|
||||
except (TypeError, ValueError):
|
||||
font_weight = math.nan
|
||||
if not math.isfinite(font_weight) or font_weight <= 0:
|
||||
font_weight = None
|
||||
return font_name, font_flags, font_weight
|
||||
|
||||
|
||||
def _char_font_styles(char: dict[str, Any]) -> frozenset[PDFTextStyle]:
|
||||
"""只依据直接字体证据返回 PDF 字符粗体样式。"""
|
||||
|
||||
font_name, font_flags, font_weight = _pdf_font_metadata(char)
|
||||
styles: set[PDFTextStyle] = set()
|
||||
if (
|
||||
font_flags & PDF_FONT_FORCE_BOLD_FLAG
|
||||
or (font_weight is not None and font_weight >= PDF_BOLD_MIN_WEIGHT)
|
||||
or bool(_PDF_BOLD_FONT_NAME_RE.search(font_name))
|
||||
):
|
||||
styles.add("bold")
|
||||
return frozenset(styles)
|
||||
|
||||
|
||||
def _has_list_marker_separator(
|
||||
chars: Sequence[dict[str, Any]],
|
||||
marker_source_index: int,
|
||||
next_source_index: int,
|
||||
median_height: float,
|
||||
) -> bool:
|
||||
"""判断行首项目符号与后续正文之间是否存在空白或明显视觉间隔。"""
|
||||
|
||||
if any(str(chars[index].get("char") or "").isspace() for index in range(marker_source_index + 1, next_source_index)):
|
||||
return True
|
||||
marker_bbox = _coerce_bbox(chars[marker_source_index].get("bbox"))
|
||||
next_bbox = _coerce_bbox(chars[next_source_index].get("bbox"))
|
||||
return bool(marker_bbox is not None and next_bbox is not None and next_bbox[0] - marker_bbox[2] >= 0.5 * median_height)
|
||||
|
||||
|
||||
def _filter_pdf_bold_runs(
|
||||
chars: Sequence[dict[str, Any]],
|
||||
font_styles: Sequence[frozenset[PDFTextStyle]],
|
||||
median_height: float,
|
||||
) -> list[frozenset[PDFTextStyle]]:
|
||||
"""过滤过短粗体 run 和与正文分离的行首项目符号粗体。"""
|
||||
|
||||
output = list(font_styles)
|
||||
comparable_chars = [
|
||||
(source_index, fragment)
|
||||
for source_index, char in enumerate(chars)
|
||||
if (fragment := _normalize_match_fragment(char.get("char")))
|
||||
]
|
||||
run_start = 0
|
||||
while run_start < len(comparable_chars):
|
||||
source_index, _fragment = comparable_chars[run_start]
|
||||
if "bold" not in output[source_index]:
|
||||
run_start += 1
|
||||
continue
|
||||
run_end = run_start + 1
|
||||
while run_end < len(comparable_chars):
|
||||
next_source_index, _next_fragment = comparable_chars[run_end]
|
||||
if "bold" not in output[next_source_index]:
|
||||
break
|
||||
run_end += 1
|
||||
|
||||
run = comparable_chars[run_start:run_end]
|
||||
run_text = "".join(fragment for _index, fragment in run)
|
||||
is_short = len(run_text) < PDF_BOLD_MIN_COMPARABLE_CHAR_COUNT
|
||||
is_isolated_leading_marker = (
|
||||
run_start == 0
|
||||
and run_end < len(comparable_chars)
|
||||
and bool(run_text)
|
||||
and all(char in _PDF_LIST_MARKER_CHARS for char in run_text)
|
||||
and _has_list_marker_separator(
|
||||
chars,
|
||||
run[-1][0],
|
||||
comparable_chars[run_end][0],
|
||||
median_height,
|
||||
)
|
||||
)
|
||||
if is_short or is_isolated_leading_marker:
|
||||
for run_source_index, _run_fragment in run:
|
||||
output[run_source_index] = frozenset(style for style in output[run_source_index] if style != "bold")
|
||||
run_start = run_end
|
||||
return output
|
||||
|
||||
|
||||
def _build_line_candidate(line: Any) -> _LineCandidate | None:
|
||||
"""从视觉水平 line 构造字符几何候选,旋转文字和退化行返回空。"""
|
||||
|
||||
if int(getattr(line, "angle", 0) or 0) % 360 != 0:
|
||||
return None
|
||||
line_bbox = _coerce_bbox(getattr(line, "bbox", None))
|
||||
if line_bbox is None:
|
||||
return None
|
||||
chars = _ordered_line_chars(line)
|
||||
visible_chars: list[_VisibleChar] = []
|
||||
for char_index, char in enumerate(chars):
|
||||
text = str(char.get("char") or "")
|
||||
bbox = _coerce_bbox(char.get("bbox"))
|
||||
if bbox is None or not text.isprintable() or text.isspace():
|
||||
continue
|
||||
visible_chars.append(_VisibleChar(source_index=char_index, bbox=bbox))
|
||||
if not visible_chars:
|
||||
return None
|
||||
|
||||
heights = [char.bbox[3] - char.bbox[1] for char in visible_chars]
|
||||
median_height = statistics.median(heights)
|
||||
if median_height <= 0:
|
||||
return None
|
||||
body_chars = [char for char, height in zip(visible_chars, heights) if height >= 0.8 * median_height]
|
||||
if not body_chars:
|
||||
return None
|
||||
font_styles = [_char_font_styles(char) for char in chars]
|
||||
return _LineCandidate(
|
||||
bbox=line_bbox,
|
||||
chars=chars,
|
||||
visible_chars=visible_chars,
|
||||
median_height=median_height,
|
||||
center_y=statistics.median((char.bbox[1] + char.bbox[3]) / 2 for char in body_chars),
|
||||
bottom_y=statistics.median(char.bbox[3] for char in body_chars),
|
||||
source_index=int(getattr(line, "source_index", 0) or 0),
|
||||
font_styles=_filter_pdf_bold_runs(
|
||||
chars,
|
||||
font_styles,
|
||||
median_height,
|
||||
),
|
||||
decoration_ranges={
|
||||
"underline": [],
|
||||
"strikethrough": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _drawing_match_for_line(
|
||||
line: _LineCandidate,
|
||||
drawing: Any,
|
||||
style: PDFTextDecoration,
|
||||
) -> _DrawingMatch | None:
|
||||
"""按目标纵向锚点和公共几何规则校验单条文本装饰线。"""
|
||||
|
||||
if getattr(drawing, "orientation", None) != "horizontal":
|
||||
return None
|
||||
drawing_bbox = _coerce_bbox(getattr(drawing, "bbox", None))
|
||||
if drawing_bbox is None:
|
||||
return None
|
||||
drawing_length = drawing_bbox[2] - drawing_bbox[0]
|
||||
if drawing_length < TEXT_DECORATION_MIN_LENGTH_HEIGHT_RATIO * line.median_height:
|
||||
return None
|
||||
drawing_center_y = (drawing_bbox[1] + drawing_bbox[3]) / 2
|
||||
target_y = line.bottom_y if style == "underline" else line.center_y
|
||||
target_tolerance = (
|
||||
UNDERLINE_BOTTOM_TOLERANCE_HEIGHT_RATIO if style == "underline" else STRIKETHROUGH_CENTER_TOLERANCE_HEIGHT_RATIO
|
||||
)
|
||||
target_distance_ratio = abs(drawing_center_y - target_y) / line.median_height
|
||||
if target_distance_ratio > target_tolerance:
|
||||
return None
|
||||
try:
|
||||
drawing_width = max(0.0, float(getattr(drawing, "width", 0.0) or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if drawing_width > TEXT_DECORATION_MAX_WIDTH_HEIGHT_RATIO * line.median_height:
|
||||
return None
|
||||
|
||||
hit_chars = [char for char in line.visible_chars if drawing_bbox[0] <= (char.bbox[0] + char.bbox[2]) / 2 <= drawing_bbox[2]]
|
||||
if not hit_chars:
|
||||
return None
|
||||
hit_left = min(char.bbox[0] for char in hit_chars)
|
||||
hit_right = max(char.bbox[2] for char in hit_chars)
|
||||
if (hit_right - hit_left) / drawing_length < TEXT_DECORATION_MIN_TEXT_COVERAGE_RATIO:
|
||||
return None
|
||||
endpoint_distance = min(
|
||||
abs(drawing_bbox[0] - hit_left),
|
||||
abs(drawing_bbox[2] - hit_right),
|
||||
)
|
||||
if endpoint_distance > TEXT_DECORATION_ENDPOINT_TOLERANCE_HEIGHT_RATIO * line.median_height:
|
||||
return None
|
||||
|
||||
overlap = max(
|
||||
0.0,
|
||||
min(line.bbox[2], drawing_bbox[2]) - max(line.bbox[0], drawing_bbox[0]),
|
||||
)
|
||||
horizontal_overlap_ratio = overlap / max(
|
||||
0.01,
|
||||
min(line.bbox[2] - line.bbox[0], drawing_length),
|
||||
)
|
||||
return _DrawingMatch(
|
||||
style=style,
|
||||
start_index=min(char.source_index for char in hit_chars),
|
||||
end_index=max(char.source_index for char in hit_chars) + 1,
|
||||
target_distance_ratio=target_distance_ratio,
|
||||
horizontal_overlap_ratio=horizontal_overlap_ratio,
|
||||
)
|
||||
|
||||
|
||||
def _merge_source_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""合并重叠或相邻的来源字符区间。"""
|
||||
|
||||
merged: list[tuple[int, int]] = []
|
||||
for start, end in sorted(ranges):
|
||||
if start >= end:
|
||||
continue
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _line_style_payload(line: _LineCandidate) -> PDFTextStyleLine | None:
|
||||
"""把来源字符的字体与装饰线证据转换为紧凑文本样式区间。"""
|
||||
|
||||
decoration_styles: list[set[PDFTextStyle]] = [set() for _char in line.chars]
|
||||
for style in _PDF_TEXT_DECORATION_ORDER:
|
||||
for start, end in _merge_source_ranges(line.decoration_ranges[style]):
|
||||
for char_index in range(max(0, start), min(end, len(line.chars))):
|
||||
decoration_styles[char_index].add(style)
|
||||
compact_parts: list[str] = []
|
||||
compact_styles: list[tuple[PDFTextStyle, ...]] = []
|
||||
for char_index, char in enumerate(line.chars):
|
||||
fragment = _normalize_match_fragment(char.get("char"))
|
||||
if not fragment:
|
||||
continue
|
||||
compact_parts.append(fragment)
|
||||
styles = set(line.font_styles[char_index])
|
||||
styles.update(decoration_styles[char_index])
|
||||
canonical_styles = _canonical_styles(styles)
|
||||
compact_styles.extend([canonical_styles] * len(fragment))
|
||||
|
||||
text = "".join(compact_parts)
|
||||
if not text:
|
||||
return None
|
||||
compact_ranges: list[PDFTextStyleRange] = []
|
||||
active_start = 0
|
||||
active_styles: tuple[PDFTextStyle, ...] = ()
|
||||
for offset, styles in enumerate([*compact_styles, ()]):
|
||||
if styles == active_styles:
|
||||
continue
|
||||
if active_styles:
|
||||
compact_ranges.append(PDFTextStyleRange(active_start, offset, active_styles))
|
||||
active_start = offset
|
||||
active_styles = styles
|
||||
return PDFTextStyleLine(
|
||||
bbox=line.bbox,
|
||||
text=text,
|
||||
style_ranges=tuple(compact_ranges),
|
||||
source_index=line.source_index,
|
||||
)
|
||||
|
||||
|
||||
def _build_line_geometry_grids(
|
||||
candidates: Sequence[_LineCandidate],
|
||||
) -> tuple[
|
||||
float,
|
||||
dict[int, list[tuple[int, PDFTextDecoration]]],
|
||||
dict[int, list[int]],
|
||||
]:
|
||||
"""按装饰线锚点和行顶坐标建立网格,限制每条 drawing 的局部比较范围。"""
|
||||
|
||||
grid_size = max(
|
||||
1.0,
|
||||
statistics.median(line.median_height for line in candidates),
|
||||
)
|
||||
anchor_grid: dict[int, list[tuple[int, PDFTextDecoration]]] = {}
|
||||
top_grid: dict[int, list[int]] = {}
|
||||
for line_index, line in enumerate(candidates):
|
||||
top_grid.setdefault(math.floor(line.bbox[1] / grid_size), []).append(line_index)
|
||||
for style, target_y, tolerance_ratio in (
|
||||
(
|
||||
"underline",
|
||||
line.bottom_y,
|
||||
UNDERLINE_BOTTOM_TOLERANCE_HEIGHT_RATIO,
|
||||
),
|
||||
(
|
||||
"strikethrough",
|
||||
line.center_y,
|
||||
STRIKETHROUGH_CENTER_TOLERANCE_HEIGHT_RATIO,
|
||||
),
|
||||
):
|
||||
tolerance = tolerance_ratio * line.median_height
|
||||
start_cell = math.floor((target_y - tolerance) / grid_size)
|
||||
end_cell = math.floor((target_y + tolerance) / grid_size)
|
||||
for cell in range(start_cell, end_cell + 1):
|
||||
anchor_grid.setdefault(cell, []).append((line_index, style))
|
||||
return grid_size, anchor_grid, top_grid
|
||||
|
||||
|
||||
def _is_fraction_bar_candidate(
|
||||
candidates: Sequence[_LineCandidate],
|
||||
top_grid: dict[int, list[int]],
|
||||
grid_size: float,
|
||||
line_index: int,
|
||||
drawing_bbox: BBox,
|
||||
) -> bool:
|
||||
"""用紧邻且被横线覆盖的下方文本 run 排除公式分数线。"""
|
||||
|
||||
line = candidates[line_index]
|
||||
drawing_center_y = (drawing_bbox[1] + drawing_bbox[3]) / 2
|
||||
max_lower_top = drawing_center_y + UNDERLINE_FRACTION_MAX_GAP_HEIGHT_RATIO * line.median_height
|
||||
lower_indices: set[int] = set()
|
||||
for cell in range(
|
||||
math.floor(drawing_center_y / grid_size),
|
||||
math.floor(max_lower_top / grid_size) + 1,
|
||||
):
|
||||
lower_indices.update(top_grid.get(cell, ()))
|
||||
for lower_index in lower_indices:
|
||||
if lower_index == line_index:
|
||||
continue
|
||||
lower_line = candidates[lower_index]
|
||||
if not drawing_center_y <= lower_line.bbox[1] <= max_lower_top:
|
||||
continue
|
||||
lower_width = lower_line.bbox[2] - lower_line.bbox[0]
|
||||
horizontal_overlap = max(
|
||||
0.0,
|
||||
min(drawing_bbox[2], lower_line.bbox[2]) - max(drawing_bbox[0], lower_line.bbox[0]),
|
||||
)
|
||||
if horizontal_overlap / max(0.01, lower_width) >= UNDERLINE_FRACTION_MIN_LOWER_LINE_COVERAGE:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_pdf_text_style_lines(
|
||||
lines: Sequence[Any],
|
||||
drawing_lines: Sequence[Any],
|
||||
) -> list[PDFTextStyleLine]:
|
||||
"""从视觉文本 run 与页面 drawing 中生成全部水平行样式证据。"""
|
||||
|
||||
candidates = [candidate for line in lines if (candidate := _build_line_candidate(line)) is not None]
|
||||
if not candidates:
|
||||
return []
|
||||
horizontal_drawings = [drawing for drawing in drawing_lines if getattr(drawing, "orientation", None) == "horizontal"]
|
||||
if horizontal_drawings:
|
||||
grid_size, anchor_grid, top_grid = _build_line_geometry_grids(candidates)
|
||||
for drawing in horizontal_drawings:
|
||||
drawing_bbox = _coerce_bbox(getattr(drawing, "bbox", None))
|
||||
if drawing_bbox is None:
|
||||
continue
|
||||
drawing_center_y = (drawing_bbox[1] + drawing_bbox[3]) / 2
|
||||
candidate_anchors = anchor_grid.get(
|
||||
math.floor(drawing_center_y / grid_size),
|
||||
[],
|
||||
)
|
||||
matches: list[tuple[int, _DrawingMatch]] = []
|
||||
for line_index, style in candidate_anchors:
|
||||
match = _drawing_match_for_line(
|
||||
candidates[line_index],
|
||||
drawing,
|
||||
style,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
if style == "underline" and _is_fraction_bar_candidate(
|
||||
candidates,
|
||||
top_grid,
|
||||
grid_size,
|
||||
line_index,
|
||||
drawing_bbox,
|
||||
):
|
||||
continue
|
||||
matches.append((line_index, match))
|
||||
if not matches:
|
||||
continue
|
||||
line_index, best_match = min(
|
||||
matches,
|
||||
key=lambda item: (
|
||||
item[1].target_distance_ratio,
|
||||
-item[1].horizontal_overlap_ratio,
|
||||
candidates[item[0]].source_index,
|
||||
_PDF_TEXT_DECORATION_ORDER.index(item[1].style),
|
||||
),
|
||||
)
|
||||
candidates[line_index].decoration_ranges[best_match.style].append((best_match.start_index, best_match.end_index))
|
||||
|
||||
payloads = [payload for line in candidates if (payload := _line_style_payload(line)) is not None]
|
||||
if not any(line.style_ranges for line in payloads):
|
||||
return []
|
||||
return sorted(
|
||||
payloads,
|
||||
key=_style_line_reading_order_key,
|
||||
)
|
||||
|
||||
|
||||
def _link_region_hits_char(region: BBox, char_bbox: BBox) -> bool:
|
||||
"""按字符中心或字符面积覆盖率判断 Link 区域是否命中字符。"""
|
||||
|
||||
center_x = (char_bbox[0] + char_bbox[2]) / 2
|
||||
center_y = (char_bbox[1] + char_bbox[3]) / 2
|
||||
if region[0] <= center_x <= region[2] and region[1] <= center_y <= region[3]:
|
||||
return True
|
||||
char_area = max(
|
||||
0.01,
|
||||
(char_bbox[2] - char_bbox[0]) * (char_bbox[3] - char_bbox[1]),
|
||||
)
|
||||
return _bbox_intersection_area(region, char_bbox) / char_area >= PDF_LINK_CHAR_OVERLAP_THRESHOLD
|
||||
|
||||
|
||||
def _link_targets_for_char(
|
||||
char_bbox: BBox,
|
||||
annotations: Sequence[PDFLinkAnnotation],
|
||||
) -> set[str]:
|
||||
"""返回命中字符的全部不同链接目标,供冲突检测使用。"""
|
||||
|
||||
return {
|
||||
annotation.target
|
||||
for annotation in annotations
|
||||
if any(_link_region_hits_char(region, char_bbox) for region in annotation.bboxes)
|
||||
}
|
||||
|
||||
|
||||
def _compact_link_ranges(
|
||||
compact_targets: Sequence[str | None],
|
||||
) -> tuple[PDFTextLinkRange, ...]:
|
||||
"""把逐字符链接目标压缩为同目标连续区间。"""
|
||||
|
||||
ranges: list[PDFTextLinkRange] = []
|
||||
active_start = 0
|
||||
active_target: str | None = None
|
||||
for offset, target in enumerate([*compact_targets, None]):
|
||||
if target == active_target:
|
||||
continue
|
||||
if active_target is not None:
|
||||
ranges.append(
|
||||
PDFTextLinkRange(
|
||||
start=active_start,
|
||||
end=offset,
|
||||
target=active_target,
|
||||
)
|
||||
)
|
||||
active_start = offset
|
||||
active_target = target
|
||||
return tuple(ranges)
|
||||
|
||||
|
||||
def _build_link_line_payload(
|
||||
line: Any,
|
||||
annotations: Sequence[PDFLinkAnnotation],
|
||||
fallback_source_index: int,
|
||||
) -> PDFTextLinkLine | None:
|
||||
"""把一个视觉文本 run 与 Link 区域相交结果转换为紧凑链接证据。"""
|
||||
|
||||
try:
|
||||
angle = int(getattr(line, "angle", 0) or 0) % 360
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if angle not in {0, 90, 180, 270}:
|
||||
return None
|
||||
line_bbox = _coerce_bbox(getattr(line, "bbox", None))
|
||||
if line_bbox is None:
|
||||
return None
|
||||
nearby_annotations = [
|
||||
annotation
|
||||
for annotation in annotations
|
||||
if any(_bbox_intersection_area(line_bbox, region) > 0 for region in annotation.bboxes)
|
||||
]
|
||||
if not nearby_annotations:
|
||||
return None
|
||||
|
||||
compact_parts: list[str] = []
|
||||
compact_targets: list[str | None] = []
|
||||
for char in _ordered_line_chars(line):
|
||||
fragment = _normalize_match_fragment(char.get("char"))
|
||||
if not fragment:
|
||||
continue
|
||||
char_bbox = _coerce_bbox(char.get("bbox"))
|
||||
targets = _link_targets_for_char(char_bbox, nearby_annotations) if char_bbox is not None else set()
|
||||
# 同一字符落入不同目标时不猜测 PDF 点击层级,保留为普通文本。
|
||||
target = next(iter(targets)) if len(targets) == 1 else None
|
||||
compact_parts.append(fragment)
|
||||
compact_targets.extend([target] * len(fragment))
|
||||
|
||||
text = "".join(compact_parts)
|
||||
link_ranges = _compact_link_ranges(compact_targets)
|
||||
if not text or not link_ranges:
|
||||
return None
|
||||
try:
|
||||
source_index = int(getattr(line, "source_index", fallback_source_index))
|
||||
except (TypeError, ValueError):
|
||||
source_index = fallback_source_index
|
||||
return PDFTextLinkLine(
|
||||
bbox=line_bbox,
|
||||
text=text,
|
||||
link_ranges=link_ranges,
|
||||
source_index=source_index,
|
||||
)
|
||||
|
||||
|
||||
def detect_pdf_text_link_lines(
|
||||
lines: Sequence[Any],
|
||||
annotations: Sequence[PDFLinkAnnotation],
|
||||
) -> list[PDFTextLinkLine]:
|
||||
"""从视觉文本 run 与 PDF Link 注解生成字符级超链接证据。"""
|
||||
|
||||
if not annotations:
|
||||
return []
|
||||
payloads = [
|
||||
payload
|
||||
for line_index, line in enumerate(lines)
|
||||
if (
|
||||
payload := _build_link_line_payload(
|
||||
line,
|
||||
annotations,
|
||||
line_index,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
return sorted(
|
||||
payloads,
|
||||
key=lambda line: (line.source_index, line.bbox[1], line.bbox[0]),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_pdf_font_metadata",
|
||||
"_char_font_styles",
|
||||
"_has_list_marker_separator",
|
||||
"_filter_pdf_bold_runs",
|
||||
"_build_line_candidate",
|
||||
"_drawing_match_for_line",
|
||||
"_merge_source_ranges",
|
||||
"_line_style_payload",
|
||||
"_build_line_geometry_grids",
|
||||
"_is_fraction_bar_candidate",
|
||||
"detect_pdf_text_style_lines",
|
||||
"_link_region_hits_char",
|
||||
"_link_targets_for_char",
|
||||
"_compact_link_ranges",
|
||||
"_build_link_line_payload",
|
||||
"detect_pdf_text_link_lines",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,458 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""把已对齐的链接、样式及上下标区间物化为 InlineSpan。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from ..._shared.spans import (
|
||||
append_equation_span,
|
||||
append_hyperlink_span,
|
||||
append_text_span,
|
||||
extend_inline_spans,
|
||||
normalize_span_dicts,
|
||||
)
|
||||
from .common import _canonical_styles
|
||||
from .matching import (
|
||||
_assign_lines_to_blocks,
|
||||
_assign_script_lines_to_blocks,
|
||||
_block_bbox_to_page_bbox,
|
||||
_filter_line_styles_for_block,
|
||||
_match_link_ranges,
|
||||
_match_script_line_ranges,
|
||||
_match_style_ranges,
|
||||
_merge_raw_link_intervals,
|
||||
_merge_style_ranges,
|
||||
_project_content_chars,
|
||||
_raw_link_intervals,
|
||||
)
|
||||
from .types import (
|
||||
_NATIVE_SCRIPT_TAG_RE,
|
||||
_PDF_INLINE_SPAN_BLOCK_TYPES,
|
||||
_PDF_LINK_INTERVALS_KEY,
|
||||
_PDF_STYLE_INTERVALS_KEY,
|
||||
PDF_NATIVE_SCRIPT_MARKUP_KEY,
|
||||
PDFTextLinkLine,
|
||||
PDFTextScriptLine,
|
||||
PDFTextScriptRange,
|
||||
PDFTextStyle,
|
||||
PDFTextStyleLine,
|
||||
PDFTextStyleRange,
|
||||
_NativeScriptMarkup,
|
||||
_ProjectedChar,
|
||||
_RawStyleInterval,
|
||||
)
|
||||
|
||||
|
||||
def apply_pdf_text_links(
|
||||
blocks: list[dict[str, Any]],
|
||||
lines: Sequence[PDFTextLinkLine],
|
||||
page_size: tuple[float, float],
|
||||
) -> None:
|
||||
"""把页面 Link 几何证据写入自然语言 block,歧义时保持原文。"""
|
||||
|
||||
assignments = _assign_lines_to_blocks(blocks, lines, page_size)
|
||||
for block_index, block_lines in assignments.items():
|
||||
content = blocks[block_index].get("content")
|
||||
if not isinstance(content, str) or not content:
|
||||
continue
|
||||
projected = _project_content_chars(content)
|
||||
link_ranges = _match_link_ranges(projected, block_lines)
|
||||
if not link_ranges:
|
||||
continue
|
||||
intervals = _raw_link_intervals(content, projected, link_ranges)
|
||||
if intervals:
|
||||
intervals = _merge_raw_link_intervals(content, intervals)
|
||||
blocks[block_index][_PDF_LINK_INTERVALS_KEY] = intervals
|
||||
|
||||
|
||||
def _append_raw_style_interval(
|
||||
intervals: list[_RawStyleInterval],
|
||||
start: int | None,
|
||||
end: int,
|
||||
styles: tuple[PDFTextStyle, ...],
|
||||
) -> None:
|
||||
"""向结果追加一个合法原字符串样式区间。"""
|
||||
|
||||
if start is not None and start < end and styles:
|
||||
intervals.append(_RawStyleInterval(start, end, styles))
|
||||
|
||||
|
||||
def _merge_raw_style_intervals(
|
||||
content: str,
|
||||
intervals: Sequence[_RawStyleInterval],
|
||||
) -> list[_RawStyleInterval]:
|
||||
"""合并原字符串中相邻且样式一致、仅由普通空白隔开的区间。"""
|
||||
|
||||
merged: list[_RawStyleInterval] = []
|
||||
for interval in sorted(intervals, key=lambda item: (item.start, item.end, item.styles)):
|
||||
if interval.start >= interval.end or not interval.styles:
|
||||
continue
|
||||
if (
|
||||
merged
|
||||
and merged[-1].styles == interval.styles
|
||||
and (interval.start <= merged[-1].end or content[merged[-1].end : interval.start].isspace())
|
||||
):
|
||||
merged[-1] = _RawStyleInterval(
|
||||
merged[-1].start,
|
||||
max(merged[-1].end, interval.end),
|
||||
merged[-1].styles,
|
||||
)
|
||||
else:
|
||||
merged.append(interval)
|
||||
return merged
|
||||
|
||||
|
||||
def _raw_style_intervals(
|
||||
content: str,
|
||||
projected: Sequence[_ProjectedChar],
|
||||
ranges: Sequence[PDFTextStyleRange],
|
||||
) -> list[_RawStyleInterval]:
|
||||
"""把样式区间转换为不跨公式或已有行内标签的原字符串区间。"""
|
||||
|
||||
intervals: list[_RawStyleInterval] = []
|
||||
for style_range in ranges:
|
||||
current_start: int | None = None
|
||||
current_end = 0
|
||||
current_styles: tuple[PDFTextStyle, ...] = ()
|
||||
for token in projected[style_range.start : style_range.end]:
|
||||
# Link 注解已提供语义,链接范围内的几何下划线不重复输出为文本样式。
|
||||
missing_styles = _canonical_styles(
|
||||
style
|
||||
for style in style_range.styles
|
||||
if style not in token.existing_styles and not (style == "underline" and token.inside_hyperlink)
|
||||
)
|
||||
if not missing_styles:
|
||||
_append_raw_style_interval(
|
||||
intervals,
|
||||
current_start,
|
||||
current_end,
|
||||
current_styles,
|
||||
)
|
||||
current_start = None
|
||||
current_styles = ()
|
||||
continue
|
||||
if current_start is None:
|
||||
current_start = token.raw_start
|
||||
current_end = token.raw_end
|
||||
current_styles = missing_styles
|
||||
continue
|
||||
gap = content[current_end : token.raw_start]
|
||||
if missing_styles == current_styles and (token.raw_start <= current_end or not gap or gap.isspace()):
|
||||
current_end = max(current_end, token.raw_end)
|
||||
else:
|
||||
_append_raw_style_interval(
|
||||
intervals,
|
||||
current_start,
|
||||
current_end,
|
||||
current_styles,
|
||||
)
|
||||
current_start = token.raw_start
|
||||
current_end = token.raw_end
|
||||
current_styles = missing_styles
|
||||
_append_raw_style_interval(
|
||||
intervals,
|
||||
current_start,
|
||||
current_end,
|
||||
current_styles,
|
||||
)
|
||||
return _merge_raw_style_intervals(content, intervals)
|
||||
|
||||
|
||||
def apply_pdf_text_styles(
|
||||
blocks: list[dict[str, Any]],
|
||||
lines: Sequence[PDFTextStyleLine],
|
||||
page_size: tuple[float, float],
|
||||
) -> None:
|
||||
"""把页面字体和装饰线证据写入自然语言 block,歧义时保持原文。"""
|
||||
|
||||
assignments = _assign_lines_to_blocks(blocks, lines, page_size)
|
||||
for block_index, block_lines in assignments.items():
|
||||
block = blocks[block_index]
|
||||
block_lines = _filter_line_styles_for_block(
|
||||
block_lines,
|
||||
block.get("type"),
|
||||
)
|
||||
content = block.get("content")
|
||||
if not isinstance(content, str) or not content or not any(line.style_ranges for line in block_lines):
|
||||
continue
|
||||
projected = _project_content_chars(content)
|
||||
style_ranges = _match_style_ranges(projected, block_lines)
|
||||
if not style_ranges:
|
||||
continue
|
||||
intervals = _raw_style_intervals(content, projected, style_ranges)
|
||||
if intervals:
|
||||
existing = block.get(_PDF_STYLE_INTERVALS_KEY, [])
|
||||
block[_PDF_STYLE_INTERVALS_KEY] = _merge_raw_style_intervals(
|
||||
content,
|
||||
[
|
||||
*(interval for interval in existing if isinstance(interval, _RawStyleInterval)),
|
||||
*intervals,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _script_range_hits_late_formula_region(
|
||||
script_range: PDFTextScriptRange,
|
||||
regions: list[BBox],
|
||||
) -> bool:
|
||||
"""判断非公式候选是否落入后续文本块恢复出的行内数学区域。"""
|
||||
if script_range.formula_region:
|
||||
return False
|
||||
center_x = (script_range.bbox[0] + script_range.bbox[2]) / 2
|
||||
center_y = (script_range.bbox[1] + script_range.bbox[3]) / 2
|
||||
return any(region[0] <= center_x <= region[2] and region[1] <= center_y <= region[3] for region in regions)
|
||||
|
||||
|
||||
def _record_materialized_script_ranges(
|
||||
content: str,
|
||||
projected: Sequence[_ProjectedChar],
|
||||
combined_ranges: Sequence[PDFTextStyleRange],
|
||||
block_index: int,
|
||||
line: PDFTextScriptLine,
|
||||
script_ranges: Sequence[PDFTextScriptRange],
|
||||
output: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""记录真正通过文本投影的私有上下标区间,供审阅产物精确回溯。"""
|
||||
for script_range in script_ranges:
|
||||
evidence_line = PDFTextStyleLine(
|
||||
bbox=line.bbox,
|
||||
text=line.text,
|
||||
style_ranges=(
|
||||
PDFTextStyleRange(
|
||||
script_range.start,
|
||||
script_range.end,
|
||||
(script_range.style,),
|
||||
),
|
||||
),
|
||||
source_index=line.source_index,
|
||||
)
|
||||
matched = _match_script_line_ranges(projected, evidence_line)
|
||||
if len(matched) != 1:
|
||||
continue
|
||||
mapped = matched[0]
|
||||
if not any(
|
||||
mapped.start >= combined.start and mapped.end <= combined.end and script_range.style in combined.styles
|
||||
for combined in combined_ranges
|
||||
):
|
||||
continue
|
||||
raw_intervals = _raw_style_intervals(content, projected, [mapped])
|
||||
if len(raw_intervals) != 1:
|
||||
continue
|
||||
raw_interval = raw_intervals[0]
|
||||
output.append(
|
||||
{
|
||||
"block_index": block_index,
|
||||
"raw_start": raw_interval.start,
|
||||
"raw_end": raw_interval.end,
|
||||
"source_index": line.source_index,
|
||||
"range_start": script_range.start,
|
||||
"range_end": script_range.end,
|
||||
"role": script_range.style,
|
||||
"text": line.text[script_range.start : script_range.end],
|
||||
"bbox": script_range.bbox,
|
||||
"angle": line.angle,
|
||||
"formula_region": script_range.formula_region,
|
||||
"stable_body_count": script_range.stable_body_count,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def apply_pdf_text_scripts(
|
||||
blocks: list[dict[str, Any]],
|
||||
lines: list[PDFTextScriptLine],
|
||||
page_size: tuple[float, float],
|
||||
*,
|
||||
materialized_diagnostics: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""把 Flash 上下标 sidecar 投影到最终自然语言 block,并清理公式私有区域。"""
|
||||
assignments = _assign_script_lines_to_blocks(blocks, lines, page_size)
|
||||
for block_index, block_lines in assignments.items():
|
||||
block = blocks[block_index]
|
||||
regions = [
|
||||
region
|
||||
for value in block.get("_inline_math_regions", [])
|
||||
if (region := _block_bbox_to_page_bbox(value, page_size)) is not None
|
||||
]
|
||||
projected_lines = []
|
||||
eligible_ranges: dict[int, tuple[PDFTextScriptRange, ...]] = {}
|
||||
for line in block_lines:
|
||||
retained = tuple(
|
||||
script_range
|
||||
for script_range in line.script_ranges
|
||||
if not _script_range_hits_late_formula_region(script_range, regions)
|
||||
)
|
||||
eligible_ranges[id(line)] = retained
|
||||
ranges = tuple(
|
||||
PDFTextStyleRange(
|
||||
script_range.start,
|
||||
script_range.end,
|
||||
(script_range.style,),
|
||||
)
|
||||
for script_range in retained
|
||||
)
|
||||
projected_lines.append(
|
||||
PDFTextStyleLine(
|
||||
bbox=line.bbox,
|
||||
text=line.text,
|
||||
style_ranges=ranges,
|
||||
source_index=line.source_index,
|
||||
)
|
||||
)
|
||||
projected_lines = _filter_line_styles_for_block(projected_lines, block.get("type"))
|
||||
content = block.get("content")
|
||||
if not isinstance(content, str) or not content:
|
||||
continue
|
||||
projected = _project_content_chars(content)
|
||||
combined_ranges = _merge_style_ranges(
|
||||
[
|
||||
*_match_style_ranges(projected, projected_lines),
|
||||
*(
|
||||
matched_range
|
||||
for projected_line in projected_lines
|
||||
for matched_range in _match_script_line_ranges(projected, projected_line)
|
||||
),
|
||||
]
|
||||
)
|
||||
if materialized_diagnostics is not None:
|
||||
for line in block_lines:
|
||||
_record_materialized_script_ranges(
|
||||
content,
|
||||
projected,
|
||||
combined_ranges,
|
||||
block_index,
|
||||
line,
|
||||
eligible_ranges.get(id(line), ()),
|
||||
materialized_diagnostics,
|
||||
)
|
||||
intervals = _raw_style_intervals(content, projected, combined_ranges)
|
||||
if intervals:
|
||||
existing = block.get(_PDF_STYLE_INTERVALS_KEY, [])
|
||||
block[_PDF_STYLE_INTERVALS_KEY] = _merge_raw_style_intervals(
|
||||
content,
|
||||
[
|
||||
*(interval for interval in existing if isinstance(interval, _RawStyleInterval)),
|
||||
*intervals,
|
||||
],
|
||||
)
|
||||
for block in blocks:
|
||||
block.pop("_inline_math_regions", None)
|
||||
|
||||
|
||||
def _parse_native_script_markup(content: str) -> _NativeScriptMarkup | None:
|
||||
"""严格解析 detector-owned 平坦 sup/sub 标签;畸形或嵌套结构返回 None。"""
|
||||
marker_ranges: list[tuple[int, int]] = []
|
||||
style_intervals: list[tuple[int, int, str]] = []
|
||||
active: tuple[str, int] | None = None
|
||||
for match in _NATIVE_SCRIPT_TAG_RE.finditer(content):
|
||||
marker_ranges.append((match.start(), match.end()))
|
||||
style = "superscript" if match.group("tag") == "sup" else "subscript"
|
||||
if match.group("closing") is None:
|
||||
if active is not None:
|
||||
return None
|
||||
active = (style, match.end())
|
||||
continue
|
||||
if active is None or active[0] != style:
|
||||
return None
|
||||
if active[1] < match.start():
|
||||
style_intervals.append((active[1], match.start(), style))
|
||||
active = None
|
||||
if active is not None or not marker_ranges:
|
||||
return None
|
||||
return _NativeScriptMarkup(
|
||||
marker_ranges=tuple(marker_ranges),
|
||||
style_intervals=tuple(style_intervals),
|
||||
)
|
||||
|
||||
|
||||
def materialize_pdf_inline_spans(blocks: list[dict[str, Any]]) -> None:
|
||||
"""把 PDF 原文、样式区间、链接区间和行内公式一次性物化为 Span。"""
|
||||
formula_pattern = re.compile(r"\\\((?P<latex>.*?)\\\)", re.DOTALL)
|
||||
for block in blocks:
|
||||
owns_native_script_markup = block.pop(PDF_NATIVE_SCRIPT_MARKUP_KEY, False) is True
|
||||
if block.get("type") not in _PDF_INLINE_SPAN_BLOCK_TYPES:
|
||||
continue
|
||||
content = block.get("content")
|
||||
link_intervals = block.pop(_PDF_LINK_INTERVALS_KEY, [])
|
||||
style_intervals = block.pop(_PDF_STYLE_INTERVALS_KEY, [])
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
native_scripts = _parse_native_script_markup(content) if owns_native_script_markup else None
|
||||
marker_ranges = native_scripts.marker_ranges if native_scripts is not None else ()
|
||||
script_intervals = native_scripts.style_intervals if native_scripts is not None else ()
|
||||
formulas = list(formula_pattern.finditer(content))
|
||||
boundaries = {0, len(content)}
|
||||
for interval in [*link_intervals, *style_intervals]:
|
||||
boundaries.update((interval.start, interval.end))
|
||||
for start, end in marker_ranges:
|
||||
boundaries.update((start, end))
|
||||
for start, end, _style in script_intervals:
|
||||
boundaries.update((start, end))
|
||||
for formula in formulas:
|
||||
boundaries.update((formula.start(), formula.end()))
|
||||
ordered = sorted(value for value in boundaries if 0 <= value <= len(content))
|
||||
spans: list[dict[str, Any]] = []
|
||||
for start, end in zip(ordered, ordered[1:]):
|
||||
if start >= end:
|
||||
continue
|
||||
if any(marker_start <= start and end <= marker_end for marker_start, marker_end in marker_ranges):
|
||||
continue
|
||||
formula = next((item for item in formulas if item.start() == start and item.end() == end), None)
|
||||
if formula is not None:
|
||||
append_equation_span(spans, formula.group("latex"))
|
||||
continue
|
||||
text = content[start:end]
|
||||
if not text:
|
||||
continue
|
||||
styles = _canonical_styles(
|
||||
style
|
||||
for interval in style_intervals
|
||||
if interval.start <= start and end <= interval.end
|
||||
for style in interval.styles
|
||||
)
|
||||
script_styles = tuple(
|
||||
style
|
||||
for interval_start, interval_end, style in script_intervals
|
||||
if interval_start <= start and end <= interval_end
|
||||
)
|
||||
combined_styles = tuple(dict.fromkeys((*styles, *script_styles)))
|
||||
link = next(
|
||||
(interval for interval in link_intervals if interval.start <= start and end <= interval.end),
|
||||
None,
|
||||
)
|
||||
if link is None:
|
||||
if combined_styles and text.strip():
|
||||
leading_length = len(text) - len(text.lstrip())
|
||||
trailing_length = len(text) - len(text.rstrip())
|
||||
core_end = len(text) - trailing_length if trailing_length else len(text)
|
||||
append_text_span(spans, text[:leading_length])
|
||||
append_text_span(spans, text[leading_length:core_end], combined_styles)
|
||||
append_text_span(spans, text[core_end:])
|
||||
else:
|
||||
append_text_span(spans, text, combined_styles)
|
||||
continue
|
||||
children: list[dict[str, Any]] = []
|
||||
append_text_span(children, text, (style for style in combined_styles if style != "underline"))
|
||||
if spans and spans[-1].get("type") == "hyperlink" and spans[-1].get("url") == link.target:
|
||||
existing = spans[-1].get("content")
|
||||
if isinstance(existing, list):
|
||||
extend_inline_spans(existing, children)
|
||||
continue
|
||||
append_hyperlink_span(spans, children, link.target)
|
||||
block["content"] = normalize_span_dicts(spans)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_pdf_text_links",
|
||||
"_append_raw_style_interval",
|
||||
"_merge_raw_style_intervals",
|
||||
"_raw_style_intervals",
|
||||
"apply_pdf_text_styles",
|
||||
"_script_range_hits_late_formula_region",
|
||||
"_record_materialized_script_ranges",
|
||||
"apply_pdf_text_scripts",
|
||||
"_parse_native_script_markup",
|
||||
"materialize_pdf_inline_spans",
|
||||
]
|
||||
@@ -0,0 +1,963 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""按公式区域和字符几何识别上下标证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
import unicodedata
|
||||
from typing import Any, Literal, Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import _rotate_bbox_to_upright
|
||||
from ..script_geometry import ScriptRole, classify_char_script_roles
|
||||
from .common import _coerce_bbox, _normalize_match_fragment, _ordered_line_chars
|
||||
from .types import (
|
||||
_PDF_SCRIPT_AUTHOR_MARKS,
|
||||
_PDF_SCRIPT_CITATION_BRACKETS,
|
||||
_PDF_SCRIPT_COMPACT_JOINERS,
|
||||
_PDF_SCRIPT_MATH_BASE_CHARS,
|
||||
_PDF_SCRIPT_SIGN_CHARS,
|
||||
_PDF_SCRIPT_SPACED_OPERATORS,
|
||||
_PDF_SCRIPT_TOKEN_CONNECTORS,
|
||||
_PDF_SCRIPT_TRAILING_MARKS,
|
||||
PDFTextScriptLine,
|
||||
PDFTextScriptRange,
|
||||
)
|
||||
|
||||
|
||||
def _rotate_origin_to_upright(
|
||||
origin: tuple[float, float],
|
||||
page_size: tuple[float, float],
|
||||
angle: int,
|
||||
) -> tuple[float, float]:
|
||||
"""把页面字符 origin 旋到当前 Flash 行的局部正向坐标。"""
|
||||
x, y = origin
|
||||
page_width, page_height = page_size
|
||||
if angle == 270:
|
||||
return page_height - y, x
|
||||
if angle == 90:
|
||||
return y, page_width - x
|
||||
if angle == 180:
|
||||
return page_width - x, page_height - y
|
||||
return origin
|
||||
|
||||
|
||||
def _bbox_center_inside_region(bbox: BBox, region: BBox) -> bool:
|
||||
"""判断字符 tight bbox 中心是否落入公式区域。"""
|
||||
center_x = (bbox[0] + bbox[2]) / 2
|
||||
center_y = (bbox[1] + bbox[3]) / 2
|
||||
return region[0] <= center_x <= region[2] and region[1] <= center_y <= region[3]
|
||||
|
||||
|
||||
def _script_region_memberships(
|
||||
chars: list[dict[str, Any]],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
regions: list[BBox],
|
||||
) -> list[int | None]:
|
||||
"""按页面 tight 中心把字符分配到公式区域,区域外返回 None。"""
|
||||
memberships: list[int | None] = []
|
||||
for char in chars:
|
||||
char_idx = char.get("char_idx")
|
||||
tight_bbox = tight_bboxes.get(char_idx) if isinstance(char_idx, int) else None
|
||||
region_index = None
|
||||
if tight_bbox is not None:
|
||||
region_index = next(
|
||||
(index for index, region in enumerate(regions) if _bbox_center_inside_region(tight_bbox, region)),
|
||||
None,
|
||||
)
|
||||
memberships.append(region_index)
|
||||
return memberships
|
||||
|
||||
|
||||
def _script_char_text(char: dict[str, Any]) -> str:
|
||||
"""返回单字符脚本判定使用的稳定文本。"""
|
||||
return str(char.get("char", ""))
|
||||
|
||||
|
||||
def _is_cjk_text(text: str) -> bool:
|
||||
"""判断单字符是否属于 CJK、日文假名或韩文书写系统。"""
|
||||
if len(text) != 1:
|
||||
return False
|
||||
codepoint = ord(text)
|
||||
return (
|
||||
0x3400 <= codepoint <= 0x4DBF
|
||||
or 0x4E00 <= codepoint <= 0x9FFF
|
||||
or 0xF900 <= codepoint <= 0xFAFF
|
||||
or 0x3040 <= codepoint <= 0x30FF
|
||||
or 0xAC00 <= codepoint <= 0xD7AF
|
||||
)
|
||||
|
||||
|
||||
def _is_math_identifier_char(text: str) -> bool:
|
||||
"""识别可与拉丁 base/index 共同组成数学 token 的字母数字字符。"""
|
||||
if len(text) != 1 or _is_cjk_text(text):
|
||||
return False
|
||||
if text.isascii():
|
||||
return text.isalnum()
|
||||
category = unicodedata.category(text)
|
||||
unicode_name = unicodedata.name(text, "")
|
||||
return (
|
||||
text in _PDF_SCRIPT_MATH_BASE_CHARS
|
||||
or "GREEK" in unicode_name
|
||||
or "MATHEMATICAL" in unicode_name
|
||||
or category in {"Lu", "Ll", "Lm"}
|
||||
)
|
||||
|
||||
|
||||
def _is_math_script_token_char(text: str) -> bool:
|
||||
"""判断字符是否属于可按 source order 重新锚定的数学 token。"""
|
||||
return _is_math_identifier_char(text) or text in _PDF_SCRIPT_TOKEN_CONNECTORS
|
||||
|
||||
|
||||
def _iter_math_script_tokens(chars: list[dict[str, Any]]) -> list[list[int]]:
|
||||
"""按连续数学 identifier 和连接符切分局部 token,并在 CJK 边界断开。"""
|
||||
tokens: list[list[int]] = []
|
||||
current: list[int] = []
|
||||
for index, char in enumerate(chars):
|
||||
if _is_math_script_token_char(_script_char_text(char)):
|
||||
current.append(index)
|
||||
continue
|
||||
if current:
|
||||
tokens.append(current)
|
||||
current = []
|
||||
if current:
|
||||
tokens.append(current)
|
||||
return tokens
|
||||
|
||||
|
||||
def _citation_script_indices(chars: list[dict[str, Any]], roles: list[ScriptRole]) -> set[int]:
|
||||
"""识别方括号引用区间,避免保守 token 规则删除数字引用。"""
|
||||
protected: set[int] = set()
|
||||
for start, char in enumerate(chars):
|
||||
closing = _PDF_SCRIPT_CITATION_BRACKETS.get(_script_char_text(char))
|
||||
if closing is None:
|
||||
continue
|
||||
for end in range(start + 1, min(len(chars), start + 16)):
|
||||
if _script_char_text(chars[end]) != closing:
|
||||
continue
|
||||
if any(roles[index] != "body" and _script_char_text(chars[index]).isalnum() for index in range(start + 1, end)):
|
||||
protected.update(range(start, end + 1))
|
||||
break
|
||||
return protected
|
||||
|
||||
|
||||
def _token_origin(
|
||||
char: dict[str, Any],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> float | None:
|
||||
"""读取 token 字符的局部正向 origin y。"""
|
||||
char_idx = char.get("char_idx")
|
||||
origin = origins.get(char_idx) if isinstance(char_idx, int) else None
|
||||
return float(origin[1]) if origin is not None else None
|
||||
|
||||
|
||||
def _token_tight_height(
|
||||
char: dict[str, Any],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
) -> float:
|
||||
"""读取 token 字符的局部正向 tight 高度。"""
|
||||
char_idx = char.get("char_idx")
|
||||
bbox = tight_bboxes.get(char_idx) if isinstance(char_idx, int) else None
|
||||
return max(0.0, bbox[3] - bbox[1]) if bbox is not None else 0.0
|
||||
|
||||
|
||||
def _has_adjacent_math_base(
|
||||
chars: list[dict[str, Any]],
|
||||
index: int,
|
||||
roles: list[ScriptRole],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> bool:
|
||||
"""判断孤立索引左侧是否存在紧邻且位移明确的非 CJK 数学 base。"""
|
||||
if index <= 0 or roles[index - 1] != "body":
|
||||
return False
|
||||
base_text = _script_char_text(chars[index - 1])
|
||||
if not _is_math_identifier_char(base_text):
|
||||
return False
|
||||
base_origin = _token_origin(chars[index - 1], origins)
|
||||
script_origin = _token_origin(chars[index], origins)
|
||||
base_height = _token_tight_height(chars[index - 1], tight_bboxes)
|
||||
base_idx = chars[index - 1].get("char_idx")
|
||||
script_idx = chars[index].get("char_idx")
|
||||
base_bbox = tight_bboxes.get(base_idx) if isinstance(base_idx, int) else None
|
||||
script_bbox = tight_bboxes.get(script_idx) if isinstance(script_idx, int) else None
|
||||
if base_origin is None or script_origin is None or base_bbox is None or script_bbox is None:
|
||||
return False
|
||||
return (
|
||||
_bbox_axis_overlap(base_bbox, script_bbox, axis="y") > 0
|
||||
or _horizontal_gap_between_bboxes(base_bbox, script_bbox) <= max(2.0, 0.5 * base_height)
|
||||
) and abs(script_origin - base_origin) >= max(0.35, 0.08 * base_height)
|
||||
|
||||
|
||||
def _horizontal_gap_between_bboxes(first: BBox, second: BBox) -> float:
|
||||
"""返回两个 tight bbox 的水平间隙。"""
|
||||
return max(0.0, first[0] - second[2], second[0] - first[2])
|
||||
|
||||
|
||||
def _token_split_position(
|
||||
chars: list[dict[str, Any]],
|
||||
token: list[int],
|
||||
roles: list[ScriptRole],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> int | None:
|
||||
"""用最左 origin 簇和显式连接符确定 base 与索引的分界。"""
|
||||
alnum_positions = [index for index in token if _is_math_identifier_char(_script_char_text(chars[index]))]
|
||||
if len(alnum_positions) < 2:
|
||||
return None
|
||||
first = alnum_positions[0]
|
||||
first_origin = _token_origin(chars[first], origins)
|
||||
first_height = _token_tight_height(chars[first], tight_bboxes)
|
||||
origin_tolerance = max(0.35, 0.06 * first_height)
|
||||
leading_connectors = [
|
||||
index for index in token if index < first and _script_char_text(chars[index]) in _PDF_SCRIPT_TOKEN_CONNECTORS
|
||||
]
|
||||
if leading_connectors:
|
||||
return alnum_positions[1]
|
||||
for position in alnum_positions[1:]:
|
||||
if any(_script_char_text(chars[index]) in _PDF_SCRIPT_TOKEN_CONNECTORS for index in range(first + 1, position)):
|
||||
return position
|
||||
origin = _token_origin(chars[position], origins)
|
||||
if first_origin is not None and origin is not None and abs(origin - first_origin) > origin_tolerance:
|
||||
return position
|
||||
scripted_positions = [position for position in alnum_positions[1:] if roles[position] != "body"]
|
||||
if roles[first] == "body" and scripted_positions:
|
||||
return scripted_positions[0]
|
||||
return None
|
||||
|
||||
|
||||
def _script_geometry_is_aligned(
|
||||
chars: list[dict[str, Any]],
|
||||
first: int,
|
||||
second: int,
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> bool:
|
||||
"""判断两个字符是否处在同一 displaced baseline 上。"""
|
||||
first_origin = _token_origin(chars[first], origins)
|
||||
second_origin = _token_origin(chars[second], origins)
|
||||
first_height = _token_tight_height(chars[first], tight_bboxes)
|
||||
second_height = _token_tight_height(chars[second], tight_bboxes)
|
||||
first_idx = chars[first].get("char_idx")
|
||||
second_idx = chars[second].get("char_idx")
|
||||
first_bbox = tight_bboxes.get(first_idx) if isinstance(first_idx, int) else None
|
||||
second_bbox = tight_bboxes.get(second_idx) if isinstance(second_idx, int) else None
|
||||
if first_origin is None or second_origin is None or first_bbox is None or second_bbox is None:
|
||||
return False
|
||||
scale = max(first_height, second_height, 1.0)
|
||||
first_center = (first_bbox[1] + first_bbox[3]) / 2
|
||||
second_center = (second_bbox[1] + second_bbox[3]) / 2
|
||||
return abs(first_origin - second_origin) <= max(0.35, 0.06 * scale) and abs(first_center - second_center) <= max(
|
||||
0.75,
|
||||
0.3 * scale,
|
||||
)
|
||||
|
||||
|
||||
def _nearest_nonspace_index(
|
||||
chars: list[dict[str, Any]],
|
||||
start: int,
|
||||
step: Literal[-1, 1],
|
||||
) -> int | None:
|
||||
"""从指定位置向前或向后查找最近的非空白字符。"""
|
||||
index = start + step
|
||||
while 0 <= index < len(chars):
|
||||
if not _script_char_text(chars[index]).isspace():
|
||||
return index
|
||||
index += step
|
||||
return None
|
||||
|
||||
|
||||
def _close_spaced_script_operators(
|
||||
chars: list[dict[str, Any]],
|
||||
roles: list[ScriptRole],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> None:
|
||||
"""跨少量 PDF 空格闭合同基线的 `1 - x` 一类角标 run。"""
|
||||
for seed, role in enumerate(list(roles)):
|
||||
if role == "body" or not _is_math_identifier_char(_script_char_text(chars[seed])):
|
||||
continue
|
||||
operator_index = _nearest_nonspace_index(chars, seed, 1)
|
||||
if operator_index is None or operator_index - seed > 3:
|
||||
continue
|
||||
if _script_char_text(chars[operator_index]) not in _PDF_SCRIPT_SPACED_OPERATORS:
|
||||
continue
|
||||
target = _nearest_nonspace_index(chars, operator_index, 1)
|
||||
if target is None or target - operator_index > 3:
|
||||
continue
|
||||
if not _is_math_identifier_char(_script_char_text(chars[target])):
|
||||
continue
|
||||
if not _script_geometry_is_aligned(chars, seed, operator_index, tight_bboxes, origins):
|
||||
continue
|
||||
if not _script_geometry_is_aligned(chars, seed, target, tight_bboxes, origins):
|
||||
continue
|
||||
roles[operator_index] = role
|
||||
roles[target] = role
|
||||
|
||||
|
||||
def _close_compact_aligned_script_suffixes(
|
||||
chars: list[dict[str, Any]],
|
||||
raw_roles: list[ScriptRole],
|
||||
refined_roles: list[ScriptRole],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> None:
|
||||
"""把已有可信角标 run 后同基线的紧凑连字符后缀整体闭合。"""
|
||||
for joiner_index in range(1, len(chars) - 1):
|
||||
if _script_char_text(chars[joiner_index]) not in _PDF_SCRIPT_COMPACT_JOINERS:
|
||||
continue
|
||||
left_seed = joiner_index - 1
|
||||
role = refined_roles[left_seed]
|
||||
if role == "body" or not _is_math_identifier_char(_script_char_text(chars[left_seed])):
|
||||
continue
|
||||
|
||||
left_start = left_seed
|
||||
while (
|
||||
left_start > 0
|
||||
and refined_roles[left_start - 1] == role
|
||||
and _is_math_identifier_char(_script_char_text(chars[left_start - 1]))
|
||||
):
|
||||
left_start -= 1
|
||||
if left_seed - left_start + 1 < 2:
|
||||
continue
|
||||
anchor_index = left_start - 1
|
||||
if (
|
||||
anchor_index < 0
|
||||
or refined_roles[anchor_index] != "body"
|
||||
or not _is_math_identifier_char(_script_char_text(chars[anchor_index]))
|
||||
):
|
||||
continue
|
||||
|
||||
suffix_start = joiner_index + 1
|
||||
suffix_end = suffix_start
|
||||
while suffix_end < len(chars) and _is_math_identifier_char(_script_char_text(chars[suffix_end])):
|
||||
suffix_end += 1
|
||||
if suffix_end - suffix_start < 2:
|
||||
continue
|
||||
restored_indices = range(joiner_index, suffix_end)
|
||||
if any(raw_roles[index] != role for index in restored_indices):
|
||||
continue
|
||||
if not _script_geometry_is_aligned(chars, left_seed, joiner_index, tight_bboxes, origins):
|
||||
continue
|
||||
if any(
|
||||
not _script_geometry_is_aligned(chars, left_seed, index, tight_bboxes, origins)
|
||||
for index in range(suffix_start, suffix_end)
|
||||
):
|
||||
continue
|
||||
refined_roles[joiner_index:suffix_end] = [role] * (suffix_end - joiner_index)
|
||||
|
||||
|
||||
def _protected_subscript_indices(
|
||||
chars: list[dict[str, Any]],
|
||||
roles: list[ScriptRole],
|
||||
tokens: list[list[int]],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> set[int]:
|
||||
"""找出拥有内部 base 或与其同基线连通的下标字符。"""
|
||||
protected: set[int] = set()
|
||||
for token in tokens:
|
||||
for position, index in enumerate(token):
|
||||
if roles[index] != "sub" or not _is_math_identifier_char(_script_char_text(chars[index])):
|
||||
continue
|
||||
if any(
|
||||
earlier < index and roles[earlier] == "body" and _is_math_identifier_char(_script_char_text(chars[earlier]))
|
||||
for earlier in token[:position]
|
||||
) or _has_adjacent_math_base(chars, index, roles, tight_bboxes, origins):
|
||||
protected.add(index)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for index, role in enumerate(roles):
|
||||
if role != "sub" or index in protected or not _is_math_identifier_char(_script_char_text(chars[index])):
|
||||
continue
|
||||
for seed in tuple(protected):
|
||||
start, end = sorted((seed, index))
|
||||
if end - start > 5 or not _script_geometry_is_aligned(chars, seed, index, tight_bboxes, origins):
|
||||
continue
|
||||
if all(
|
||||
_script_char_text(chars[bridge]).isspace()
|
||||
or _script_char_text(chars[bridge]) in _PDF_SCRIPT_TOKEN_CONNECTORS
|
||||
or _script_char_text(chars[bridge]) in _PDF_SCRIPT_SIGN_CHARS
|
||||
or _script_char_text(chars[bridge]) == "."
|
||||
for bridge in range(start + 1, end)
|
||||
):
|
||||
protected.add(index)
|
||||
changed = True
|
||||
break
|
||||
return protected
|
||||
|
||||
|
||||
def _refine_math_script_tokens(
|
||||
chars: list[dict[str, Any]],
|
||||
roles: list[ScriptRole],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
*,
|
||||
formula_region: bool,
|
||||
) -> list[ScriptRole]:
|
||||
"""以最左稳定簇保护 base,并对弱单字符和复杂未分段 token 保守拒识。"""
|
||||
refined = list(roles)
|
||||
citation_indices = _citation_script_indices(chars, refined)
|
||||
complex_unsegmented_token = False
|
||||
tokens = _iter_math_script_tokens(chars)
|
||||
token_alnum_positions = {
|
||||
tuple(token): [index for index in token if _is_math_identifier_char(_script_char_text(chars[index]))]
|
||||
for token in tokens
|
||||
}
|
||||
token_splits = {
|
||||
tuple(token): _token_split_position(
|
||||
chars,
|
||||
token,
|
||||
refined,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
)
|
||||
for token in tokens
|
||||
}
|
||||
token_families: dict[str, list[tuple[int, ...]]] = {}
|
||||
for token in tokens:
|
||||
key = tuple(token)
|
||||
alnum_positions = token_alnum_positions[key]
|
||||
if len(alnum_positions) >= 2:
|
||||
token_families.setdefault(_script_char_text(chars[alnum_positions[0]]), []).append(key)
|
||||
trusted_family_bases = {
|
||||
base
|
||||
for base, members in token_families.items()
|
||||
if len(members) >= 3
|
||||
or any(token_splits[member] is not None for member in members)
|
||||
or any(any(_script_char_text(chars[index]) in _PDF_SCRIPT_TOKEN_CONNECTORS for index in member) for member in members)
|
||||
}
|
||||
for token in tokens:
|
||||
if any(index in citation_indices for index in token):
|
||||
continue
|
||||
token_key = tuple(token)
|
||||
alnum_positions = token_alnum_positions[token_key]
|
||||
if not alnum_positions or not any(refined[index] != "body" for index in token):
|
||||
continue
|
||||
token_roles = {refined[index] for index in token if refined[index] != "body"}
|
||||
if token_roles == {"sup", "sub"}:
|
||||
complex_unsegmented_token = True
|
||||
if len(alnum_positions) == 1:
|
||||
continue
|
||||
first_position = alnum_positions[0]
|
||||
suffix_positions = alnum_positions[1:]
|
||||
if (
|
||||
refined[first_position] == "sup"
|
||||
and all(refined[index] == "body" for index in suffix_positions)
|
||||
and len(suffix_positions) >= 2
|
||||
and all(_script_char_text(chars[index]).isalpha() for index in suffix_positions)
|
||||
):
|
||||
continue
|
||||
split_position = token_splits[token_key]
|
||||
if split_position is None and _script_char_text(chars[alnum_positions[0]]) in trusted_family_bases:
|
||||
split_position = alnum_positions[1]
|
||||
if split_position is None:
|
||||
if all(refined[index] != "body" for index in alnum_positions):
|
||||
for index in token:
|
||||
refined[index] = "body"
|
||||
continue
|
||||
base_positions = [index for index in alnum_positions if index < split_position]
|
||||
base_origins = [origin for index in base_positions if (origin := _token_origin(chars[index], origins)) is not None]
|
||||
base_heights = [_token_tight_height(chars[index], tight_bboxes) for index in base_positions]
|
||||
base_origin = statistics.median(base_origins) if base_origins else None
|
||||
base_height = statistics.median([height for height in base_heights if height > 0]) if any(base_heights) else 0.0
|
||||
for index in token:
|
||||
if index < split_position or _script_char_text(chars[index]) in _PDF_SCRIPT_TOKEN_CONNECTORS:
|
||||
refined[index] = "body"
|
||||
continue
|
||||
text = _script_char_text(chars[index])
|
||||
if not _is_math_identifier_char(text) or refined[index] != "body":
|
||||
continue
|
||||
origin = _token_origin(chars[index], origins)
|
||||
if base_origin is None or origin is None:
|
||||
continue
|
||||
shift = origin - base_origin
|
||||
if abs(shift) >= max(0.35, 0.08 * base_height):
|
||||
refined[index] = "sub" if shift > 0 else "sup"
|
||||
_close_spaced_script_operators(
|
||||
chars,
|
||||
refined,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
)
|
||||
if complex_unsegmented_token and not formula_region:
|
||||
for index in range(len(refined)):
|
||||
if index not in citation_indices:
|
||||
refined[index] = "body"
|
||||
scripted_alnum = [
|
||||
index for index, role in enumerate(refined) if role != "body" and _script_char_text(chars[index]).isalnum()
|
||||
]
|
||||
if not formula_region and len(scripted_alnum) >= 2 and any(_script_char_text(char) in {"∑", "∫"} for char in chars):
|
||||
for index in range(len(refined)):
|
||||
if index not in citation_indices:
|
||||
refined[index] = "body"
|
||||
has_compact_multiply = any(
|
||||
_script_char_text(char) == "×"
|
||||
and 0 < index < len(chars) - 1
|
||||
and not _script_char_text(chars[index - 1]).isspace()
|
||||
and not _script_char_text(chars[index + 1]).isspace()
|
||||
for index, char in enumerate(chars)
|
||||
)
|
||||
if not formula_region and len(scripted_alnum) >= 2 and has_compact_multiply:
|
||||
for index in range(len(refined)):
|
||||
if index not in citation_indices:
|
||||
refined[index] = "body"
|
||||
if not formula_region:
|
||||
for operator_index, char in enumerate(chars):
|
||||
operator = _script_char_text(char)
|
||||
nearby = [candidate for candidate in scripted_alnum if abs(candidate - operator_index) <= 5]
|
||||
if (
|
||||
operator in {"/", "⁄"}
|
||||
and any(candidate < operator_index for candidate in nearby)
|
||||
and any(candidate > operator_index for candidate in nearby)
|
||||
):
|
||||
for candidate in nearby:
|
||||
if candidate not in citation_indices:
|
||||
refined[candidate] = "body"
|
||||
protected_subscripts = _protected_subscript_indices(
|
||||
chars,
|
||||
refined,
|
||||
tokens,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
)
|
||||
for index, role in enumerate(list(refined)):
|
||||
if (
|
||||
role == "sub"
|
||||
and index not in citation_indices
|
||||
and index not in protected_subscripts
|
||||
and _is_math_identifier_char(_script_char_text(chars[index]))
|
||||
):
|
||||
refined[index] = "body"
|
||||
for index, role in enumerate(list(refined)):
|
||||
if role == "body" or index in citation_indices:
|
||||
continue
|
||||
text = _script_char_text(chars[index])
|
||||
if text.isalnum() or text in _PDF_SCRIPT_AUTHOR_MARKS:
|
||||
continue
|
||||
if text in {",", ","} and all(
|
||||
0 <= neighbor < len(refined)
|
||||
and refined[neighbor] == role
|
||||
and (_script_char_text(chars[neighbor]).isalnum() or _script_char_text(chars[neighbor]) in _PDF_SCRIPT_AUTHOR_MARKS)
|
||||
for neighbor in (index - 1, index + 1)
|
||||
):
|
||||
continue
|
||||
if text == "." and all(
|
||||
0 <= neighbor < len(refined) and refined[neighbor] == role and _script_char_text(chars[neighbor]).isdigit()
|
||||
for neighbor in (index - 1, index + 1)
|
||||
):
|
||||
continue
|
||||
if text in _PDF_SCRIPT_SIGN_CHARS:
|
||||
sign_neighbors = [
|
||||
neighbor
|
||||
for step in (-1, 1)
|
||||
if (neighbor := _nearest_nonspace_index(chars, index, step)) is not None
|
||||
and abs(neighbor - index) <= 3
|
||||
and refined[neighbor] == role
|
||||
]
|
||||
if sign_neighbors and any(_script_char_text(chars[neighbor]).isdigit() for neighbor in sign_neighbors):
|
||||
continue
|
||||
if text in _PDF_SCRIPT_TRAILING_MARKS:
|
||||
previous = _nearest_nonspace_index(chars, index, -1)
|
||||
body_prefix = previous - 1 if previous is not None else -1
|
||||
if (
|
||||
role == "sup"
|
||||
and previous is not None
|
||||
and index - previous == 1
|
||||
and body_prefix >= 0
|
||||
and refined[body_prefix] == "body"
|
||||
and _script_char_text(chars[body_prefix]).isalpha()
|
||||
and refined[previous] == role
|
||||
and roles[index] == role
|
||||
and _script_char_text(chars[previous]).isalpha()
|
||||
and _script_geometry_is_aligned(chars, previous, index, tight_bboxes, origins)
|
||||
):
|
||||
continue
|
||||
refined[index] = "body"
|
||||
if not formula_region:
|
||||
_close_compact_aligned_script_suffixes(
|
||||
chars,
|
||||
roles,
|
||||
refined,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
)
|
||||
return refined
|
||||
|
||||
|
||||
def _bbox_axis_overlap(first: BBox, second: BBox, *, axis: Literal["x", "y"]) -> float:
|
||||
"""返回两个 bbox 在指定轴上的绝对重叠长度。"""
|
||||
start, end = (0, 2) if axis == "x" else (1, 3)
|
||||
return max(0.0, min(first[end], second[end]) - max(first[start], second[start]))
|
||||
|
||||
|
||||
def _fraction_member_indices(
|
||||
page_size: tuple[float, float],
|
||||
all_chars: list[dict[str, Any]],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
drawing_lines: Sequence[Any],
|
||||
angle: int,
|
||||
) -> set[int]:
|
||||
"""按页面方向一次识别分数线两侧的上下叠字,供复杂分式整块拒识。"""
|
||||
if not all_chars or not drawing_lines:
|
||||
return set()
|
||||
local_chars: list[tuple[int, str, BBox]] = []
|
||||
local_heights = []
|
||||
for char in all_chars:
|
||||
char_idx = char.get("char_idx")
|
||||
text = _script_char_text(char)
|
||||
bbox = tight_bboxes.get(char_idx) if isinstance(char_idx, int) else None
|
||||
if not isinstance(char_idx, int) or bbox is None or not text.isprintable() or text.isspace():
|
||||
continue
|
||||
local_bbox = _rotate_bbox_to_upright(bbox, page_size, angle)
|
||||
local_chars.append((char_idx, text, local_bbox))
|
||||
local_heights.append(local_bbox[3] - local_bbox[1])
|
||||
scale = statistics.median([height for height in local_heights if height > 0]) if local_heights else 8.0
|
||||
members: set[int] = set()
|
||||
for drawing in drawing_lines:
|
||||
raw_bbox = _coerce_bbox(getattr(drawing, "bbox", drawing))
|
||||
if raw_bbox is None:
|
||||
continue
|
||||
local_rule = _rotate_bbox_to_upright(raw_bbox, page_size, angle)
|
||||
width = local_rule[2] - local_rule[0]
|
||||
height = local_rule[3] - local_rule[1]
|
||||
if width < max(2.0, 0.45 * scale) or width > 12.0 * scale or height > max(1.25, 0.25 * scale):
|
||||
continue
|
||||
rule_y = (local_rule[1] + local_rule[3]) / 2
|
||||
aligned = [
|
||||
(char_idx, bbox)
|
||||
for char_idx, text, bbox in local_chars
|
||||
if text.isalnum()
|
||||
and abs((bbox[1] + bbox[3]) / 2 - rule_y) <= 2.25 * scale
|
||||
and (
|
||||
_bbox_axis_overlap(bbox, local_rule, axis="x") > 0
|
||||
or local_rule[0] - 0.25 * scale <= (bbox[0] + bbox[2]) / 2 <= local_rule[2] + 0.25 * scale
|
||||
)
|
||||
]
|
||||
above = [
|
||||
(char_idx, bbox)
|
||||
for char_idx, bbox in aligned
|
||||
if bbox[3] <= rule_y + 0.2 * scale and rule_y - bbox[3] <= 1.75 * scale
|
||||
]
|
||||
below = [
|
||||
(char_idx, bbox)
|
||||
for char_idx, bbox in aligned
|
||||
if bbox[1] >= rule_y - 0.2 * scale and bbox[1] - rule_y <= 1.75 * scale
|
||||
]
|
||||
# 超过局部公式尺度的长横线更像脚注/段落分隔线,不用于分式成员抑制。
|
||||
if width > 8.0 * scale:
|
||||
continue
|
||||
if above and below:
|
||||
members.update(char_idx for char_idx, _bbox in above)
|
||||
members.update(char_idx for char_idx, _bbox in below)
|
||||
return members
|
||||
|
||||
|
||||
def _strong_structural_script_roles(
|
||||
chars: list[dict[str, Any]],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
) -> dict[int, ScriptRole]:
|
||||
"""提取可在恢复公式区域中保留的引用和邻接 base 强脚本证据。"""
|
||||
|
||||
roles = classify_char_script_roles(
|
||||
chars,
|
||||
tight_bboxes=tight_bboxes,
|
||||
origins=origins,
|
||||
)
|
||||
strong_roles: dict[int, ScriptRole] = {}
|
||||
for index in _citation_script_indices(chars, roles):
|
||||
if roles[index] != "body":
|
||||
strong_roles[index] = roles[index]
|
||||
for index, role in enumerate(roles):
|
||||
if role != "sup" or not _is_math_identifier_char(_script_char_text(chars[index])):
|
||||
continue
|
||||
base_height = _token_tight_height(chars[index - 1], tight_bboxes) if index > 0 else 0.0
|
||||
script_height = _token_tight_height(chars[index], tight_bboxes)
|
||||
if base_height <= 0 or script_height > 0.8 * base_height:
|
||||
continue
|
||||
next_index = _nearest_nonspace_index(chars, index, 1)
|
||||
if next_index is not None and _is_math_script_token_char(_script_char_text(chars[next_index])):
|
||||
continue
|
||||
if _has_adjacent_math_base(
|
||||
chars,
|
||||
index,
|
||||
roles,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
):
|
||||
strong_roles[index] = role
|
||||
return strong_roles
|
||||
|
||||
|
||||
def _classify_script_runs(
|
||||
chars: list[dict[str, Any]],
|
||||
local_tight_bboxes: dict[int, BBox],
|
||||
local_origins: dict[int, tuple[float, float]],
|
||||
memberships: list[int | None],
|
||||
) -> tuple[list[str], list[int], list[bool]]:
|
||||
"""按公式区域边界分段分类,并要求公式段内部存在稳定 body。"""
|
||||
roles: list[ScriptRole] = ["body"] * len(chars)
|
||||
body_counts = [0] * len(chars)
|
||||
formula_flags = [False] * len(chars)
|
||||
start = 0
|
||||
while start < len(chars):
|
||||
membership = memberships[start]
|
||||
end = start + 1
|
||||
while end < len(chars) and memberships[end] == membership:
|
||||
end += 1
|
||||
run_chars = chars[start:end]
|
||||
run_indices = {int(char["char_idx"]) for char in run_chars if isinstance(char.get("char_idx"), int)}
|
||||
run_roles = classify_char_script_roles(
|
||||
run_chars,
|
||||
tight_bboxes={index: local_tight_bboxes[index] for index in run_indices if index in local_tight_bboxes},
|
||||
origins={index: local_origins[index] for index in run_indices if index in local_origins},
|
||||
)
|
||||
run_roles = _refine_math_script_tokens(
|
||||
run_chars,
|
||||
run_roles,
|
||||
local_tight_bboxes,
|
||||
local_origins,
|
||||
formula_region=membership is not None,
|
||||
)
|
||||
visible = [
|
||||
index
|
||||
for index, char in enumerate(run_chars)
|
||||
if str(char.get("char", "")).isprintable() and not str(char.get("char", "")).isspace()
|
||||
]
|
||||
body_count = sum(run_roles[index] == "body" and str(run_chars[index].get("char", "")).isalnum() for index in visible)
|
||||
marked_count = sum(run_roles[index] != "body" for index in visible)
|
||||
body_tight_heights = [
|
||||
local_tight_bboxes[int(run_chars[index]["char_idx"])][3] - local_tight_bboxes[int(run_chars[index]["char_idx"])][1]
|
||||
for index in visible
|
||||
if run_roles[index] == "body"
|
||||
and isinstance(run_chars[index].get("char_idx"), int)
|
||||
and int(run_chars[index]["char_idx"]) in local_tight_bboxes
|
||||
]
|
||||
script_tight_heights = [
|
||||
local_tight_bboxes[int(run_chars[index]["char_idx"])][3] - local_tight_bboxes[int(run_chars[index]["char_idx"])][1]
|
||||
for index in visible
|
||||
if run_roles[index] != "body"
|
||||
and isinstance(run_chars[index].get("char_idx"), int)
|
||||
and int(run_chars[index]["char_idx"]) in local_tight_bboxes
|
||||
]
|
||||
stable_formula_body = (
|
||||
body_count > 0
|
||||
and bool(body_tight_heights)
|
||||
and (not script_tight_heights or max(body_tight_heights) >= 1.1 * max(script_tight_heights))
|
||||
)
|
||||
if membership is not None and (not stable_formula_body or marked_count >= len(visible)):
|
||||
run_roles = ["body"] * len(run_chars)
|
||||
for offset, role in enumerate(run_roles, start=start):
|
||||
roles[offset] = role
|
||||
body_counts[offset] = body_count
|
||||
formula_flags[offset] = membership is not None
|
||||
start = end
|
||||
return roles, body_counts, formula_flags
|
||||
|
||||
|
||||
def _script_line_char_roles(
|
||||
line: Any,
|
||||
page_size: tuple[float, float],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
fraction_members: set[int],
|
||||
) -> tuple[list[dict[str, Any]], list[ScriptRole], list[int], list[bool]]:
|
||||
"""按正文同款公式分段返回原字符及其上下标角色。"""
|
||||
|
||||
chars = _ordered_line_chars(line)
|
||||
if not chars:
|
||||
return [], [], [], []
|
||||
angle = int(getattr(line, "angle", 0) or 0) % 360
|
||||
local_chars: list[dict[str, Any]] = []
|
||||
local_tight_bboxes: dict[int, BBox] = {}
|
||||
local_origins: dict[int, tuple[float, float]] = {}
|
||||
for char in chars:
|
||||
local_char = dict(char)
|
||||
bbox = _coerce_bbox(char.get("bbox"))
|
||||
if bbox is not None:
|
||||
local_char["bbox"] = _rotate_bbox_to_upright(bbox, page_size, angle)
|
||||
local_chars.append(local_char)
|
||||
char_idx = char.get("char_idx")
|
||||
if not isinstance(char_idx, int):
|
||||
continue
|
||||
tight_bbox = tight_bboxes.get(char_idx)
|
||||
if tight_bbox is not None:
|
||||
local_tight_bboxes[char_idx] = _rotate_bbox_to_upright(
|
||||
tight_bbox,
|
||||
page_size,
|
||||
angle,
|
||||
)
|
||||
origin = origins.get(char_idx)
|
||||
if origin is not None:
|
||||
local_origins[char_idx] = _rotate_origin_to_upright(
|
||||
origin,
|
||||
page_size,
|
||||
angle,
|
||||
)
|
||||
regions = [bbox for value in getattr(line, "inline_math_regions", []) if (bbox := _coerce_bbox(value)) is not None]
|
||||
memberships = _script_region_memberships(chars, tight_bboxes, regions)
|
||||
roles, body_counts, formula_flags = _classify_script_runs(
|
||||
local_chars,
|
||||
local_tight_bboxes,
|
||||
local_origins,
|
||||
memberships,
|
||||
)
|
||||
if bool(getattr(line, "compact_formula_cluster", False)) or (
|
||||
bool(getattr(line, "restored_inline_cluster", False)) and bool(regions)
|
||||
):
|
||||
strong_structural_roles = _strong_structural_script_roles(
|
||||
local_chars,
|
||||
local_tight_bboxes,
|
||||
local_origins,
|
||||
)
|
||||
roles = [strong_structural_roles.get(index, "body") for index in range(len(roles))]
|
||||
for index, char in enumerate(chars):
|
||||
char_idx = char.get("char_idx")
|
||||
if isinstance(char_idx, int) and char_idx in fraction_members:
|
||||
roles[index] = "body"
|
||||
return chars, roles, body_counts, formula_flags
|
||||
|
||||
|
||||
def _script_line_payload(
|
||||
line: Any,
|
||||
page_size: tuple[float, float],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
fraction_members: set[int],
|
||||
) -> PDFTextScriptLine | None:
|
||||
"""把 Flash 行转换为公式分段后的紧凑上下标 sidecar。"""
|
||||
|
||||
chars, roles, body_counts, formula_flags = _script_line_char_roles(
|
||||
line,
|
||||
page_size,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
fraction_members,
|
||||
)
|
||||
if not chars:
|
||||
return None
|
||||
angle = int(getattr(line, "angle", 0) or 0) % 360
|
||||
compact_parts: list[str] = []
|
||||
compact_roles: list[str] = []
|
||||
compact_bboxes: list[BBox | None] = []
|
||||
compact_body_counts: list[int] = []
|
||||
compact_formula_flags: list[bool] = []
|
||||
for index, char in enumerate(chars):
|
||||
fragment = _normalize_match_fragment(char.get("char"))
|
||||
if not fragment:
|
||||
continue
|
||||
compact_parts.append(fragment)
|
||||
char_idx = char.get("char_idx")
|
||||
page_tight_bbox = tight_bboxes.get(char_idx) if isinstance(char_idx, int) else None
|
||||
compact_roles.extend([roles[index]] * len(fragment))
|
||||
compact_bboxes.extend([page_tight_bbox] * len(fragment))
|
||||
compact_body_counts.extend([body_counts[index]] * len(fragment))
|
||||
compact_formula_flags.extend([formula_flags[index]] * len(fragment))
|
||||
text = "".join(compact_parts)
|
||||
if not text:
|
||||
return None
|
||||
ranges: list[PDFTextScriptRange] = []
|
||||
start = 0
|
||||
while start < len(compact_roles):
|
||||
role = compact_roles[start]
|
||||
end = start + 1
|
||||
while end < len(compact_roles) and compact_roles[end] == role:
|
||||
end += 1
|
||||
if role in {"sup", "sub"}:
|
||||
range_bboxes = [bbox for bbox in compact_bboxes[start:end] if bbox is not None]
|
||||
if range_bboxes:
|
||||
page_bbox = (
|
||||
min(bbox[0] for bbox in range_bboxes),
|
||||
min(bbox[1] for bbox in range_bboxes),
|
||||
max(bbox[2] for bbox in range_bboxes),
|
||||
max(bbox[3] for bbox in range_bboxes),
|
||||
)
|
||||
ranges.append(
|
||||
PDFTextScriptRange(
|
||||
start=start,
|
||||
end=end,
|
||||
style="superscript" if role == "sup" else "subscript",
|
||||
bbox=page_bbox,
|
||||
stable_body_count=max(compact_body_counts[start:end], default=0),
|
||||
formula_region=any(compact_formula_flags[start:end]),
|
||||
)
|
||||
)
|
||||
start = end
|
||||
return PDFTextScriptLine(
|
||||
bbox=getattr(line, "bbox"),
|
||||
text=text,
|
||||
script_ranges=tuple(ranges),
|
||||
source_index=int(getattr(line, "source_index", 0) or 0),
|
||||
angle=angle,
|
||||
)
|
||||
|
||||
|
||||
def detect_pdf_text_script_lines(
|
||||
lines: list[Any],
|
||||
page_size: tuple[float, float],
|
||||
tight_bboxes: dict[int, BBox],
|
||||
origins: dict[int, tuple[float, float]],
|
||||
*,
|
||||
all_chars: list[dict[str, Any]] | None = None,
|
||||
drawing_lines: Sequence[Any] | None = None,
|
||||
) -> list[PDFTextScriptLine]:
|
||||
"""检测 Flash 剩余自然文本行中的上下标候选。"""
|
||||
resolved_chars = all_chars or []
|
||||
resolved_drawings = drawing_lines or []
|
||||
fraction_members_by_angle = {
|
||||
angle: _fraction_member_indices(
|
||||
page_size,
|
||||
resolved_chars,
|
||||
tight_bboxes,
|
||||
resolved_drawings,
|
||||
angle,
|
||||
)
|
||||
for angle in {int(getattr(line, "angle", 0) or 0) % 360 for line in lines}
|
||||
}
|
||||
return [
|
||||
payload
|
||||
for line in lines
|
||||
if (
|
||||
payload := _script_line_payload(
|
||||
line,
|
||||
page_size,
|
||||
tight_bboxes,
|
||||
origins,
|
||||
fraction_members_by_angle[int(getattr(line, "angle", 0) or 0) % 360],
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_rotate_origin_to_upright",
|
||||
"_bbox_center_inside_region",
|
||||
"_script_region_memberships",
|
||||
"_script_char_text",
|
||||
"_is_cjk_text",
|
||||
"_is_math_identifier_char",
|
||||
"_is_math_script_token_char",
|
||||
"_iter_math_script_tokens",
|
||||
"_citation_script_indices",
|
||||
"_token_origin",
|
||||
"_token_tight_height",
|
||||
"_has_adjacent_math_base",
|
||||
"_horizontal_gap_between_bboxes",
|
||||
"_token_split_position",
|
||||
"_script_geometry_is_aligned",
|
||||
"_nearest_nonspace_index",
|
||||
"_close_spaced_script_operators",
|
||||
"_close_compact_aligned_script_suffixes",
|
||||
"_protected_subscript_indices",
|
||||
"_refine_math_script_tokens",
|
||||
"_bbox_axis_overlap",
|
||||
"_fraction_member_indices",
|
||||
"_strong_structural_script_roles",
|
||||
"_classify_script_runs",
|
||||
"_script_line_char_roles",
|
||||
"_script_line_payload",
|
||||
"detect_pdf_text_script_lines",
|
||||
]
|
||||
@@ -0,0 +1,386 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""定义原生 PDF 行内样式证据、区间及内部投影类型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
from .....types import RAW_ALGORITHM, RAW_CAPTION, RAW_FOOTNOTE, RAW_PHONETIC, BBox, BlockType
|
||||
|
||||
TEXT_DECORATION_MIN_LENGTH_HEIGHT_RATIO = 1.8
|
||||
|
||||
STRIKETHROUGH_CENTER_TOLERANCE_HEIGHT_RATIO = 0.2
|
||||
|
||||
UNDERLINE_BOTTOM_TOLERANCE_HEIGHT_RATIO = 0.2
|
||||
|
||||
TEXT_DECORATION_MAX_WIDTH_HEIGHT_RATIO = 0.2
|
||||
|
||||
TEXT_DECORATION_MIN_TEXT_COVERAGE_RATIO = 0.55
|
||||
|
||||
TEXT_DECORATION_ENDPOINT_TOLERANCE_HEIGHT_RATIO = 0.5
|
||||
|
||||
UNDERLINE_FRACTION_MAX_GAP_HEIGHT_RATIO = 0.15
|
||||
|
||||
UNDERLINE_FRACTION_MIN_LOWER_LINE_COVERAGE = 0.8
|
||||
|
||||
PDF_FONT_ITALIC_FLAG = 1 << 6
|
||||
|
||||
PDF_FONT_FORCE_BOLD_FLAG = 1 << 18
|
||||
|
||||
PDF_BOLD_MIN_WEIGHT = 600.0
|
||||
|
||||
PDF_BOLD_MIN_COMPARABLE_CHAR_COUNT = 2
|
||||
|
||||
PDF_LINK_CHAR_OVERLAP_THRESHOLD = 0.5
|
||||
|
||||
PDFTextStyle = Literal[
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"superscript",
|
||||
"subscript",
|
||||
]
|
||||
|
||||
PDFScriptStyle = Literal["superscript", "subscript"]
|
||||
|
||||
_PDF_LINK_INTERVALS_KEY = "_inline_link_intervals"
|
||||
|
||||
_PDF_STYLE_INTERVALS_KEY = "_inline_style_intervals"
|
||||
|
||||
PDF_NATIVE_SCRIPT_MARKUP_KEY = "_pdf_native_script_markup"
|
||||
|
||||
_NATIVE_SCRIPT_TAG_RE = re.compile(r"<(?P<closing>/)?(?P<tag>sup|sub)>")
|
||||
|
||||
_PDF_INLINE_SPAN_BLOCK_TYPES = {
|
||||
BlockType.TEXT,
|
||||
BlockType.REF_TEXT,
|
||||
BlockType.DOC_TITLE,
|
||||
BlockType.PARAGRAPH_TITLE,
|
||||
BlockType.HEADER,
|
||||
BlockType.FOOTER,
|
||||
BlockType.PAGE_NUMBER,
|
||||
BlockType.ASIDE_TEXT,
|
||||
BlockType.PAGE_FOOTNOTE,
|
||||
BlockType.LIST,
|
||||
BlockType.INDEX,
|
||||
RAW_ALGORITHM,
|
||||
RAW_CAPTION,
|
||||
RAW_FOOTNOTE,
|
||||
RAW_PHONETIC,
|
||||
}
|
||||
|
||||
PDFTextDecoration = Literal["underline", "strikethrough"]
|
||||
|
||||
PDF_TEXT_STYLE_ORDER: tuple[PDFTextStyle, ...] = (
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"superscript",
|
||||
"subscript",
|
||||
)
|
||||
|
||||
_PDF_TEXT_DECORATION_ORDER: tuple[PDFTextDecoration, ...] = (
|
||||
"underline",
|
||||
"strikethrough",
|
||||
)
|
||||
|
||||
PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES = frozenset(
|
||||
{
|
||||
BlockType.TEXT,
|
||||
BlockType.REF_TEXT,
|
||||
BlockType.DOC_TITLE,
|
||||
BlockType.PARAGRAPH_TITLE,
|
||||
BlockType.LIST,
|
||||
BlockType.INDEX,
|
||||
BlockType.HEADER,
|
||||
BlockType.FOOTER,
|
||||
BlockType.PAGE_NUMBER,
|
||||
BlockType.ASIDE_TEXT,
|
||||
BlockType.PAGE_FOOTNOTE,
|
||||
RAW_CAPTION,
|
||||
RAW_FOOTNOTE,
|
||||
}
|
||||
)
|
||||
|
||||
_PDF_TEXT_STYLE_TARGET_BLOCK_TYPES: dict[PDFTextStyle, frozenset[str]] = {
|
||||
"bold": frozenset({BlockType.TEXT}),
|
||||
"italic": frozenset(),
|
||||
"underline": frozenset({BlockType.TEXT}),
|
||||
"strikethrough": PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES,
|
||||
"superscript": PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES,
|
||||
"subscript": PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES,
|
||||
}
|
||||
|
||||
_PDF_FONT_SUBSET_PREFIX_RE = re.compile(r"^[A-Z]{6}\+")
|
||||
|
||||
_PDF_BOLD_FONT_NAME_RE = re.compile(
|
||||
r"bold|demi|black|heavy|(?:^|[-_,])(?:bd|bi)(?:mt)?$|gbi$|(?:^|[-_,])w[6-9]$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PDF_LIST_MARKER_CHARS = frozenset("•◦‣⁃▪▫●○■□∙·▶▷►▸▹\uf0b7")
|
||||
|
||||
_PDF_GEOMETRIC_TEXT_STYLES = frozenset({"underline", "strikethrough"})
|
||||
|
||||
_PDF_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
||||
|
||||
_PDF_SEPARATOR_SPACE_CHARS = frozenset(
|
||||
"\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000"
|
||||
)
|
||||
|
||||
_PDF_ZERO_WIDTH_CHARS = frozenset({"\u200b", "\u2060", "\ufeff"})
|
||||
|
||||
_LIGATURE_REPLACEMENTS = {
|
||||
"ff": "ff",
|
||||
"fi": "fi",
|
||||
"fl": "fl",
|
||||
"ffi": "ffi",
|
||||
"ffl": "ffl",
|
||||
"ſt": "ft",
|
||||
"st": "st",
|
||||
}
|
||||
|
||||
_PDF_SCRIPT_TOKEN_CONNECTORS = frozenset({"^", "_", "ˆ", "~"})
|
||||
|
||||
_PDF_SCRIPT_COMPACT_JOINERS = frozenset({"-", "−", "–"})
|
||||
|
||||
_PDF_SCRIPT_CITATION_BRACKETS = {
|
||||
"[": "]",
|
||||
"[": "]",
|
||||
}
|
||||
|
||||
_PDF_SCRIPT_AUTHOR_MARKS = frozenset({"*", "†", "‡", "∗"})
|
||||
|
||||
_PDF_SCRIPT_SIGN_CHARS = frozenset({"+", "-", "−", "–", "⁻", "⁺"})
|
||||
|
||||
_PDF_SCRIPT_SPACED_OPERATORS = frozenset({"+", "-", "−", "–"})
|
||||
|
||||
_PDF_SCRIPT_TRAILING_MARKS = frozenset({")", ")"})
|
||||
|
||||
_PDF_SCRIPT_MATH_BASE_CHARS = frozenset({"∆", "Δ", "σ", "Σ", "φ", "Φ", "μ", "µ", "Ω", "Ω", "λ", "γ", "δ", "ρ", "β", "α"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextStyleRange:
|
||||
"""保存可比较文本中的一个半开样式区间。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
styles: tuple[PDFTextStyle, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextStyleLine:
|
||||
"""保存一个视觉文本 run 的几何、可比较文本和样式区间。"""
|
||||
|
||||
bbox: BBox
|
||||
text: str
|
||||
style_ranges: tuple[PDFTextStyleRange, ...]
|
||||
source_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextScriptRange:
|
||||
"""保存 Flash 上下标的文本区间、页面 tight bbox 与稳定性证据。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
style: PDFScriptStyle
|
||||
bbox: BBox
|
||||
stable_body_count: int
|
||||
formula_region: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextScriptLine:
|
||||
"""保存一个 Flash 视觉行的紧凑文本及上下标候选。"""
|
||||
|
||||
bbox: BBox
|
||||
text: str
|
||||
script_ranges: tuple[PDFTextScriptRange, ...]
|
||||
source_index: int
|
||||
angle: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextLinkRange:
|
||||
"""保存可比较文本中的一个半开超链接区间。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
target: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PDFTextLinkLine:
|
||||
"""保存视觉文本 run 的几何、可比较文本和超链接区间。"""
|
||||
|
||||
bbox: BBox
|
||||
text: str
|
||||
link_ranges: tuple[PDFTextLinkRange, ...]
|
||||
source_index: int
|
||||
|
||||
|
||||
PDFTextEvidenceLine = TypeVar(
|
||||
"PDFTextEvidenceLine",
|
||||
PDFTextStyleLine,
|
||||
PDFTextLinkLine,
|
||||
PDFTextScriptLine,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _VisibleChar:
|
||||
"""保存参与文本装饰线几何判断的可见字符。"""
|
||||
|
||||
source_index: int
|
||||
bbox: BBox
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _LineCandidate:
|
||||
"""保存字体与文本装饰线匹配阶段使用的视觉文本行指标。"""
|
||||
|
||||
bbox: BBox
|
||||
chars: list[dict[str, Any]]
|
||||
visible_chars: list[_VisibleChar]
|
||||
median_height: float
|
||||
center_y: float
|
||||
bottom_y: float
|
||||
source_index: int
|
||||
font_styles: list[frozenset[PDFTextStyle]]
|
||||
decoration_ranges: dict[PDFTextDecoration, list[tuple[int, int]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DrawingMatch:
|
||||
"""保存单条 drawing 对单个文本行的匹配结果和排序指标。"""
|
||||
|
||||
style: PDFTextDecoration
|
||||
start_index: int
|
||||
end_index: int
|
||||
target_distance_ratio: float
|
||||
horizontal_overlap_ratio: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProjectedChar:
|
||||
"""保存 model content 可比较字符到原字符串位置的映射。"""
|
||||
|
||||
value: str
|
||||
raw_start: int
|
||||
raw_end: int
|
||||
existing_styles: frozenset[PDFTextStyle]
|
||||
formula_gap_before: bool
|
||||
inside_hyperlink: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RawStyleInterval:
|
||||
"""保存 model content 原字符串中的半开样式区间。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
styles: tuple[PDFTextStyle, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _NativeScriptMarkup:
|
||||
"""保存 detector-owned 上下标标签边界及对应原文样式区间。"""
|
||||
|
||||
marker_ranges: tuple[tuple[int, int], ...]
|
||||
style_intervals: tuple[tuple[int, int, str], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _MatchedLinkRange:
|
||||
"""保存已经对齐到 block 可比较文本的链接区间与物理行身份。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
target: str
|
||||
source_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RawLinkInterval:
|
||||
"""保存 model content 原字符串中的半开链接区间。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
target: str
|
||||
source_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LineProjectionMatch:
|
||||
"""保存物理行跨公式空洞对齐到 block 可比较文本的结果。"""
|
||||
|
||||
start: int
|
||||
end: int
|
||||
source_to_projected: tuple[int | None, ...]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TEXT_DECORATION_MIN_LENGTH_HEIGHT_RATIO",
|
||||
"STRIKETHROUGH_CENTER_TOLERANCE_HEIGHT_RATIO",
|
||||
"UNDERLINE_BOTTOM_TOLERANCE_HEIGHT_RATIO",
|
||||
"TEXT_DECORATION_MAX_WIDTH_HEIGHT_RATIO",
|
||||
"TEXT_DECORATION_MIN_TEXT_COVERAGE_RATIO",
|
||||
"TEXT_DECORATION_ENDPOINT_TOLERANCE_HEIGHT_RATIO",
|
||||
"UNDERLINE_FRACTION_MAX_GAP_HEIGHT_RATIO",
|
||||
"UNDERLINE_FRACTION_MIN_LOWER_LINE_COVERAGE",
|
||||
"PDF_FONT_ITALIC_FLAG",
|
||||
"PDF_FONT_FORCE_BOLD_FLAG",
|
||||
"PDF_BOLD_MIN_WEIGHT",
|
||||
"PDF_BOLD_MIN_COMPARABLE_CHAR_COUNT",
|
||||
"PDF_LINK_CHAR_OVERLAP_THRESHOLD",
|
||||
"PDFTextStyle",
|
||||
"PDFScriptStyle",
|
||||
"_PDF_LINK_INTERVALS_KEY",
|
||||
"_PDF_STYLE_INTERVALS_KEY",
|
||||
"PDF_NATIVE_SCRIPT_MARKUP_KEY",
|
||||
"_NATIVE_SCRIPT_TAG_RE",
|
||||
"_PDF_INLINE_SPAN_BLOCK_TYPES",
|
||||
"PDFTextDecoration",
|
||||
"PDF_TEXT_STYLE_ORDER",
|
||||
"_PDF_TEXT_DECORATION_ORDER",
|
||||
"PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES",
|
||||
"_PDF_TEXT_STYLE_TARGET_BLOCK_TYPES",
|
||||
"_PDF_FONT_SUBSET_PREFIX_RE",
|
||||
"_PDF_BOLD_FONT_NAME_RE",
|
||||
"_PDF_LIST_MARKER_CHARS",
|
||||
"_PDF_GEOMETRIC_TEXT_STYLES",
|
||||
"_PDF_CONTROL_CHAR_RE",
|
||||
"_PDF_SEPARATOR_SPACE_CHARS",
|
||||
"_PDF_ZERO_WIDTH_CHARS",
|
||||
"_LIGATURE_REPLACEMENTS",
|
||||
"_PDF_SCRIPT_TOKEN_CONNECTORS",
|
||||
"_PDF_SCRIPT_COMPACT_JOINERS",
|
||||
"_PDF_SCRIPT_CITATION_BRACKETS",
|
||||
"_PDF_SCRIPT_AUTHOR_MARKS",
|
||||
"_PDF_SCRIPT_SIGN_CHARS",
|
||||
"_PDF_SCRIPT_SPACED_OPERATORS",
|
||||
"_PDF_SCRIPT_TRAILING_MARKS",
|
||||
"_PDF_SCRIPT_MATH_BASE_CHARS",
|
||||
"PDFTextStyleRange",
|
||||
"PDFTextStyleLine",
|
||||
"PDFTextScriptRange",
|
||||
"PDFTextScriptLine",
|
||||
"PDFTextLinkRange",
|
||||
"PDFTextLinkLine",
|
||||
"PDFTextEvidenceLine",
|
||||
"_VisibleChar",
|
||||
"_LineCandidate",
|
||||
"_DrawingMatch",
|
||||
"_ProjectedChar",
|
||||
"_RawStyleInterval",
|
||||
"_NativeScriptMarkup",
|
||||
"_MatchedLinkRange",
|
||||
"_RawLinkInterval",
|
||||
"_LineProjectionMatch",
|
||||
]
|
||||
@@ -13,6 +13,7 @@ from typing import Sequence
|
||||
from ....utils.text import is_hyphen_at_line_end
|
||||
from ....types import BBox
|
||||
|
||||
from .typography import _normalized_font_family
|
||||
from .models import (
|
||||
_LineItem,
|
||||
_LocalAxisLine,
|
||||
@@ -46,17 +47,6 @@ def _title_fonts_compatible(first: _LineItem, second: _LineItem) -> bool:
|
||||
return not (font_conflicts or weight_conflicts)
|
||||
|
||||
|
||||
def _normalized_font_family(
|
||||
signature: tuple[str, int] | None,
|
||||
) -> str | None:
|
||||
"""移除 PDF 字体子集前缀并归一化字体族名称,供几何续行作软兼容判断。"""
|
||||
|
||||
if signature is None:
|
||||
return None
|
||||
name = re.sub(r"^[A-Z]{6}\+", "", signature[0])
|
||||
return re.sub(r"[\s_-]+", "", name).casefold() or None
|
||||
|
||||
|
||||
def _font_signatures_share_family(
|
||||
first: tuple[str, int] | None,
|
||||
second: tuple[str, int] | None,
|
||||
|
||||
@@ -63,8 +63,14 @@ def _merge_same_baseline_text_lines(
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
# 只枚举满足必要分类条件的行对,组内仍按原索引递增,保持并查集认领顺序。
|
||||
compatible_indices: dict[tuple[int, bool, str | None], list[int]] = {}
|
||||
for index, line in enumerate(lines):
|
||||
compatible_indices.setdefault((line.angle, line.formula_candidate_only, line.semantic_type), []).append(index)
|
||||
for left_index, left_line in enumerate(lines):
|
||||
for right_index in range(left_index + 1, len(lines)):
|
||||
for right_index in compatible_indices[(left_line.angle, left_line.formula_candidate_only, left_line.semantic_type)]:
|
||||
if right_index <= left_index:
|
||||
continue
|
||||
right_line = lines[right_index]
|
||||
if _can_merge_same_baseline_pair(
|
||||
left_line,
|
||||
@@ -531,8 +537,6 @@ def _can_merge_same_baseline_pair(
|
||||
return False
|
||||
if first.visual_row_id == second.visual_row_id and (first.split_from_row or second.split_from_row):
|
||||
return False
|
||||
if _connection_crosses_table(first.bbox, second.bbox, table_bboxes):
|
||||
return False
|
||||
first_height = _line_effective_height(first, first_bbox)
|
||||
second_height = _line_effective_height(second, second_bbox)
|
||||
has_compatible_dominant_font = not (
|
||||
@@ -548,13 +552,13 @@ def _can_merge_same_baseline_pair(
|
||||
second_bbox,
|
||||
second_height,
|
||||
):
|
||||
return True
|
||||
return not _connection_crosses_table(first.bbox, second.bbox, table_bboxes)
|
||||
return _touching_same_baseline_geometry(
|
||||
first_bbox,
|
||||
first_height,
|
||||
second_bbox,
|
||||
second_height,
|
||||
)
|
||||
) and not _connection_crosses_table(first.bbox, second.bbox, table_bboxes)
|
||||
|
||||
|
||||
def _touching_same_baseline_geometry(
|
||||
|
||||
@@ -13,7 +13,7 @@ from ....types import BBox
|
||||
from .document import PDFPathInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .text_styles import PDFTextScriptLine
|
||||
from .inline.types import PDFTextScriptLine
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -15,8 +15,9 @@ from typing import Any, Literal, Mapping, Sequence
|
||||
from pdftext.schema import Char
|
||||
|
||||
from ....types import BBox
|
||||
from .document import PDFDocument
|
||||
from .document import PDFDocument, PDFDrawingLine
|
||||
|
||||
from .typography import _normalized_font_family
|
||||
from .models import (
|
||||
_AxisLine,
|
||||
_LineItem,
|
||||
@@ -649,17 +650,6 @@ def _detect_leading_emphasis_width(
|
||||
return max(0.1, prefix_bbox[2] - prefix_bbox[0])
|
||||
|
||||
|
||||
def _normalized_font_family(
|
||||
signature: tuple[str, int] | None,
|
||||
) -> str | None:
|
||||
"""移除 PDF 子集前缀并归一化字体族,供行首排版切换判断使用。"""
|
||||
|
||||
if signature is None:
|
||||
return None
|
||||
name = re.sub(r"^[A-Z]{6}\+", "", signature[0])
|
||||
return re.sub(r"[\s_-]+", "", name).casefold() or None
|
||||
|
||||
|
||||
def _detect_leading_typography_width(
|
||||
glyphs: list[tuple[BBox, tuple[str, int] | None, float | None]],
|
||||
) -> float | None:
|
||||
@@ -811,23 +801,28 @@ def _merge_native_inline_scripts(
|
||||
|
||||
candidates: list[tuple[float, int, int, Literal["prefix", "suffix"]]] = []
|
||||
detached_candidate_pairs: set[tuple[int, int, Literal["prefix", "suffix"]]] = set()
|
||||
# 候选生成期间行不会修改,按本轮索引缓存字符统计,避免每对行重算字号中位数。
|
||||
compact_texts = ["".join(char for char in line.text if not char.isspace()) for line in lines]
|
||||
reference_markers = [_INLINE_REFERENCE_MARKER_RE.fullmatch(text) is not None for text in compact_texts]
|
||||
local_bboxes = [_rotate_bbox_to_upright(line.bbox, page_size, line.angle) for line in lines]
|
||||
canonical_scales = [_native_typographic_scale(line) for line in lines]
|
||||
for small_index, small in enumerate(lines):
|
||||
compact_text = "".join(char for char in small.text if not char.isspace())
|
||||
compact_text = compact_texts[small_index]
|
||||
if not compact_text:
|
||||
continue
|
||||
small_local_bbox = _rotate_bbox_to_upright(small.bbox, page_size, small.angle)
|
||||
small_local_bbox = local_bboxes[small_index]
|
||||
for base_index, base in enumerate(lines):
|
||||
if small_index == base_index or small.angle != base.angle or small.visual_row_id == base.visual_row_id:
|
||||
continue
|
||||
if small.effective_height <= 0 or base.effective_height <= 0:
|
||||
continue
|
||||
canonical_small_scale = _native_typographic_scale(small)
|
||||
canonical_base_scale = _native_typographic_scale(base)
|
||||
canonical_small_scale = canonical_scales[small_index]
|
||||
canonical_base_scale = canonical_scales[base_index]
|
||||
legacy_small_scale = max(0.1, small.effective_height)
|
||||
legacy_base_scale = max(0.1, base.effective_height)
|
||||
legacy_ratio = legacy_small_scale / legacy_base_scale
|
||||
use_canonical_reference_scale = (
|
||||
_INLINE_REFERENCE_MARKER_RE.fullmatch(compact_text) is not None
|
||||
reference_markers[small_index]
|
||||
and not 0.35 <= legacy_ratio <= 0.8
|
||||
and 0.35 <= canonical_small_scale / canonical_base_scale <= 0.8
|
||||
)
|
||||
@@ -838,7 +833,7 @@ def _merge_native_inline_scripts(
|
||||
continue
|
||||
if len(compact_text) > 8 and small_local_bbox[2] - small_local_bbox[0] > 3.0 * base_scale:
|
||||
continue
|
||||
base_local_bbox = _rotate_bbox_to_upright(base.bbox, page_size, base.angle)
|
||||
base_local_bbox = local_bboxes[base_index]
|
||||
vertical_overlap = max(
|
||||
0.0,
|
||||
min(small_local_bbox[3], base_local_bbox[3]) - max(small_local_bbox[1], base_local_bbox[1]),
|
||||
@@ -975,8 +970,14 @@ def _normalize_pdftext_angle(value: Any) -> int:
|
||||
def _get_pdf_drawing_lines(pdf_doc: PDFDocument, page_idx: int) -> list[_AxisLine]:
|
||||
"""读取 PDFDocument 的公共绘图线结果,并隔离具体 PDFium 类型。"""
|
||||
|
||||
return _coerce_pdf_drawing_lines(pdf_doc.get_page_drawing_lines(page_idx))
|
||||
|
||||
|
||||
def _coerce_pdf_drawing_lines(drawing_lines: Sequence[PDFDrawingLine]) -> list[_AxisLine]:
|
||||
"""把独立接口或批量快照中的绘图线统一转换为 Flash 内部坐标类型。"""
|
||||
|
||||
output: list[_AxisLine] = []
|
||||
for drawing_line in pdf_doc.get_page_drawing_lines(page_idx):
|
||||
for drawing_line in drawing_lines:
|
||||
bbox = _coerce_bbox(drawing_line.bbox)
|
||||
if bbox is None:
|
||||
continue
|
||||
|
||||
@@ -45,7 +45,7 @@ def close_pdfium_child(pdfium_obj: Any) -> None:
|
||||
close()
|
||||
|
||||
|
||||
def close_pdfium_objects_safely(*pdfium_objs, owner: str = "pdfium cleanup") -> None:
|
||||
def close_pdfium_objects_safely(*pdfium_objs: object, owner: str = "pdfium cleanup") -> None:
|
||||
"""清理多个 PDFium 对象时逐个尝试关闭,避免前一个关闭失败阻断后续对象释放。"""
|
||||
for pdfium_obj in pdfium_objs:
|
||||
if pdfium_obj is None:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""集中隔离 pdftext 0.6 列表与 0.7 PageChars 容器之间的适配。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
from pdftext.pdf.chars import deduplicate_chars
|
||||
from pdftext.pdf.pages import assign_scripts, get_lines, get_spans
|
||||
from pdftext.schema import Bbox, Char, Line
|
||||
|
||||
try:
|
||||
from pdftext.pdf.chars import PageChars
|
||||
except ImportError:
|
||||
PageChars = None
|
||||
|
||||
|
||||
def _is_pdftext_page_chars(chars: Any) -> bool:
|
||||
"""判断对象是否为 pdftext 0.7 引入的 PageChars 列式字符容器。"""
|
||||
return PageChars is not None and isinstance(chars, PageChars)
|
||||
|
||||
|
||||
def _deduplicate_pdftext_chars(chars: Any) -> Any:
|
||||
"""按当前 pdftext 返回类型调用官方去重,兼容测试或旧版本的 list 字符。"""
|
||||
if _is_pdftext_page_chars(chars) or PageChars is None:
|
||||
return deduplicate_chars(chars)
|
||||
return chars
|
||||
|
||||
|
||||
def _materialize_page_chars(chars: Any) -> list[Char]:
|
||||
"""将 pdftext 0.7 的 PageChars 物化为 MinerU 既有 char dict 列表。"""
|
||||
boxes = chars.boxes.tolist()
|
||||
rotations = chars.rotations.tolist()
|
||||
font_ids = chars.font_ids.tolist()
|
||||
char_indices = chars.char_indices.tolist()
|
||||
|
||||
return [
|
||||
cast(
|
||||
Char,
|
||||
{
|
||||
"bbox": Bbox([float(coord) for coord in boxes[index]]),
|
||||
"char": chars.text[index],
|
||||
"rotation": float(rotations[index]),
|
||||
"font": chars.fonts[int(font_ids[index])],
|
||||
"char_idx": int(char_indices[index]),
|
||||
},
|
||||
)
|
||||
for index in range(len(chars))
|
||||
]
|
||||
|
||||
|
||||
def _ensure_legacy_chars(chars: Any) -> list[Char]:
|
||||
"""统一输出旧版 char dict 列表,隔离 pdftext 0.7 的返回结构变化。"""
|
||||
if _is_pdftext_page_chars(chars):
|
||||
return _materialize_page_chars(chars)
|
||||
return cast(list[Char], chars)
|
||||
|
||||
|
||||
def _char_bbox_values(bbox: object) -> tuple[float, float, float, float] | None:
|
||||
"""将 tuple/list 或 pdftext Bbox 对象统一转换为四元组坐标。"""
|
||||
if bbox is None:
|
||||
return None
|
||||
if isinstance(bbox, (list, tuple)) and len(bbox) == 4:
|
||||
return tuple(float(value) for value in bbox) # type: ignore[return-value]
|
||||
|
||||
attrs = ("x_start", "y_start", "x_end", "y_end")
|
||||
if all(hasattr(bbox, attr) for attr in attrs):
|
||||
return tuple(float(getattr(bbox, attr)) for attr in attrs) # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
||||
def _get_single_char_text(char: Char) -> str:
|
||||
"""提取单个 PDF 字符文本,异常空值用替换符保证 PageChars 长度一致。"""
|
||||
text = str(char.get("char", ""))
|
||||
if len(text) == 1:
|
||||
return text
|
||||
return text[:1] or "\ufffd"
|
||||
|
||||
|
||||
def _get_char_font_id(
|
||||
char: Char,
|
||||
fonts: list[dict[str, Any]],
|
||||
font_cache: dict[tuple[Any, Any, Any, Any], int],
|
||||
) -> int:
|
||||
"""为旧版字符 font 生成 PageChars 需要的页内 font id。"""
|
||||
font = char.get("font") or {}
|
||||
font_key = (
|
||||
font.get("name"),
|
||||
font.get("flags"),
|
||||
font.get("size"),
|
||||
font.get("weight"),
|
||||
)
|
||||
font_id = font_cache.get(font_key)
|
||||
if font_id is None:
|
||||
font_id = len(fonts)
|
||||
font_cache[font_key] = font_id
|
||||
fonts.append(
|
||||
{
|
||||
"name": font.get("name"),
|
||||
"flags": font.get("flags"),
|
||||
"size": font.get("size"),
|
||||
"weight": font.get("weight"),
|
||||
}
|
||||
)
|
||||
return font_id
|
||||
|
||||
|
||||
def _get_char_index(char: Char, fallback_idx: int) -> int:
|
||||
"""提取旧版字符索引,缺失或为空时回退到当前列表位置。"""
|
||||
char_idx = char.get("char_idx")
|
||||
if char_idx is None:
|
||||
char_idx = fallback_idx
|
||||
return int(char_idx)
|
||||
|
||||
|
||||
def _legacy_chars_to_page_chars(chars: Any) -> Any:
|
||||
"""将旧版 char dict 列表打包回 pdftext 0.7 get_spans 所需的 PageChars。"""
|
||||
if PageChars is None or _is_pdftext_page_chars(chars):
|
||||
return chars
|
||||
|
||||
fonts: list[dict[str, Any]] = []
|
||||
font_cache: dict[tuple[Any, Any, Any, Any], int] = {}
|
||||
text_parts: list[str] = []
|
||||
codes: list[int] = []
|
||||
rotations: list[float] = []
|
||||
boxes: list[tuple[float, float, float, float]] = []
|
||||
font_ids: list[int] = []
|
||||
char_indices: list[int] = []
|
||||
|
||||
for fallback_idx, char in enumerate(cast(list[Char], chars)):
|
||||
char_text = _get_single_char_text(char)
|
||||
bbox_values = _char_bbox_values(char.get("bbox"))
|
||||
if bbox_values is None:
|
||||
bbox_values = (0.0, 0.0, 0.0, 0.0)
|
||||
text_parts.append(char_text)
|
||||
codes.append(ord(char_text))
|
||||
rotations.append(float(char.get("rotation") or 0.0))
|
||||
boxes.append(bbox_values)
|
||||
font_ids.append(_get_char_font_id(char, fonts, font_cache))
|
||||
char_indices.append(_get_char_index(char, fallback_idx))
|
||||
|
||||
return PageChars(
|
||||
"".join(text_parts),
|
||||
np.array(codes, dtype=np.uint32),
|
||||
np.array(rotations, dtype=np.float64),
|
||||
np.array(boxes, dtype=np.float64).reshape((len(boxes), 4)),
|
||||
np.array(font_ids, dtype=np.int32),
|
||||
fonts,
|
||||
np.array(char_indices, dtype=np.int64),
|
||||
)
|
||||
|
||||
|
||||
def get_lines_from_chars(
|
||||
chars: list[Char],
|
||||
superscript_height_threshold: float = 0.7,
|
||||
line_distance_threshold: float = 0.1,
|
||||
) -> list[Line]:
|
||||
"""从已提取的字符构建 pdftext lines,避免重复读取 PDFium textpage。"""
|
||||
chars = _legacy_chars_to_page_chars(chars)
|
||||
spans = get_spans(
|
||||
chars,
|
||||
superscript_height_threshold=superscript_height_threshold,
|
||||
line_distance_threshold=line_distance_threshold,
|
||||
)
|
||||
lines = get_lines(spans)
|
||||
assign_scripts(
|
||||
lines,
|
||||
height_threshold=superscript_height_threshold,
|
||||
line_distance_threshold=line_distance_threshold,
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
__all__ = ["get_lines_from_chars"]
|
||||
+171
-108
@@ -4,24 +4,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
from ....types import BBox
|
||||
from .._shared.xycut import sort_entries
|
||||
from .document import PDFDocument, PDFImageInfo, get_lines_from_chars
|
||||
from .text_styles import (
|
||||
PDFTextLinkLine,
|
||||
PDFTextStyleLine,
|
||||
from .document import PDFDocument, PDFImageInfo, PDFPageTextGeometry, get_lines_from_chars
|
||||
from .inline.detection import detect_pdf_text_link_lines, detect_pdf_text_style_lines
|
||||
from .inline.matching import _realign_repaired_text_evidence
|
||||
from .inline.materialize import (
|
||||
apply_pdf_text_links,
|
||||
apply_pdf_text_scripts,
|
||||
apply_pdf_text_styles,
|
||||
detect_pdf_text_script_lines,
|
||||
detect_pdf_text_link_lines,
|
||||
detect_pdf_text_style_lines,
|
||||
materialize_pdf_inline_spans,
|
||||
_realign_repaired_text_evidence,
|
||||
)
|
||||
from .inline.scripts import detect_pdf_text_script_lines
|
||||
from .inline.types import PDFTextLinkLine, PDFTextStyleLine
|
||||
|
||||
from .models import (
|
||||
_AxisLine,
|
||||
@@ -47,7 +46,7 @@ from .geometry import (
|
||||
from .native_text import (
|
||||
_build_native_line_items,
|
||||
_extract_decorative_text_rules,
|
||||
_get_pdf_drawing_lines,
|
||||
_coerce_pdf_drawing_lines,
|
||||
_median_native_glyph_width,
|
||||
_sanitize_pdf_control_text,
|
||||
_resplit_native_visual_runs,
|
||||
@@ -105,25 +104,25 @@ from .auxiliary_text import (
|
||||
_marginal_geometry_matches,
|
||||
_marginal_text_matches,
|
||||
)
|
||||
from .titles import (
|
||||
from .title_analysis.body_profile import _infer_document_body_profile
|
||||
from .title_analysis.document_profile import _infer_document_title_profile
|
||||
from .title_analysis.page_titles import _classify_page_titles
|
||||
from .title_analysis.structural import (
|
||||
_classify_body_height_section_titles,
|
||||
_classify_explicit_section_titles,
|
||||
_classify_inline_typography_reset_titles,
|
||||
_classify_document_structural_titles,
|
||||
_classify_page_titles,
|
||||
_infer_document_body_profile,
|
||||
_infer_document_title_profile,
|
||||
_promote_noninitial_document_title_band,
|
||||
)
|
||||
from .text_blocks import (
|
||||
_build_text_blocks,
|
||||
from .text_assembly.annotations import (
|
||||
_merge_fragmented_header_blocks,
|
||||
_merge_front_matter_column_blocks,
|
||||
_merge_image_caption_text_blocks,
|
||||
_merge_internal_text_block_group,
|
||||
_merge_multiline_title_blocks,
|
||||
_merge_repeated_compact_title_continuations,
|
||||
)
|
||||
from .text_assembly.assembly import _build_text_blocks
|
||||
from .text_assembly.common import _merge_internal_text_block_group
|
||||
from .visual_annotations import _classify_and_bind_visual_annotations
|
||||
|
||||
|
||||
@@ -293,36 +292,39 @@ def _repeated_header_evidence_pages(
|
||||
return supported_pages
|
||||
|
||||
|
||||
def _analyze_native_document(
|
||||
pdf_doc: PDFDocument,
|
||||
*,
|
||||
script_diagnostics: list[dict[str, Any]] | None = None,
|
||||
geometry_diagnostics: list[dict[str, Any]] | None = None,
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""逐页读取数字 PDF,并在轻量页面上完成跨页文本类型判定。"""
|
||||
@dataclass(slots=True)
|
||||
class _DocumentSources:
|
||||
"""持有跨页校准前的原始页面,以及最终物化仍需的紧凑样式证据。"""
|
||||
|
||||
page_sizes = [pdf_doc.page_size(page_idx) for page_idx in range(pdf_doc.page_count)]
|
||||
page_image_infos = [pdf_doc.get_page_image_infos(page_idx) for page_idx in range(pdf_doc.page_count)]
|
||||
page_signature_bboxes = [pdf_doc.get_page_signature_bboxes(page_idx) for page_idx in range(pdf_doc.page_count)]
|
||||
watermark_fingerprints = _detect_repeated_raster_watermark_fingerprints(
|
||||
page_image_infos,
|
||||
page_sizes,
|
||||
)
|
||||
page_sources: list[_PageSource]
|
||||
page_text_geometries: list[PDFPageTextGeometry]
|
||||
page_sizes: list[tuple[float, float]]
|
||||
page_style_lines: list[list[PDFTextStyleLine]]
|
||||
page_link_lines: list[list[PDFTextLinkLine]]
|
||||
|
||||
|
||||
def _collect_document_sources(pdf_doc: PDFDocument) -> _DocumentSources:
|
||||
"""逐页收集原生证据,局部快照与字符引用在收集阶段退出时释放。"""
|
||||
|
||||
page_sizes: list[tuple[float, float]] = []
|
||||
page_image_infos: list[list[PDFImageInfo]] = []
|
||||
page_sources: list[_PageSource] = []
|
||||
page_text_geometries = []
|
||||
page_style_lines: list[list[PDFTextStyleLine]] = []
|
||||
page_link_lines: list[list[PDFTextLinkLine]] = []
|
||||
for page_idx in range(pdf_doc.page_count):
|
||||
page_size = page_sizes[page_idx]
|
||||
text_geometry = pdf_doc.get_page_chars_with_geometry(page_idx)
|
||||
snapshot = pdf_doc._extract_native_page(page_idx)
|
||||
page_size = snapshot.page_size
|
||||
page_sizes.append(page_size)
|
||||
page_image_infos.append(snapshot.image_infos)
|
||||
text_geometry = snapshot.text_geometry
|
||||
chars = text_geometry.chars
|
||||
lines = _build_native_line_items(
|
||||
get_lines_from_chars(chars),
|
||||
page_size,
|
||||
page_rotation=pdf_doc.page_rotation(page_idx),
|
||||
page_rotation=snapshot.rotation,
|
||||
)
|
||||
drawing_lines = _get_pdf_drawing_lines(pdf_doc, page_idx)
|
||||
drawing_lines = _coerce_pdf_drawing_lines(snapshot.drawing_lines)
|
||||
lines, decorative_rules = _extract_decorative_text_rules(
|
||||
lines,
|
||||
page_size,
|
||||
@@ -332,7 +334,7 @@ def _analyze_native_document(
|
||||
page_link_lines.append(
|
||||
detect_pdf_text_link_lines(
|
||||
lines,
|
||||
pdf_doc.get_page_link_annotations(page_idx),
|
||||
snapshot.link_annotations,
|
||||
)
|
||||
)
|
||||
source = _PageSource(
|
||||
@@ -340,69 +342,83 @@ def _analyze_native_document(
|
||||
lines=lines,
|
||||
chars=chars,
|
||||
drawing_lines=drawing_lines,
|
||||
image_bboxes=_filter_repeated_raster_watermark_bboxes(
|
||||
page_image_infos[page_idx],
|
||||
page_size,
|
||||
watermark_fingerprints,
|
||||
),
|
||||
signature_bboxes=page_signature_bboxes[page_idx],
|
||||
form_bboxes=pdf_doc.get_page_form_bboxes(page_idx),
|
||||
path_infos=pdf_doc.get_page_path_infos(page_idx),
|
||||
signature_bboxes=snapshot.signature_bboxes,
|
||||
form_bboxes=snapshot.form_bboxes,
|
||||
path_infos=snapshot.path_infos,
|
||||
)
|
||||
page_sources.append(source)
|
||||
page_text_geometries.append(text_geometry)
|
||||
|
||||
geometry_plan = build_document_geometry_plan(
|
||||
[source.lines for source in page_sources],
|
||||
page_text_geometries,
|
||||
page_sizes,
|
||||
)
|
||||
for page_index, source in enumerate(page_sources):
|
||||
# 容器认领前只启用高置信 X 修复;Y trim 等表格、公式和图片行被排除后再应用。
|
||||
apply_line_geometry_repairs(
|
||||
source.lines,
|
||||
page_index=page_index,
|
||||
plan=geometry_plan,
|
||||
allow_y_trim=False,
|
||||
watermark_fingerprints = _detect_repeated_raster_watermark_fingerprints(page_image_infos, page_sizes)
|
||||
for source, image_infos in zip(page_sources, page_image_infos, strict=True):
|
||||
source.image_bboxes = _filter_repeated_raster_watermark_bboxes(
|
||||
image_infos,
|
||||
source.page_size,
|
||||
watermark_fingerprints,
|
||||
)
|
||||
|
||||
return _DocumentSources(page_sources, page_text_geometries, page_sizes, page_style_lines, page_link_lines)
|
||||
|
||||
|
||||
def _prepare_document_sources(
|
||||
sources: _DocumentSources,
|
||||
*,
|
||||
geometry_diagnostics: list[dict[str, Any]] | None = None,
|
||||
) -> list[_PreparedPage]:
|
||||
"""先完成全文几何和页眉判定,再按顺序消费原始页面并释放已用证据。"""
|
||||
|
||||
geometry_plan = build_document_geometry_plan(
|
||||
[source.lines for source in sources.page_sources],
|
||||
sources.page_text_geometries,
|
||||
sources.page_sizes,
|
||||
)
|
||||
for page_index, source in enumerate(sources.page_sources):
|
||||
# 容器认领前只允许 X 修复,表格和图形认领后再启用 Y trim。
|
||||
apply_line_geometry_repairs(source.lines, page_index=page_index, plan=geometry_plan, allow_y_trim=False)
|
||||
if geometry_diagnostics is not None:
|
||||
geometry_diagnostics.append(geometry_plan.to_dict())
|
||||
_classify_raw_page_marginals(sources.page_sources)
|
||||
separators = _detect_repeated_header_separator_bboxes(sources.page_sources)
|
||||
repaired_chars_by_page: dict[int, dict[int, BBox]] = {}
|
||||
for (page_index, char_idx), repair in geometry_plan.char_repairs.items():
|
||||
repaired_chars_by_page.setdefault(page_index, {})[char_idx] = repair.layout_bbox
|
||||
|
||||
_classify_raw_page_marginals(page_sources)
|
||||
repeated_header_separators = _detect_repeated_header_separator_bboxes(
|
||||
page_sources,
|
||||
)
|
||||
prepared_pages = [
|
||||
_prepare_page_source(
|
||||
source,
|
||||
tight_bboxes=geometry.tight_bboxes,
|
||||
origins=geometry.origins,
|
||||
geometry_plan=geometry_plan,
|
||||
page_index=page_index,
|
||||
style_lines=page_style_lines[page_index],
|
||||
link_lines=page_link_lines[page_index],
|
||||
table_header_separator_bboxes=(repeated_header_separators[page_index]),
|
||||
)
|
||||
for page_index, (source, geometry) in enumerate(zip(page_sources, page_text_geometries, strict=True))
|
||||
]
|
||||
if script_diagnostics is not None:
|
||||
script_diagnostics.extend(
|
||||
{
|
||||
"page_index": page_index,
|
||||
"page_size": prepared.page_size,
|
||||
"script_lines": list(prepared.script_lines),
|
||||
"lines": [
|
||||
{
|
||||
"source_index": line.source_index,
|
||||
"text": line.text,
|
||||
"bbox": line.bbox,
|
||||
"angle": line.angle,
|
||||
}
|
||||
for line in prepared.remaining_lines
|
||||
],
|
||||
}
|
||||
for page_index, prepared in enumerate(prepared_pages)
|
||||
pending = deque(zip(sources.page_sources, sources.page_text_geometries, strict=True))
|
||||
sources.page_sources.clear()
|
||||
sources.page_text_geometries.clear()
|
||||
prepared_pages: list[_PreparedPage] = []
|
||||
while pending:
|
||||
page_index = len(prepared_pages)
|
||||
source, geometry = pending.popleft()
|
||||
prepared_pages.append(
|
||||
_prepare_page_source(
|
||||
source,
|
||||
tight_bboxes=geometry.tight_bboxes,
|
||||
origins=geometry.origins,
|
||||
geometry_plan=geometry_plan,
|
||||
page_index=page_index,
|
||||
style_lines=sources.page_style_lines[page_index],
|
||||
link_lines=sources.page_link_lines[page_index],
|
||||
table_header_separator_bboxes=separators[page_index],
|
||||
repaired_char_bboxes=repaired_chars_by_page.pop(page_index, {}),
|
||||
)
|
||||
)
|
||||
# 删除对象所有者引用,不清空共享字符容器,公式重建副本仍可安全使用。
|
||||
del source, geometry
|
||||
return prepared_pages
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DocumentTextProfiles:
|
||||
"""分别保存原始正文尺度、规范正文尺度及全文标题原型。"""
|
||||
|
||||
body: _DocumentBodyProfile | None
|
||||
canonical_body: _DocumentBodyProfile | None
|
||||
title: _DocumentTitleProfile | None
|
||||
|
||||
|
||||
def _classify_document_text(prepared_pages: list[_PreparedPage]) -> _DocumentTextProfiles:
|
||||
"""按既有顺序分类跨页辅助文本,再统计正文并确认结构标题。"""
|
||||
|
||||
_classify_repeated_visual_headers(prepared_pages)
|
||||
_classify_repeated_page_marginals(prepared_pages)
|
||||
@@ -433,23 +449,24 @@ def _analyze_native_document(
|
||||
legacy_body_profile=document_body_profile,
|
||||
document_title_profile=document_title_profile,
|
||||
)
|
||||
finalized_pages = [
|
||||
_finalize_prepared_page(
|
||||
prepared,
|
||||
page_index,
|
||||
canonical_body_profile=canonical_body_profile,
|
||||
document_body_profile=document_body_profile,
|
||||
document_title_profile=document_title_profile,
|
||||
)
|
||||
for page_index, prepared in enumerate(prepared_pages)
|
||||
]
|
||||
return _DocumentTextProfiles(document_body_profile, canonical_body_profile, document_title_profile)
|
||||
|
||||
|
||||
def _materialize_document_inline(
|
||||
finalized_pages: list[list[dict[str, Any]]],
|
||||
prepared_pages: list[_PreparedPage],
|
||||
sources: _DocumentSources,
|
||||
script_diagnostics: list[dict[str, Any]] | None,
|
||||
) -> None:
|
||||
"""在页面归一化后按链接、样式、上下标顺序物化最终行内语义。"""
|
||||
|
||||
for page_index, (page_blocks, prepared, style_lines, link_lines, page_size) in enumerate(
|
||||
zip(
|
||||
finalized_pages,
|
||||
prepared_pages,
|
||||
page_style_lines,
|
||||
page_link_lines,
|
||||
page_sizes,
|
||||
sources.page_style_lines,
|
||||
sources.page_link_lines,
|
||||
sources.page_sizes,
|
||||
strict=True,
|
||||
)
|
||||
):
|
||||
@@ -466,6 +483,49 @@ def _analyze_native_document(
|
||||
materialized_diagnostics=materialized_diagnostics,
|
||||
)
|
||||
materialize_pdf_inline_spans(page_blocks)
|
||||
|
||||
|
||||
def _analyze_native_document(
|
||||
pdf_doc: PDFDocument,
|
||||
*,
|
||||
script_diagnostics: list[dict[str, Any]] | None = None,
|
||||
geometry_diagnostics: list[dict[str, Any]] | None = None,
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""逐页读取数字 PDF,并在轻量页面上完成跨页文本类型判定。"""
|
||||
|
||||
sources = _collect_document_sources(pdf_doc)
|
||||
prepared_pages = _prepare_document_sources(sources, geometry_diagnostics=geometry_diagnostics)
|
||||
if script_diagnostics is not None:
|
||||
script_diagnostics.extend(
|
||||
{
|
||||
"page_index": page_index,
|
||||
"page_size": prepared.page_size,
|
||||
"script_lines": list(prepared.script_lines),
|
||||
"lines": [
|
||||
{
|
||||
"source_index": line.source_index,
|
||||
"text": line.text,
|
||||
"bbox": line.bbox,
|
||||
"angle": line.angle,
|
||||
}
|
||||
for line in prepared.remaining_lines
|
||||
],
|
||||
}
|
||||
for page_index, prepared in enumerate(prepared_pages)
|
||||
)
|
||||
|
||||
profiles = _classify_document_text(prepared_pages)
|
||||
finalized_pages = [
|
||||
_finalize_prepared_page(
|
||||
prepared,
|
||||
page_index,
|
||||
canonical_body_profile=profiles.canonical_body,
|
||||
document_body_profile=profiles.body,
|
||||
document_title_profile=profiles.title,
|
||||
)
|
||||
for page_index, prepared in enumerate(prepared_pages)
|
||||
]
|
||||
_materialize_document_inline(finalized_pages, prepared_pages, sources, script_diagnostics)
|
||||
return finalized_pages
|
||||
|
||||
|
||||
@@ -479,6 +539,7 @@ def _prepare_page_source(
|
||||
style_lines: list[PDFTextStyleLine] | None = None,
|
||||
link_lines: list[PDFTextLinkLine] | None = None,
|
||||
table_header_separator_bboxes: set[BBox] | None = None,
|
||||
repaired_char_bboxes: dict[int, BBox] | None = None,
|
||||
) -> _PreparedPage:
|
||||
"""先认领视觉容器,再标注辅助文本并留下可跨页比较的轻量文本行。"""
|
||||
|
||||
@@ -582,11 +643,13 @@ def _prepare_page_source(
|
||||
)
|
||||
if geometry_plan is not None:
|
||||
repaired_line_bboxes = {line.source_index: line.bbox for line in source.lines}
|
||||
repaired_char_bboxes = {
|
||||
char_idx: repair.layout_bbox
|
||||
for (repair_page_index, char_idx), repair in geometry_plan.char_repairs.items()
|
||||
if repair_page_index == page_index
|
||||
}
|
||||
if repaired_char_bboxes is None:
|
||||
# 单页内部入口保留独立调用能力;文档主链路已提前按页建立索引。
|
||||
repaired_char_bboxes = {
|
||||
char_idx: repair.layout_bbox
|
||||
for (repair_page_index, char_idx), repair in geometry_plan.char_repairs.items()
|
||||
if repair_page_index == page_index
|
||||
}
|
||||
unclaimed_lines, resplits = _resplit_native_visual_runs(
|
||||
unclaimed_lines,
|
||||
source.page_size,
|
||||
|
||||
@@ -11,6 +11,7 @@ import numpy as np
|
||||
from pdftext.schema import Char
|
||||
|
||||
from ....types import BBox
|
||||
from .table_geometry import normalize_bbox as _coerce_bbox, rotate_local_bbox as _rotate_local_bbox
|
||||
|
||||
|
||||
# 空间投影思路参考 LiteParse v2.6.0 的字符分段和网格投影;
|
||||
@@ -67,19 +68,6 @@ def _normalize_table_text(value: Any) -> str:
|
||||
return value.translate(_PUNCTUATION_TRANSLATION)
|
||||
|
||||
|
||||
def _coerce_bbox(value: Any) -> BBox | None:
|
||||
"""把 bbox 规范成有效浮点四元组,异常或退化框返回 None。"""
|
||||
try:
|
||||
x0, y0, x1, y1 = [float(item) for item in value]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
left, right = sorted((x0, x1))
|
||||
top, bottom = sorted((y0, y1))
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return (left, top, right, bottom)
|
||||
|
||||
|
||||
def _bbox_union(bbox1: BBox, bbox2: BBox) -> BBox:
|
||||
"""合并两个字符框,得到当前文本片段的外接框。"""
|
||||
return (
|
||||
@@ -99,18 +87,6 @@ def _normalize_angle(angle: Any) -> int:
|
||||
return normalized_angle if normalized_angle in {0, 90, 180, 270} else 0
|
||||
|
||||
|
||||
def _rotate_local_bbox(bbox: BBox, width: float, height: float, angle: int) -> BBox:
|
||||
"""把表格局部 bbox 旋转到与正向表格裁图一致的坐标系。"""
|
||||
x0, y0, x1, y1 = bbox
|
||||
if angle == 270:
|
||||
return (height - y1, x0, height - y0, x1)
|
||||
if angle == 90:
|
||||
return (y0, width - x1, y1, width - x0)
|
||||
if angle == 180:
|
||||
return (width - x1, height - y1, width - x0, height - y0)
|
||||
return bbox
|
||||
|
||||
|
||||
def _select_table_chars(chars: list[Char], table_bbox: BBox) -> list[Char]:
|
||||
"""按字符框中心点选择表格内字符,并保持原始字符流顺序。"""
|
||||
x0, y0, x1, y1 = table_bbox
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""提供表格恢复与文本投影共享的局部几何,不改变各业务层校验策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....types import BBox
|
||||
|
||||
|
||||
def normalize_bbox(value: object) -> BBox | None:
|
||||
"""把任意四元组规范为有效浮点 bbox,异常或退化框返回空。"""
|
||||
|
||||
try:
|
||||
x0, y0, x1, y1 = [float(item) for item in value] # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
left, right = sorted((x0, x1))
|
||||
top, bottom = sorted((y0, y1))
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def rotate_local_bbox(
|
||||
bbox: BBox,
|
||||
width: float,
|
||||
height: float,
|
||||
angle: int,
|
||||
) -> BBox:
|
||||
"""把表格裁剪框内 bbox 转换到正向表格局部坐标。"""
|
||||
|
||||
x0, y0, x1, y1 = bbox
|
||||
if angle == 270:
|
||||
return height - y1, x0, height - y0, x1
|
||||
if angle == 90:
|
||||
return y0, width - x1, y1, width - x0
|
||||
if angle == 180:
|
||||
return width - x1, height - y1, width - x0, height - y0
|
||||
return bbox
|
||||
|
||||
|
||||
__all__ = ["normalize_bbox", "rotate_local_bbox"]
|
||||
@@ -6,20 +6,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable
|
||||
|
||||
from .....types import BBox
|
||||
|
||||
|
||||
def normalize_bbox(value: object) -> BBox | None:
|
||||
"""把任意四元组规范为有效浮点 bbox,异常或退化框返回空。"""
|
||||
|
||||
try:
|
||||
x0, y0, x1, y1 = [float(item) for item in value] # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
left, right = sorted((x0, x1))
|
||||
top, bottom = sorted((y0, y1))
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return left, top, right, bottom
|
||||
from ..table_geometry import normalize_bbox as normalize_bbox, rotate_local_bbox as rotate_local_bbox
|
||||
|
||||
|
||||
def normalize_angle(value: object) -> int:
|
||||
@@ -83,24 +70,6 @@ def bbox_center(bbox: BBox) -> tuple[float, float]:
|
||||
return (bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0
|
||||
|
||||
|
||||
def rotate_local_bbox(
|
||||
bbox: BBox,
|
||||
width: float,
|
||||
height: float,
|
||||
angle: int,
|
||||
) -> BBox:
|
||||
"""把表格裁剪框内 bbox 转换到正向表格局部坐标。"""
|
||||
|
||||
x0, y0, x1, y1 = bbox
|
||||
if angle == 270:
|
||||
return height - y1, x0, height - y0, x1
|
||||
if angle == 90:
|
||||
return y0, width - x1, y1, width - x0
|
||||
if angle == 180:
|
||||
return width - x1, height - y1, width - x0, height - y0
|
||||
return bbox
|
||||
|
||||
|
||||
def page_bbox_to_table_local(
|
||||
bbox: BBox,
|
||||
table_bbox: BBox,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""共享稀疏表格的坐标聚类和局部物理规则转换,保留两路候选策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .contracts import NativeTableInput
|
||||
from .geometry import normalize_angle, normalize_bbox, page_bbox_to_table_local
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LocalRule:
|
||||
"""保存转换到正向表格坐标后的细线区间。"""
|
||||
|
||||
orientation: str
|
||||
coordinate: float
|
||||
start: float
|
||||
end: float
|
||||
width: float
|
||||
|
||||
|
||||
def cluster_members(
|
||||
values: list[float],
|
||||
tolerance: float,
|
||||
) -> list[tuple[float, tuple[float, ...]]]:
|
||||
"""按一维距离聚类坐标并保留每簇原始成员。"""
|
||||
|
||||
if not values:
|
||||
return []
|
||||
clusters: list[list[float]] = [[value] for value in sorted(values)[:1]]
|
||||
for value in sorted(values)[1:]:
|
||||
center = float(statistics.median(clusters[-1]))
|
||||
if abs(value - center) <= tolerance:
|
||||
clusters[-1].append(value)
|
||||
else:
|
||||
clusters.append([value])
|
||||
return [(float(statistics.median(cluster)), tuple(cluster)) for cluster in clusters]
|
||||
|
||||
|
||||
def _local_rules(
|
||||
table_input: NativeTableInput,
|
||||
width: float,
|
||||
height: float,
|
||||
) -> tuple[_LocalRule, ...]:
|
||||
"""把页面 drawing 线转换为按局部长轴重新判向的规则区间。"""
|
||||
|
||||
table_bbox = normalize_bbox(table_input.table_bbox)
|
||||
if table_bbox is None:
|
||||
return ()
|
||||
angle = normalize_angle(table_input.angle)
|
||||
output: list[_LocalRule] = []
|
||||
for rule in table_input.drawing_lines:
|
||||
bbox = normalize_bbox(rule.bbox)
|
||||
if bbox is None:
|
||||
continue
|
||||
local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
|
||||
if local_bbox is None:
|
||||
continue
|
||||
local_width = local_bbox[2] - local_bbox[0]
|
||||
local_height = local_bbox[3] - local_bbox[1]
|
||||
if local_width >= max(1.0, 3.0 * max(local_height, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="horizontal",
|
||||
coordinate=(local_bbox[1] + local_bbox[3]) / 2.0,
|
||||
start=max(0.0, local_bbox[0]),
|
||||
end=min(width, local_bbox[2]),
|
||||
width=max(rule.width, local_height),
|
||||
)
|
||||
)
|
||||
elif local_height >= max(1.0, 3.0 * max(local_width, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="vertical",
|
||||
coordinate=(local_bbox[0] + local_bbox[2]) / 2.0,
|
||||
start=max(0.0, local_bbox[1]),
|
||||
end=min(height, local_bbox[3]),
|
||||
width=max(rule.width, local_width),
|
||||
)
|
||||
)
|
||||
return tuple(output)
|
||||
|
||||
|
||||
__all__ = ["_LocalRule", "cluster_members", "_local_rules"]
|
||||
@@ -9,6 +9,7 @@ from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .sparse_common import _LocalRule, _local_rules, cluster_members
|
||||
from .candidate import GridCellSpec, build_candidate
|
||||
from .contracts import (
|
||||
NativeTableCandidate,
|
||||
@@ -31,17 +32,6 @@ MAX_SPARSE_HYPOTHESES = 8
|
||||
MAX_HEADER_ROWS = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LocalRule:
|
||||
"""保存转换到正向表格坐标后的细线区间。"""
|
||||
|
||||
orientation: str
|
||||
coordinate: float
|
||||
start: float
|
||||
end: float
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TrackHypothesis:
|
||||
"""保存一组少线表叶子列轨及其独立证据。"""
|
||||
@@ -62,68 +52,6 @@ class _DenseLayout:
|
||||
dense_row_indices: tuple[int, ...]
|
||||
|
||||
|
||||
def _cluster_with_members(
|
||||
values: list[float],
|
||||
tolerance: float,
|
||||
) -> list[tuple[float, tuple[float, ...]]]:
|
||||
"""按一维距离聚类坐标并保留每簇原始成员。"""
|
||||
|
||||
if not values:
|
||||
return []
|
||||
clusters: list[list[float]] = [[value] for value in sorted(values)[:1]]
|
||||
for value in sorted(values)[1:]:
|
||||
center = float(statistics.median(clusters[-1]))
|
||||
if abs(value - center) <= tolerance:
|
||||
clusters[-1].append(value)
|
||||
else:
|
||||
clusters.append([value])
|
||||
return [(float(statistics.median(cluster)), tuple(cluster)) for cluster in clusters]
|
||||
|
||||
|
||||
def _local_rules(
|
||||
table_input: NativeTableInput,
|
||||
width: float,
|
||||
height: float,
|
||||
) -> tuple[_LocalRule, ...]:
|
||||
"""把页面 drawing 线转换为按局部长轴重新判向的规则区间。"""
|
||||
|
||||
table_bbox = normalize_bbox(table_input.table_bbox)
|
||||
if table_bbox is None:
|
||||
return ()
|
||||
angle = normalize_angle(table_input.angle)
|
||||
output: list[_LocalRule] = []
|
||||
for rule in table_input.drawing_lines:
|
||||
bbox = normalize_bbox(rule.bbox)
|
||||
if bbox is None:
|
||||
continue
|
||||
local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
|
||||
if local_bbox is None:
|
||||
continue
|
||||
local_width = local_bbox[2] - local_bbox[0]
|
||||
local_height = local_bbox[3] - local_bbox[1]
|
||||
if local_width >= max(1.0, 3.0 * max(local_height, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="horizontal",
|
||||
coordinate=(local_bbox[1] + local_bbox[3]) / 2.0,
|
||||
start=max(0.0, local_bbox[0]),
|
||||
end=min(width, local_bbox[2]),
|
||||
width=max(rule.width, local_height),
|
||||
)
|
||||
)
|
||||
elif local_height >= max(1.0, 3.0 * max(local_width, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="vertical",
|
||||
coordinate=(local_bbox[0] + local_bbox[2]) / 2.0,
|
||||
start=max(0.0, local_bbox[1]),
|
||||
end=min(height, local_bbox[3]),
|
||||
width=max(rule.width, local_width),
|
||||
)
|
||||
)
|
||||
return tuple(output)
|
||||
|
||||
|
||||
def _long_horizontal_rules(
|
||||
rules: tuple[_LocalRule, ...],
|
||||
width: float,
|
||||
@@ -142,7 +70,7 @@ def _vertical_track_evidence(
|
||||
"""合并同 X 分段竖线并返回覆盖足够的物理列轨。"""
|
||||
|
||||
vertical_rules = [rule for rule in rules if rule.orientation == "vertical"]
|
||||
clusters = _cluster_with_members(
|
||||
clusters = cluster_members(
|
||||
[rule.coordinate for rule in vertical_rules],
|
||||
tolerance,
|
||||
)
|
||||
@@ -199,7 +127,7 @@ def _rectangle_edge_evidence(
|
||||
|
||||
supported = [
|
||||
coordinate
|
||||
for coordinate, members in _cluster_with_members(raw_edges, tolerance)
|
||||
for coordinate, members in cluster_members(raw_edges, tolerance)
|
||||
if len(members) >= 2 or coordinate <= tolerance or width - coordinate <= tolerance
|
||||
]
|
||||
return tuple(sorted({0.0, width, *supported}))
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .sparse_common import _LocalRule, _local_rules, cluster_members
|
||||
from .candidate import GridCellSpec, build_candidate
|
||||
from .contracts import (
|
||||
NativeTableCandidate,
|
||||
@@ -28,17 +29,6 @@ from .geometry import (
|
||||
MIN_MULTILINE_RELIABILITY = 0.98
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LocalRule:
|
||||
"""保存正向表格局部坐标中的线段区间。"""
|
||||
|
||||
orientation: str
|
||||
coordinate: float
|
||||
start: float
|
||||
end: float
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LocalRectangle:
|
||||
"""保存正向表格局部坐标中的矩形证据。"""
|
||||
@@ -67,68 +57,6 @@ class _LogicalRow:
|
||||
bottom: float
|
||||
|
||||
|
||||
def _cluster_members(
|
||||
values: list[float],
|
||||
tolerance: float,
|
||||
) -> list[tuple[float, tuple[float, ...]]]:
|
||||
"""按相邻距离聚类坐标并保留每簇成员。"""
|
||||
|
||||
if not values:
|
||||
return []
|
||||
clusters: list[list[float]] = [[value] for value in sorted(values)[:1]]
|
||||
for value in sorted(values)[1:]:
|
||||
center = float(statistics.median(clusters[-1]))
|
||||
if abs(value - center) <= tolerance:
|
||||
clusters[-1].append(value)
|
||||
else:
|
||||
clusters.append([value])
|
||||
return [(float(statistics.median(cluster)), tuple(cluster)) for cluster in clusters]
|
||||
|
||||
|
||||
def _local_rules(
|
||||
table_input: NativeTableInput,
|
||||
width: float,
|
||||
height: float,
|
||||
) -> tuple[_LocalRule, ...]:
|
||||
"""把页面 drawing 转换为局部横竖线证据。"""
|
||||
|
||||
table_bbox = normalize_bbox(table_input.table_bbox)
|
||||
if table_bbox is None:
|
||||
return ()
|
||||
angle = normalize_angle(table_input.angle)
|
||||
output: list[_LocalRule] = []
|
||||
for rule in table_input.drawing_lines:
|
||||
bbox = normalize_bbox(rule.bbox)
|
||||
if bbox is None:
|
||||
continue
|
||||
local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
|
||||
if local_bbox is None:
|
||||
continue
|
||||
local_width = local_bbox[2] - local_bbox[0]
|
||||
local_height = local_bbox[3] - local_bbox[1]
|
||||
if local_width >= max(1.0, 3.0 * max(local_height, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="horizontal",
|
||||
coordinate=(local_bbox[1] + local_bbox[3]) / 2.0,
|
||||
start=max(0.0, local_bbox[0]),
|
||||
end=min(width, local_bbox[2]),
|
||||
width=max(rule.width, local_height),
|
||||
)
|
||||
)
|
||||
elif local_height >= max(1.0, 3.0 * max(local_width, 0.1)):
|
||||
output.append(
|
||||
_LocalRule(
|
||||
orientation="vertical",
|
||||
coordinate=(local_bbox[0] + local_bbox[2]) / 2.0,
|
||||
start=max(0.0, local_bbox[1]),
|
||||
end=min(height, local_bbox[3]),
|
||||
width=max(rule.width, local_width),
|
||||
)
|
||||
)
|
||||
return tuple(output)
|
||||
|
||||
|
||||
def _local_rectangles(
|
||||
table_input: NativeTableInput,
|
||||
width: float,
|
||||
@@ -268,7 +196,7 @@ def _rectangle_tracks(
|
||||
|
||||
edges = [coordinate for rectangle in rectangles for coordinate in (rectangle.bbox[0], rectangle.bbox[2])]
|
||||
positions: list[float] = [0.0, width]
|
||||
for coordinate, members in _cluster_members(edges, tolerance):
|
||||
for coordinate, members in cluster_members(edges, tolerance):
|
||||
if len(members) < 2:
|
||||
continue
|
||||
snapped = 0.0 if coordinate <= outer_tolerance else width if width - coordinate <= outer_tolerance else coordinate
|
||||
@@ -295,7 +223,7 @@ def _filled_band_count(
|
||||
if bottom - top < 0.75 * median_height or right - left < 0.08 * width:
|
||||
continue
|
||||
bands.append((top, bottom))
|
||||
clustered = _cluster_members(
|
||||
clustered = cluster_members(
|
||||
[(top + bottom) / 2.0 for top, bottom in bands],
|
||||
max(tolerance, 0.50 * median_height),
|
||||
)
|
||||
|
||||
@@ -1152,27 +1152,57 @@ def _looks_like_single_column_tracks(
|
||||
)
|
||||
|
||||
|
||||
def _build_vector_candidate(
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _VectorTracks:
|
||||
"""保存已通过别名、尺寸和物理行数校验的规范轨道。"""
|
||||
|
||||
snap_tolerance: float
|
||||
local_width: float
|
||||
rules: list[_MergedRule]
|
||||
canonical_x_tracks: list[_CanonicalTrack]
|
||||
canonical_y_tracks: list[_CanonicalTrack]
|
||||
x_tracks: list[float]
|
||||
y_tracks: list[float]
|
||||
narrow_empty_threshold: float
|
||||
is_line_grid: bool
|
||||
is_single_row_shape: bool
|
||||
is_single_column_shape: bool
|
||||
rows: int
|
||||
cols: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _VectorTopology:
|
||||
"""保存隔断连接后的逻辑单元格及独立物理证据。"""
|
||||
|
||||
specs: tuple[GridCellSpec, ...]
|
||||
separator_decisions: list[float]
|
||||
ambiguous_ratio: float
|
||||
alias_separator_recoveries: int
|
||||
y_alias_separator_recoveries: int
|
||||
alias_affected_rows: set[int]
|
||||
single_row_evidence: _SingleRowEvidence | None
|
||||
single_column_evidence: _SingleColumnEvidence | None
|
||||
|
||||
|
||||
def _reject_vector_candidate(diagnostics: dict[str, Any] | None, gate: str) -> None:
|
||||
"""记录当前假设的首个拒绝门,供各显式阶段保留统一诊断行为。"""
|
||||
|
||||
if diagnostics is not None:
|
||||
diagnostics["first_rejection_gate"] = gate
|
||||
return None
|
||||
|
||||
|
||||
def _build_vector_tracks(
|
||||
table_input: NativeTableInput,
|
||||
text: NativeTableText,
|
||||
*,
|
||||
include_drawing: bool,
|
||||
include_rectangles: bool,
|
||||
evidence_label: str,
|
||||
prune_unsupported_horizontal: bool = False,
|
||||
diagnostics: dict[str, Any] | None = None,
|
||||
) -> NativeTableCandidate | None:
|
||||
"""按单一证据来源构造一个显式 rowspan/colspan 候选。"""
|
||||
|
||||
if diagnostics is not None:
|
||||
diagnostics["evidence"] = evidence_label
|
||||
|
||||
def reject(gate: str) -> None:
|
||||
"""记录当前假设的首个拒绝门并返回空候选。"""
|
||||
|
||||
if diagnostics is not None:
|
||||
diagnostics["first_rejection_gate"] = gate
|
||||
return None
|
||||
prune_unsupported_horizontal: bool,
|
||||
diagnostics: dict[str, Any] | None,
|
||||
) -> _VectorTracks | None:
|
||||
"""构造并规范化轨道,保持 halo、别名及物理行数的原有拒绝顺序。"""
|
||||
|
||||
snap_tolerance = clamp(
|
||||
0.08 * text.median_glyph_height,
|
||||
@@ -1195,7 +1225,7 @@ def _build_vector_candidate(
|
||||
)
|
||||
table_bbox = normalize_bbox(table_input.table_bbox)
|
||||
if table_bbox is None:
|
||||
return reject("table_geometry")
|
||||
return _reject_vector_candidate(diagnostics, "table_geometry")
|
||||
local_width, local_height = table_local_size(
|
||||
table_bbox,
|
||||
normalize_angle(table_input.angle),
|
||||
@@ -1208,7 +1238,7 @@ def _build_vector_candidate(
|
||||
evidence_halo=0.0,
|
||||
)
|
||||
if not exact_fragments or len(exact_fragments) > MAX_PRIMITIVES_PER_TABLE:
|
||||
return reject("raw_fragments")
|
||||
return _reject_vector_candidate(diagnostics, "raw_fragments")
|
||||
exact_rules = _merge_rule_fragments(
|
||||
exact_fragments,
|
||||
snap_tolerance,
|
||||
@@ -1295,7 +1325,7 @@ def _build_vector_candidate(
|
||||
snap_tolerance,
|
||||
)
|
||||
):
|
||||
return reject("rect_lattice")
|
||||
return _reject_vector_candidate(diagnostics, "rect_lattice")
|
||||
line_widths = [rule.width for rule in table_input.drawing_lines if rule.width > 0]
|
||||
median_line_width = float(statistics.median(line_widths)) if line_widths else 0.0
|
||||
narrow_empty_threshold = max(
|
||||
@@ -1347,7 +1377,7 @@ def _build_vector_candidate(
|
||||
snap_tolerance,
|
||||
)
|
||||
):
|
||||
return reject("canonical_alias")
|
||||
return _reject_vector_candidate(diagnostics, "canonical_alias")
|
||||
x_tracks = _canonical_track_coordinates(canonical_x_tracks)
|
||||
y_tracks = _canonical_track_coordinates(canonical_y_tracks)
|
||||
if diagnostics is not None:
|
||||
@@ -1373,7 +1403,7 @@ def _build_vector_candidate(
|
||||
right - left <= narrow_empty_threshold and not any(left < center < right for center in glyph_centers_x)
|
||||
for left, right in zip(x_tracks, x_tracks[1:])
|
||||
):
|
||||
return reject("remaining_narrow_track")
|
||||
return _reject_vector_candidate(diagnostics, "remaining_narrow_track")
|
||||
if any(
|
||||
bottom_track.coordinate - top_track.coordinate <= narrow_empty_threshold
|
||||
and not any(top_track.coordinate < center < bottom_track.coordinate for center in glyph_centers_y)
|
||||
@@ -1400,7 +1430,7 @@ def _build_vector_candidate(
|
||||
canonical_y_tracks[1:],
|
||||
)
|
||||
):
|
||||
return reject("remaining_narrow_track")
|
||||
return _reject_vector_candidate(diagnostics, "remaining_narrow_track")
|
||||
is_line_grid = include_drawing and not include_rectangles
|
||||
is_single_row_shape = is_line_grid and len(y_tracks) == 2 and len(x_tracks) >= 3
|
||||
is_single_column_shape = is_line_grid and len(x_tracks) == 2 and len(y_tracks) >= 3
|
||||
@@ -1410,13 +1440,13 @@ def _build_vector_candidate(
|
||||
or len(x_tracks) > MAX_TRACKS_PER_AXIS
|
||||
or len(y_tracks) > MAX_TRACKS_PER_AXIS
|
||||
):
|
||||
return reject("track_count")
|
||||
return _reject_vector_candidate(diagnostics, "track_count")
|
||||
rows = len(y_tracks) - 1
|
||||
cols = len(x_tracks) - 1
|
||||
if diagnostics is not None:
|
||||
diagnostics["grid"] = {"rows": rows, "cols": cols}
|
||||
if rows * cols > MAX_ATOMIC_CELLS:
|
||||
return reject("atomic_cell_limit")
|
||||
return _reject_vector_candidate(diagnostics, "atomic_cell_limit")
|
||||
dense_baseline_pairs = (
|
||||
_physical_row_dense_baseline_pairs(
|
||||
text,
|
||||
@@ -1429,7 +1459,43 @@ def _build_vector_candidate(
|
||||
if diagnostics is not None:
|
||||
diagnostics["physical_row_dense_baseline_pairs"] = list(dense_baseline_pairs)
|
||||
if dense_baseline_pairs:
|
||||
return reject("physical_row_undercount")
|
||||
return _reject_vector_candidate(diagnostics, "physical_row_undercount")
|
||||
|
||||
return _VectorTracks(
|
||||
snap_tolerance=snap_tolerance,
|
||||
local_width=local_width,
|
||||
rules=rules,
|
||||
canonical_x_tracks=canonical_x_tracks,
|
||||
canonical_y_tracks=canonical_y_tracks,
|
||||
x_tracks=x_tracks,
|
||||
y_tracks=y_tracks,
|
||||
narrow_empty_threshold=narrow_empty_threshold,
|
||||
is_line_grid=is_line_grid,
|
||||
is_single_row_shape=is_single_row_shape,
|
||||
is_single_column_shape=is_single_column_shape,
|
||||
rows=rows,
|
||||
cols=cols,
|
||||
)
|
||||
|
||||
|
||||
def _build_vector_topology(
|
||||
tracks: _VectorTracks,
|
||||
text: NativeTableText,
|
||||
diagnostics: dict[str, Any] | None,
|
||||
) -> _VectorTopology | None:
|
||||
"""连接原子格并验证矩形拓扑,保留单行和单列的独立物理证据。"""
|
||||
|
||||
snap_tolerance = tracks.snap_tolerance
|
||||
rules = tracks.rules
|
||||
canonical_x_tracks = tracks.canonical_x_tracks
|
||||
canonical_y_tracks = tracks.canonical_y_tracks
|
||||
x_tracks = tracks.x_tracks
|
||||
y_tracks = tracks.y_tracks
|
||||
narrow_empty_threshold = tracks.narrow_empty_threshold
|
||||
is_single_row_shape = tracks.is_single_row_shape
|
||||
is_single_column_shape = tracks.is_single_column_shape
|
||||
rows = tracks.rows
|
||||
cols = tracks.cols
|
||||
|
||||
union_find = _UnionFind(rows * cols)
|
||||
separator_decisions: list[float] = []
|
||||
@@ -1517,7 +1583,7 @@ def _build_vector_candidate(
|
||||
diagnostics["y_alias_separator_recoveries"] = y_alias_separator_recoveries
|
||||
diagnostics["alias_affected_rows"] = sorted(alias_affected_rows)
|
||||
if ambiguous_ratio > 0.05:
|
||||
return reject("ambiguous_separator")
|
||||
return _reject_vector_candidate(diagnostics, "ambiguous_separator")
|
||||
|
||||
single_row_evidence: _SingleRowEvidence | None = None
|
||||
if is_single_row_shape:
|
||||
@@ -1541,7 +1607,7 @@ def _build_vector_candidate(
|
||||
"glyph_crossing": single_row_evidence.glyph_crossing,
|
||||
}
|
||||
if not single_row_evidence.verified:
|
||||
return reject("single_row_physical_evidence")
|
||||
return _reject_vector_candidate(diagnostics, "single_row_physical_evidence")
|
||||
|
||||
single_column_evidence: _SingleColumnEvidence | None = None
|
||||
if is_single_column_shape:
|
||||
@@ -1565,7 +1631,7 @@ def _build_vector_candidate(
|
||||
"glyph_crossing": single_column_evidence.glyph_crossing,
|
||||
}
|
||||
if not single_column_evidence.verified:
|
||||
return reject("single_column_physical_evidence")
|
||||
return _reject_vector_candidate(diagnostics, "single_column_physical_evidence")
|
||||
|
||||
specs = _build_component_specs(
|
||||
union_find,
|
||||
@@ -1575,11 +1641,55 @@ def _build_vector_candidate(
|
||||
y_tracks,
|
||||
)
|
||||
if specs is None:
|
||||
return reject("nonrectangular_topology")
|
||||
return _reject_vector_candidate(diagnostics, "nonrectangular_topology")
|
||||
maximum_row_cells = max(sum(spec.row <= row_index < spec.row + spec.rowspan for spec in specs) for row_index in range(rows))
|
||||
maximum_col_cells = max(sum(spec.col <= col_index < spec.col + spec.colspan for spec in specs) for col_index in range(cols))
|
||||
if (maximum_row_cells < 2 and not is_single_column_shape) or (maximum_col_cells < 2 and not is_single_row_shape):
|
||||
return reject("degenerate_grid")
|
||||
return _reject_vector_candidate(diagnostics, "degenerate_grid")
|
||||
return _VectorTopology(
|
||||
specs=specs,
|
||||
separator_decisions=separator_decisions,
|
||||
ambiguous_ratio=ambiguous_ratio,
|
||||
alias_separator_recoveries=alias_separator_recoveries,
|
||||
y_alias_separator_recoveries=y_alias_separator_recoveries,
|
||||
alias_affected_rows=alias_affected_rows,
|
||||
single_row_evidence=single_row_evidence,
|
||||
single_column_evidence=single_column_evidence,
|
||||
)
|
||||
|
||||
|
||||
def _materialize_vector_candidate(
|
||||
tracks: _VectorTracks,
|
||||
topology: _VectorTopology,
|
||||
text: NativeTableText,
|
||||
evidence_label: str,
|
||||
diagnostics: dict[str, Any] | None,
|
||||
) -> NativeTableCandidate | None:
|
||||
"""将文本落格并评分,按原顺序执行完整性与空行发布门。"""
|
||||
|
||||
snap_tolerance = tracks.snap_tolerance
|
||||
local_width = tracks.local_width
|
||||
rules = tracks.rules
|
||||
canonical_x_tracks = tracks.canonical_x_tracks
|
||||
canonical_y_tracks = tracks.canonical_y_tracks
|
||||
x_tracks = tracks.x_tracks
|
||||
y_tracks = tracks.y_tracks
|
||||
narrow_empty_threshold = tracks.narrow_empty_threshold
|
||||
is_line_grid = tracks.is_line_grid
|
||||
is_single_row_shape = tracks.is_single_row_shape
|
||||
is_single_column_shape = tracks.is_single_column_shape
|
||||
rows = tracks.rows
|
||||
cols = tracks.cols
|
||||
|
||||
specs = topology.specs
|
||||
separator_decisions = topology.separator_decisions
|
||||
ambiguous_ratio = topology.ambiguous_ratio
|
||||
alias_separator_recoveries = topology.alias_separator_recoveries
|
||||
y_alias_separator_recoveries = topology.y_alias_separator_recoveries
|
||||
alias_affected_rows = topology.alias_affected_rows
|
||||
single_row_evidence = topology.single_row_evidence
|
||||
single_column_evidence = topology.single_column_evidence
|
||||
|
||||
decisiveness = float(statistics.mean(separator_decisions)) if separator_decisions else 1.0
|
||||
if single_row_evidence is not None:
|
||||
decisiveness = max(
|
||||
@@ -1684,11 +1794,11 @@ def _build_vector_candidate(
|
||||
if diagnostics is not None and diagnostics.get("candidate_rejection_gate")
|
||||
else "candidate_hard_gate"
|
||||
)
|
||||
return reject(candidate_gate)
|
||||
return _reject_vector_candidate(diagnostics, candidate_gate)
|
||||
if is_single_row_shape and (candidate.text_capture < 1.0 or candidate.order_consistency < 1.0):
|
||||
return reject("single_row_text_integrity")
|
||||
return _reject_vector_candidate(diagnostics, "single_row_text_integrity")
|
||||
if is_single_column_shape and (candidate.text_capture < 1.0 or candidate.order_consistency < 1.0):
|
||||
return reject("single_column_text_integrity")
|
||||
return _reject_vector_candidate(diagnostics, "single_column_text_integrity")
|
||||
row_content_support = [
|
||||
sum(bool(cell.content.strip()) for cell in candidate.cells if cell.row <= row_index < cell.row + cell.rowspan)
|
||||
for row_index in range(candidate.rows)
|
||||
@@ -1702,7 +1812,7 @@ def _build_vector_candidate(
|
||||
):
|
||||
if diagnostics is not None:
|
||||
diagnostics["empty_rows"] = sorted(empty_rows)
|
||||
return reject("empty_row")
|
||||
return _reject_vector_candidate(diagnostics, "empty_row")
|
||||
if diagnostics is not None:
|
||||
diagnostics["first_rejection_gate"] = None
|
||||
diagnostics["score"] = candidate.score
|
||||
@@ -1710,6 +1820,36 @@ def _build_vector_candidate(
|
||||
return candidate
|
||||
|
||||
|
||||
def _build_vector_candidate(
|
||||
table_input: NativeTableInput,
|
||||
text: NativeTableText,
|
||||
*,
|
||||
include_drawing: bool,
|
||||
include_rectangles: bool,
|
||||
evidence_label: str,
|
||||
prune_unsupported_horizontal: bool = False,
|
||||
diagnostics: dict[str, Any] | None = None,
|
||||
) -> NativeTableCandidate | None:
|
||||
"""按轨道、拓扑、文本落格及评分的固定顺序构造矢量候选。"""
|
||||
|
||||
if diagnostics is not None:
|
||||
diagnostics["evidence"] = evidence_label
|
||||
tracks = _build_vector_tracks(
|
||||
table_input,
|
||||
text,
|
||||
include_drawing=include_drawing,
|
||||
include_rectangles=include_rectangles,
|
||||
prune_unsupported_horizontal=prune_unsupported_horizontal,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
if tracks is None:
|
||||
return None
|
||||
topology = _build_vector_topology(tracks, text, diagnostics)
|
||||
if topology is None:
|
||||
return None
|
||||
return _materialize_vector_candidate(tracks, topology, text, evidence_label, diagnostics)
|
||||
|
||||
|
||||
def build_vector_candidates(
|
||||
table_input: NativeTableInput,
|
||||
text: NativeTableText,
|
||||
|
||||
@@ -27,7 +27,7 @@ from .table_recovery.contracts import (
|
||||
)
|
||||
from .table_recovery.geometry import page_bbox_to_table_local
|
||||
from .table_recovery.text import build_cell_text_parts
|
||||
from .text_styles import _fraction_member_indices, _script_line_char_roles
|
||||
from .inline.scripts import _fraction_member_indices, _script_line_char_roles
|
||||
|
||||
|
||||
def _table_char_map(chars: tuple[Char, ...]) -> dict[int, Char]:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""按职责拆分的 Flash PDF 内部实现。"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,589 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""组装跨行标题、图片注释、页眉及首页信息块。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Any
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import (
|
||||
_bbox_axis_overlap_ratio,
|
||||
_bbox_center_x,
|
||||
_bbox_center_y,
|
||||
_bbox_union_many,
|
||||
)
|
||||
from ..line_layout import (
|
||||
_is_structural_typography_gap,
|
||||
)
|
||||
from .common import _FIGURE_CAPTION_MARKER_RE, _components_share_lane_role, _merge_internal_text_block_group
|
||||
|
||||
|
||||
def _merge_image_caption_text_blocks(
|
||||
blocks: list[dict[str, Any]],
|
||||
image_bboxes: list[BBox],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""在图像邻接已成立后,用通用图注标记确认锚点并吸收同字体续行。"""
|
||||
|
||||
if not image_bboxes:
|
||||
return blocks
|
||||
text_indices = [
|
||||
index
|
||||
for index, block in enumerate(blocks)
|
||||
if block.get("type") == "text"
|
||||
and isinstance(block.get("content"), str)
|
||||
and isinstance(block.get("bbox"), (list, tuple))
|
||||
]
|
||||
all_heights = [
|
||||
float(height)
|
||||
for index in text_indices
|
||||
for height in blocks[index].get("_line_heights", [])
|
||||
if isinstance(height, (int, float)) and height > 0
|
||||
]
|
||||
median_height = statistics.median(all_heights) if all_heights else 1.0
|
||||
caption_image_bboxes = _caption_image_group_bboxes(
|
||||
image_bboxes,
|
||||
median_height,
|
||||
)
|
||||
seed_indices = {
|
||||
index
|
||||
for index in text_indices
|
||||
if _FIGURE_CAPTION_MARKER_RE.match(str(blocks[index]["content"]).strip())
|
||||
and any(
|
||||
_caption_seed_matches_image(
|
||||
blocks[index],
|
||||
image_bbox,
|
||||
median_height,
|
||||
)
|
||||
for image_bbox in caption_image_bboxes
|
||||
)
|
||||
}
|
||||
if not seed_indices:
|
||||
return blocks
|
||||
|
||||
assignments: dict[int, list[int]] = {index: [] for index in seed_indices}
|
||||
for candidate_index in text_indices:
|
||||
if candidate_index in seed_indices:
|
||||
continue
|
||||
candidate = blocks[candidate_index]
|
||||
matches: list[tuple[float, float, int]] = []
|
||||
for seed_index in seed_indices:
|
||||
seed = blocks[seed_index]
|
||||
if not _caption_tail_matches_seed(
|
||||
seed,
|
||||
candidate,
|
||||
median_height,
|
||||
):
|
||||
continue
|
||||
seed_bbox = seed["bbox"]
|
||||
candidate_bbox = candidate["bbox"]
|
||||
matches.append(
|
||||
(
|
||||
_bbox_center_y(candidate_bbox) - _bbox_center_y(seed_bbox),
|
||||
abs(_bbox_center_x(candidate_bbox) - _bbox_center_x(seed_bbox)),
|
||||
seed_index,
|
||||
)
|
||||
)
|
||||
if matches:
|
||||
assignments[min(matches)[2]].append(candidate_index)
|
||||
|
||||
merged_indices: set[int] = set()
|
||||
replacements: dict[int, dict[str, Any]] = {}
|
||||
for seed_index, tail_indices in assignments.items():
|
||||
if not tail_indices:
|
||||
continue
|
||||
group_indices = [seed_index, *tail_indices]
|
||||
replacements[seed_index] = _merge_internal_text_block_group(
|
||||
blocks,
|
||||
group_indices,
|
||||
)
|
||||
merged_indices.update(tail_indices)
|
||||
return [replacements.get(index, block) for index, block in enumerate(blocks) if index not in merged_indices]
|
||||
|
||||
|
||||
def _caption_image_group_bboxes(
|
||||
image_bboxes: list[BBox],
|
||||
median_height: float,
|
||||
) -> list[BBox]:
|
||||
"""合并同一视觉行的并排图片 bbox,使跨多图的统一图注也能建立邻接。"""
|
||||
|
||||
remaining = list(image_bboxes)
|
||||
grouped_bboxes = list(image_bboxes)
|
||||
while remaining:
|
||||
group = [remaining.pop(0)]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for candidate in list(remaining):
|
||||
aligned = False
|
||||
for member in group:
|
||||
overlap = max(
|
||||
0.0,
|
||||
min(candidate[3], member[3]) - max(candidate[1], member[1]),
|
||||
)
|
||||
minimum_height = max(
|
||||
0.1,
|
||||
min(
|
||||
candidate[3] - candidate[1],
|
||||
member[3] - member[1],
|
||||
),
|
||||
)
|
||||
horizontal_gap = max(
|
||||
0.0,
|
||||
max(candidate[0], member[0]) - min(candidate[2], member[2]),
|
||||
)
|
||||
if overlap / minimum_height >= 0.7 and horizontal_gap <= 2.0 * median_height:
|
||||
aligned = True
|
||||
break
|
||||
if aligned:
|
||||
group.append(candidate)
|
||||
remaining.remove(candidate)
|
||||
changed = True
|
||||
if len(group) >= 2:
|
||||
grouped_bboxes.append(_bbox_union_many(group))
|
||||
return grouped_bboxes
|
||||
|
||||
|
||||
def _caption_seed_matches_image(
|
||||
block: dict[str, Any],
|
||||
image_bbox: BBox,
|
||||
median_height: float,
|
||||
) -> bool:
|
||||
"""用上下位置、水平投影和居中关系确认图像下方的图注空间候选。"""
|
||||
|
||||
bbox = block["bbox"]
|
||||
image_width = max(0.1, image_bbox[2] - image_bbox[0])
|
||||
block_width = max(0.1, bbox[2] - bbox[0])
|
||||
vertical_gap = max(0.0, bbox[1] - image_bbox[3])
|
||||
return (
|
||||
_bbox_center_y(bbox) >= image_bbox[3] - 0.25 * median_height
|
||||
and vertical_gap <= 2.5 * median_height
|
||||
and _bbox_axis_overlap_ratio(bbox, image_bbox, axis="x") >= 0.35
|
||||
and abs(_bbox_center_x(bbox) - _bbox_center_x(image_bbox)) <= 0.35 * max(image_width, block_width)
|
||||
and block_width <= 1.75 * image_width
|
||||
)
|
||||
|
||||
|
||||
def _caption_body_has_structural_gap(
|
||||
seed: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> bool:
|
||||
"""用图注末行、候选首行和图注内部行距阻止跨排版层级回并。"""
|
||||
|
||||
seed_bboxes = seed.get("_local_line_bboxes")
|
||||
seed_heights = seed.get("_line_heights")
|
||||
candidate_bboxes = candidate.get("_local_line_bboxes")
|
||||
candidate_heights = candidate.get("_line_heights")
|
||||
if not (
|
||||
isinstance(seed_bboxes, list)
|
||||
and isinstance(seed_heights, list)
|
||||
and len(seed_bboxes) == len(seed_heights)
|
||||
and seed_bboxes
|
||||
and isinstance(candidate_bboxes, list)
|
||||
and isinstance(candidate_heights, list)
|
||||
and len(candidate_bboxes) == len(candidate_heights)
|
||||
and candidate_bboxes
|
||||
):
|
||||
return False
|
||||
|
||||
seed_rows = sorted(
|
||||
zip(seed_bboxes, seed_heights, strict=True),
|
||||
key=lambda item: (item[0][1], item[0][0]),
|
||||
)
|
||||
candidate_rows = sorted(
|
||||
zip(candidate_bboxes, candidate_heights, strict=True),
|
||||
key=lambda item: (item[0][1], item[0][0]),
|
||||
)
|
||||
previous_bbox, previous_height = seed_rows[-1]
|
||||
current_bbox, current_height = candidate_rows[0]
|
||||
internal_gaps = [
|
||||
max(0.0, current[0][1] - (previous[0][1] + float(previous[1]))) for previous, current in zip(seed_rows, seed_rows[1:])
|
||||
]
|
||||
regular_gap = statistics.median(internal_gaps) if internal_gaps else 0.0
|
||||
gap_mad = statistics.median(abs(gap - regular_gap) for gap in internal_gaps) if internal_gaps else 0.0
|
||||
seed_fonts = seed.get("_font_signatures")
|
||||
candidate_fonts = candidate.get("_font_signatures")
|
||||
reliable_style_change = (
|
||||
isinstance(seed_fonts, set)
|
||||
and bool(seed_fonts)
|
||||
and isinstance(candidate_fonts, set)
|
||||
and bool(candidate_fonts)
|
||||
and seed_fonts.isdisjoint(candidate_fonts)
|
||||
)
|
||||
return _is_structural_typography_gap(
|
||||
float(previous_height),
|
||||
float(current_height),
|
||||
current_bbox[1] - (previous_bbox[1] + float(previous_height)),
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
reliable_style_change=reliable_style_change,
|
||||
)
|
||||
|
||||
|
||||
def _caption_tail_matches_seed(
|
||||
seed: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
median_height: float,
|
||||
) -> bool:
|
||||
"""只用同栏角色、字体、邻接和投影把无标记的图注续行接回锚点。"""
|
||||
|
||||
seed_bbox = seed["bbox"]
|
||||
candidate_bbox = candidate["bbox"]
|
||||
if not _components_share_lane_role(seed, candidate, median_height) and (
|
||||
_bbox_axis_overlap_ratio(seed_bbox, candidate_bbox, axis="x") < 0.75
|
||||
or abs(seed_bbox[0] - candidate_bbox[0]) > median_height
|
||||
):
|
||||
return False
|
||||
if _bbox_center_y(candidate_bbox) <= _bbox_center_y(seed_bbox):
|
||||
return False
|
||||
if _caption_body_has_structural_gap(seed, candidate):
|
||||
return False
|
||||
vertical_gap = max(0.0, candidate_bbox[1] - seed_bbox[3])
|
||||
if vertical_gap > 0.5 * median_height or _bbox_axis_overlap_ratio(seed_bbox, candidate_bbox, axis="x") < 0.35:
|
||||
return False
|
||||
seed_fonts = seed.get("_font_signatures")
|
||||
candidate_fonts = candidate.get("_font_signatures")
|
||||
return not (
|
||||
isinstance(seed_fonts, set)
|
||||
and seed_fonts
|
||||
and isinstance(candidate_fonts, set)
|
||||
and candidate_fonts
|
||||
and seed_fonts.isdisjoint(candidate_fonts)
|
||||
)
|
||||
|
||||
|
||||
def _merge_multiline_title_blocks(
|
||||
blocks: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""跨错误栏带合并紧贴且字体兼容的多行文档标题和段落标题。"""
|
||||
|
||||
replacements: dict[int, dict[str, Any]] = {}
|
||||
consumed: set[int] = set()
|
||||
for block_type in ("doc_title", "paragraph_title"):
|
||||
indices = [
|
||||
index
|
||||
for index, block in enumerate(blocks)
|
||||
if block.get("type") == block_type and isinstance(block.get("bbox"), (list, tuple))
|
||||
]
|
||||
indices.sort(
|
||||
key=lambda index: (
|
||||
blocks[index]["bbox"][1],
|
||||
blocks[index]["bbox"][0],
|
||||
)
|
||||
)
|
||||
groups: list[list[int]] = []
|
||||
for index in indices:
|
||||
if not groups:
|
||||
groups.append([index])
|
||||
continue
|
||||
previous_index = groups[-1][-1]
|
||||
previous = blocks[previous_index]
|
||||
current = blocks[index]
|
||||
previous_bbox = previous["bbox"]
|
||||
current_bbox = current["bbox"]
|
||||
previous_heights = previous.get("_line_heights", [])
|
||||
current_heights = current.get("_line_heights", [])
|
||||
previous_height = (
|
||||
statistics.median(previous_heights)
|
||||
if isinstance(previous_heights, list) and previous_heights
|
||||
else previous_bbox[3] - previous_bbox[1]
|
||||
)
|
||||
current_height = (
|
||||
statistics.median(current_heights)
|
||||
if isinstance(current_heights, list) and current_heights
|
||||
else current_bbox[3] - current_bbox[1]
|
||||
)
|
||||
vertical_gap = current_bbox[1] - previous_bbox[3]
|
||||
previous_fonts = previous.get("_font_signatures")
|
||||
current_fonts = current.get("_font_signatures")
|
||||
fonts_conflict = (
|
||||
isinstance(previous_fonts, set)
|
||||
and previous_fonts
|
||||
and isinstance(current_fonts, set)
|
||||
and current_fonts
|
||||
and previous_fonts.isdisjoint(current_fonts)
|
||||
)
|
||||
if (
|
||||
-0.2 * max(previous_height, current_height) <= vertical_gap <= 0.4 * max(previous_height, current_height)
|
||||
and _bbox_axis_overlap_ratio(
|
||||
previous_bbox,
|
||||
current_bbox,
|
||||
axis="x",
|
||||
)
|
||||
>= 0.2
|
||||
and not fonts_conflict
|
||||
):
|
||||
groups[-1].append(index)
|
||||
else:
|
||||
groups.append([index])
|
||||
for group in groups:
|
||||
if len(group) < 2:
|
||||
continue
|
||||
replacements[group[0]] = _merge_internal_text_block_group(
|
||||
blocks,
|
||||
group,
|
||||
)
|
||||
consumed.update(group[1:])
|
||||
return [replacements.get(index, block) for index, block in enumerate(blocks) if index not in consumed]
|
||||
|
||||
|
||||
def _merge_fragmented_header_blocks(
|
||||
blocks: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""聚合同一视觉行中等距分散的页眉页脚片段。"""
|
||||
|
||||
grouped: dict[tuple[int, int], list[int]] = {}
|
||||
for index, block in enumerate(blocks):
|
||||
row_id = block.get("_single_run_row_id")
|
||||
angle = int(block.get("angle", 0) or 0) % 360
|
||||
if block.get("type") in {"header", "footer"} and isinstance(row_id, int):
|
||||
grouped.setdefault((angle, row_id), []).append(index)
|
||||
|
||||
replacements: dict[int, dict[str, Any]] = {}
|
||||
consumed: set[int] = set()
|
||||
for indices in grouped.values():
|
||||
ordered = sorted(indices, key=lambda index: blocks[index]["bbox"][0])
|
||||
components: list[list[int]] = []
|
||||
for index in ordered:
|
||||
bbox = blocks[index]["bbox"]
|
||||
heights = blocks[index].get("_line_heights", [])
|
||||
effective_height = (
|
||||
statistics.median(heights) if isinstance(heights, list) and heights else max(0.1, bbox[3] - bbox[1])
|
||||
)
|
||||
if blocks[index].get("type") == "header" and bbox[2] - bbox[0] > 1.25 * effective_height:
|
||||
continue
|
||||
if not components:
|
||||
components.append([index])
|
||||
continue
|
||||
previous_index = components[-1][-1]
|
||||
previous_bbox = blocks[previous_index]["bbox"]
|
||||
previous_heights = blocks[previous_index].get("_line_heights", [])
|
||||
previous_height = (
|
||||
statistics.median(previous_heights)
|
||||
if isinstance(previous_heights, list) and previous_heights
|
||||
else max(0.1, previous_bbox[3] - previous_bbox[1])
|
||||
)
|
||||
if (
|
||||
bbox[0] - previous_bbox[2] <= 4.0 * max(effective_height, previous_height)
|
||||
and _bbox_axis_overlap_ratio(previous_bbox, bbox, axis="y") >= 0.5
|
||||
):
|
||||
components[-1].append(index)
|
||||
else:
|
||||
components.append([index])
|
||||
for component in components:
|
||||
if len(component) < 2:
|
||||
continue
|
||||
replacement = _merge_internal_text_block_group(
|
||||
blocks,
|
||||
component,
|
||||
preserve_visual_spaces=True,
|
||||
)
|
||||
replacement["_single_run_row_id"] = None
|
||||
replacements[component[0]] = replacement
|
||||
consumed.update(component[1:])
|
||||
return [replacements.get(index, block) for index, block in enumerate(blocks) if index not in consumed]
|
||||
|
||||
|
||||
def _merge_front_matter_column_blocks(
|
||||
blocks: list[dict[str, Any]],
|
||||
page_size: tuple[float, float],
|
||||
*,
|
||||
page_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""把首页标题下方规则排列的多列作者信息按列聚合。"""
|
||||
|
||||
if page_index != 0:
|
||||
return blocks
|
||||
page_width, page_height = page_size
|
||||
if page_width <= 0 or page_height <= 0:
|
||||
return blocks
|
||||
title_blocks = [
|
||||
block for block in blocks if block.get("type") == "doc_title" and isinstance(block.get("bbox"), (list, tuple))
|
||||
]
|
||||
if not title_blocks:
|
||||
return blocks
|
||||
title_bottom = max(block["bbox"][3] for block in title_blocks)
|
||||
candidates = [
|
||||
index
|
||||
for index, block in enumerate(blocks)
|
||||
if block.get("type") == "text"
|
||||
and isinstance(block.get("bbox"), (list, tuple))
|
||||
and title_bottom < block["bbox"][1]
|
||||
and block["bbox"][3]
|
||||
<= min(
|
||||
0.4 * page_height,
|
||||
title_bottom + 0.22 * page_height,
|
||||
)
|
||||
and block["bbox"][2] - block["bbox"][0] <= 0.32 * page_width
|
||||
and block["bbox"][3] - block["bbox"][1] <= 0.035 * page_height
|
||||
]
|
||||
if len(candidates) < 9:
|
||||
return blocks
|
||||
median_height = statistics.median(blocks[index]["bbox"][3] - blocks[index]["bbox"][1] for index in candidates)
|
||||
row_groups: list[list[int]] = []
|
||||
for index in sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
_bbox_center_y(blocks[item]["bbox"]),
|
||||
blocks[item]["bbox"][0],
|
||||
),
|
||||
):
|
||||
center_y = _bbox_center_y(blocks[index]["bbox"])
|
||||
target = next(
|
||||
(
|
||||
row
|
||||
for row in row_groups
|
||||
if abs(center_y - statistics.median(_bbox_center_y(blocks[member]["bbox"]) for member in row))
|
||||
<= 0.6 * median_height
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
row_groups.append([index])
|
||||
else:
|
||||
target.append(index)
|
||||
dense_rows = [
|
||||
row
|
||||
for row in row_groups
|
||||
if 3 <= len(row) <= 6
|
||||
and (
|
||||
max(blocks[index]["bbox"][2] for index in row) - min(blocks[index]["bbox"][0] for index in row) >= 0.55 * page_width
|
||||
)
|
||||
]
|
||||
if len(dense_rows) < 2:
|
||||
return blocks
|
||||
anchor_row = min(
|
||||
dense_rows,
|
||||
key=lambda row: (
|
||||
-len(row),
|
||||
statistics.median(_bbox_center_y(blocks[index]["bbox"]) for index in row),
|
||||
),
|
||||
)
|
||||
anchor_centers = sorted(_bbox_center_x(blocks[index]["bbox"]) for index in anchor_row)
|
||||
if len(anchor_centers) != 4:
|
||||
return blocks
|
||||
boundaries = [0.5 * (left + right) for left, right in zip(anchor_centers, anchor_centers[1:])]
|
||||
band_top = min(min(blocks[index]["bbox"][1] for index in row) for row in dense_rows) - median_height
|
||||
band_bottom = max(max(blocks[index]["bbox"][3] for index in row) for row in dense_rows) + median_height
|
||||
column_groups: list[list[int]] = [[] for _center in anchor_centers]
|
||||
for index in candidates:
|
||||
bbox = blocks[index]["bbox"]
|
||||
if not band_top <= _bbox_center_y(bbox) <= band_bottom:
|
||||
continue
|
||||
center_x = _bbox_center_x(bbox)
|
||||
column_index = sum(center_x > boundary for boundary in boundaries)
|
||||
if column_index >= len(column_groups):
|
||||
continue
|
||||
column_groups[column_index].append(index)
|
||||
if any(len(group) < 3 for group in column_groups):
|
||||
return blocks
|
||||
|
||||
replacements: dict[int, dict[str, Any]] = {}
|
||||
consumed: set[int] = set()
|
||||
for group in column_groups:
|
||||
ordered = sorted(
|
||||
group,
|
||||
key=lambda index: (
|
||||
blocks[index]["bbox"][1],
|
||||
blocks[index]["bbox"][0],
|
||||
),
|
||||
)
|
||||
replacements[ordered[0]] = _merge_internal_text_block_group(
|
||||
blocks,
|
||||
ordered,
|
||||
)
|
||||
consumed.update(ordered[1:])
|
||||
return [replacements.get(index, block) for index, block in enumerate(blocks) if index not in consumed]
|
||||
|
||||
|
||||
def _merge_repeated_compact_title_continuations(
|
||||
blocks: list[dict[str, Any]],
|
||||
page_size: tuple[float, float],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""把重复出现的两行弱标题与紧邻异字体续行恢复为普通文本块。"""
|
||||
|
||||
candidate_pairs: list[tuple[int, int, float]] = []
|
||||
for title_index, title in enumerate(blocks):
|
||||
title_lines = title.get("_local_line_bboxes")
|
||||
title_fonts = title.get("_font_signatures")
|
||||
title_bbox = title.get("bbox")
|
||||
if (
|
||||
title.get("type") != "paragraph_title"
|
||||
or not isinstance(title_bbox, (list, tuple))
|
||||
or not isinstance(title_lines, list)
|
||||
or len(title_lines) < 2
|
||||
or not isinstance(title_fonts, set)
|
||||
or not title_fonts
|
||||
):
|
||||
continue
|
||||
angle = int(title.get("angle", 0) or 0) % 360
|
||||
local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
|
||||
line_heights = [
|
||||
float(height) for height in title.get("_line_heights", []) if isinstance(height, (int, float)) and height > 0
|
||||
]
|
||||
title_height = statistics.median(line_heights) if line_heights else 0.0
|
||||
if title_height <= 0 or title_bbox[2] - title_bbox[0] > 0.55 * local_page_width:
|
||||
continue
|
||||
|
||||
continuations: list[tuple[float, int]] = []
|
||||
for text_index, text_block in enumerate(blocks):
|
||||
text_bbox = text_block.get("bbox")
|
||||
text_fonts = text_block.get("_font_signatures")
|
||||
if (
|
||||
text_block.get("type") != "text"
|
||||
or int(text_block.get("angle", 0) or 0) % 360 != angle
|
||||
or not isinstance(text_bbox, (list, tuple))
|
||||
or not isinstance(text_fonts, set)
|
||||
or not text_fonts
|
||||
or not title_fonts.isdisjoint(text_fonts)
|
||||
or text_bbox[2] - text_bbox[0] > 0.6 * local_page_width
|
||||
):
|
||||
continue
|
||||
gap = text_bbox[1] - title_bbox[3]
|
||||
if -0.25 * title_height <= gap <= 0.6 * title_height and abs(text_bbox[0] - title_bbox[0]) <= 0.75 * title_height:
|
||||
continuations.append((max(0.0, gap), text_index))
|
||||
if continuations:
|
||||
_gap, text_index = min(continuations)
|
||||
candidate_pairs.append((title_index, text_index, title_height))
|
||||
|
||||
supported_pairs: list[tuple[int, int]] = []
|
||||
for title_index, text_index, title_height in candidate_pairs:
|
||||
title_bbox = blocks[title_index]["bbox"]
|
||||
support_count = sum(
|
||||
abs(blocks[other_title]["bbox"][0] - title_bbox[0]) <= max(title_height, other_height)
|
||||
and 0.75 <= other_height / title_height <= 1.25
|
||||
for other_title, _other_text, other_height in candidate_pairs
|
||||
)
|
||||
if support_count >= 2:
|
||||
supported_pairs.append((title_index, text_index))
|
||||
if not supported_pairs:
|
||||
return blocks
|
||||
|
||||
replacements: dict[int, dict[str, Any]] = {}
|
||||
consumed: set[int] = set()
|
||||
for title_index, text_index in supported_pairs:
|
||||
if title_index in consumed or text_index in consumed:
|
||||
continue
|
||||
merged = _merge_internal_text_block_group(
|
||||
blocks,
|
||||
[title_index, text_index],
|
||||
)
|
||||
merged["type"] = "text"
|
||||
replacements[min(title_index, text_index)] = merged
|
||||
consumed.update({title_index, text_index})
|
||||
return [
|
||||
replacements.get(index, block) for index, block in enumerate(blocks) if index not in consumed or index in replacements
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_merge_image_caption_text_blocks",
|
||||
"_caption_image_group_bboxes",
|
||||
"_caption_seed_matches_image",
|
||||
"_caption_body_has_structural_gap",
|
||||
"_caption_tail_matches_seed",
|
||||
"_merge_multiline_title_blocks",
|
||||
"_merge_fragmented_header_blocks",
|
||||
"_merge_front_matter_column_blocks",
|
||||
"_merge_repeated_compact_title_continuations",
|
||||
]
|
||||
@@ -0,0 +1,297 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""保持既有顺序编排正文行分组与块级组装。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from .....utils.text import is_hyphen_at_line_end
|
||||
from ..geometry import (
|
||||
_bbox_union_many,
|
||||
_rotate_bbox_to_upright,
|
||||
_transform_axis_lines,
|
||||
)
|
||||
from ..line_layout import (
|
||||
_estimate_lane_gap,
|
||||
_infer_text_lanes,
|
||||
_line_effective_height,
|
||||
_should_connect_semantic_rows,
|
||||
_should_connect_text_rows,
|
||||
)
|
||||
from ..models import _AxisLine, _LineItem
|
||||
from ..native_text import _normalize_native_run_text
|
||||
from .common import _PARAGRAPH_FORMULA_CONTEXT_MARKER, _merge_text_line_content
|
||||
from .footnotes import _build_grouped_page_footnote_blocks
|
||||
from .merging import (
|
||||
_merge_inline_math_fragment_text_blocks,
|
||||
_merge_list_intro_text_components,
|
||||
_merge_overlapping_same_line_text_blocks,
|
||||
_merge_paragraph_formula_context_blocks,
|
||||
_merge_short_same_baseline_prefix_blocks,
|
||||
_merge_spatial_text_components,
|
||||
_merge_unterminated_text_components,
|
||||
)
|
||||
from .rows import (
|
||||
_build_hanging_indent_group_map,
|
||||
_centered_visual_reset_break_sources,
|
||||
_component_starts_with_emphasized_row,
|
||||
_explicit_text_break_sources,
|
||||
_formula_style_text_row_break_sources,
|
||||
_front_matter_keyword_break_sources,
|
||||
_infer_local_text_lane_map,
|
||||
_isolated_indented_paragraph_break_sources,
|
||||
_leading_typography_reset_break_sources,
|
||||
_local_tight_output_line_bboxes,
|
||||
_starts_structural_reference_entry,
|
||||
_structured_text_break_sources,
|
||||
)
|
||||
|
||||
|
||||
def _build_text_blocks(
|
||||
lines: list[_LineItem],
|
||||
table_bboxes: list[BBox],
|
||||
page_size: tuple[float, float],
|
||||
drawing_lines: list[_AxisLine] | None = None,
|
||||
*,
|
||||
page_footnote_groups: Sequence[set[int]] | None = None,
|
||||
page_index: int | None = None,
|
||||
visual_bboxes: Sequence[BBox] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""先构建分组脚注,再按类型屏障、栏带和自然段边界聚合其余文本。"""
|
||||
|
||||
blocks, grouped_footnote_indices = _build_grouped_page_footnote_blocks(
|
||||
lines,
|
||||
page_footnote_groups or [],
|
||||
page_size,
|
||||
)
|
||||
lines = [line for line in lines if line.source_index not in grouped_footnote_indices]
|
||||
for angle in sorted({line.angle for line in lines}):
|
||||
line_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in lines if line.angle == angle]
|
||||
if not line_geometry:
|
||||
continue
|
||||
line_geometry.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
||||
effective_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
|
||||
median_height = statistics.median(effective_heights) if effective_heights else 1.0
|
||||
local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
|
||||
local_page_height = page_size[0] if angle in {90, 270} else page_size[1]
|
||||
local_visual_bboxes = [_rotate_bbox_to_upright(bbox, page_size, angle) for bbox in (visual_bboxes or [])]
|
||||
lanes = _infer_text_lanes(line_geometry, local_page_width, median_height)
|
||||
local_axis_lines = _transform_axis_lines(drawing_lines or [], page_size, angle)
|
||||
split_row_counts: dict[int, int] = {}
|
||||
for line, _bbox in line_geometry:
|
||||
if line.visual_row_id is not None and line.split_from_row:
|
||||
split_row_counts[line.visual_row_id] = split_row_counts.get(line.visual_row_id, 0) + 1
|
||||
|
||||
for lane in lanes:
|
||||
lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
||||
if not lane.lines:
|
||||
continue
|
||||
regular_gap, gap_mad = _estimate_lane_gap(lane)
|
||||
local_lane_by_source = _infer_local_text_lane_map(lane)
|
||||
structured_break_sources = _structured_text_break_sources(
|
||||
lane,
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
)
|
||||
isolated_break_sources = _isolated_indented_paragraph_break_sources(
|
||||
lane,
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
)
|
||||
structured_break_sources.update(
|
||||
isolated_break_sources,
|
||||
)
|
||||
visual_reset_sources = _centered_visual_reset_break_sources(
|
||||
lane,
|
||||
local_visual_bboxes,
|
||||
local_page_height,
|
||||
)
|
||||
typography_reset_sources = _leading_typography_reset_break_sources(
|
||||
lane,
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
)
|
||||
formula_text_break_sources = _formula_style_text_row_break_sources(
|
||||
lane,
|
||||
)
|
||||
structured_break_sources.update(visual_reset_sources)
|
||||
structured_break_sources.update(typography_reset_sources)
|
||||
structured_break_sources.update(formula_text_break_sources)
|
||||
protected_break_sources: set[int] = set()
|
||||
protected_break_sources.update(visual_reset_sources)
|
||||
protected_break_sources.update(typography_reset_sources)
|
||||
protected_break_sources.update(formula_text_break_sources)
|
||||
protected_break_sources.update(
|
||||
_front_matter_keyword_break_sources(
|
||||
lane,
|
||||
local_page_height,
|
||||
page_index,
|
||||
)
|
||||
)
|
||||
protected_break_sources.update(
|
||||
_explicit_text_break_sources(lane),
|
||||
)
|
||||
structured_break_sources.update(
|
||||
protected_break_sources,
|
||||
)
|
||||
hanging_indent_groups = _build_hanging_indent_group_map(
|
||||
lane,
|
||||
table_bboxes,
|
||||
local_axis_lines,
|
||||
)
|
||||
component: list[tuple[_LineItem, BBox]] = [lane.lines[0]]
|
||||
components: list[list[tuple[_LineItem, BBox]]] = []
|
||||
for previous, current in zip(lane.lines, lane.lines[1:]):
|
||||
previous_type = previous[0].semantic_type
|
||||
current_type = current[0].semantic_type
|
||||
if previous_type != current_type:
|
||||
should_connect = False
|
||||
elif previous_type is not None:
|
||||
should_connect = _should_connect_semantic_rows(
|
||||
previous,
|
||||
current,
|
||||
lane,
|
||||
regular_gap,
|
||||
table_bboxes,
|
||||
local_axis_lines,
|
||||
)
|
||||
else:
|
||||
previous_group = hanging_indent_groups.get(previous[0].source_index)
|
||||
current_group = hanging_indent_groups.get(current[0].source_index)
|
||||
previous_local_lane = local_lane_by_source.get(previous[0].source_index)
|
||||
current_local_lane = local_lane_by_source.get(current[0].source_index)
|
||||
connection_lane = (
|
||||
current_local_lane
|
||||
if current_local_lane is not None and previous_local_lane is current_local_lane
|
||||
else lane
|
||||
)
|
||||
if (
|
||||
current[0].style_scale_repaired
|
||||
and current[0].split_from_row
|
||||
and current[0].visual_row_id is not None
|
||||
and split_row_counts.get(
|
||||
current[0].visual_row_id,
|
||||
0,
|
||||
)
|
||||
>= 2
|
||||
):
|
||||
should_connect = False
|
||||
elif current[0].source_index in structured_break_sources:
|
||||
should_connect = False
|
||||
elif _starts_structural_reference_entry(previous, current):
|
||||
# 编号只确认已经由悬挂缩进几何形成的新条目,不能单独扩张范围。
|
||||
should_connect = False
|
||||
elif is_hyphen_at_line_end(previous[0].text):
|
||||
# 断词续行优先于悬挂缩进分组,但仍复用正文连接中的距离和障碍限制。
|
||||
should_connect = _should_connect_text_rows(
|
||||
previous,
|
||||
current,
|
||||
connection_lane,
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
table_bboxes,
|
||||
local_axis_lines,
|
||||
)
|
||||
elif previous_group is not None or current_group is not None:
|
||||
should_connect = previous_group is not None and previous_group == current_group
|
||||
else:
|
||||
should_connect = _should_connect_text_rows(
|
||||
previous,
|
||||
current,
|
||||
connection_lane,
|
||||
regular_gap,
|
||||
gap_mad,
|
||||
table_bboxes,
|
||||
local_axis_lines,
|
||||
)
|
||||
if should_connect:
|
||||
component.append(current)
|
||||
else:
|
||||
components.append(component)
|
||||
component = [current]
|
||||
components.append(component)
|
||||
|
||||
for component_geometry in components:
|
||||
component_lines = [item[0] for item in component_geometry]
|
||||
component_local_lane = local_lane_by_source.get(component_lines[0].source_index)
|
||||
if component_local_lane is None or not all(
|
||||
local_lane_by_source.get(line.source_index) is component_local_lane for line in component_lines
|
||||
):
|
||||
component_local_lane = lane
|
||||
if component_lines[0].semantic_type == "doc_title":
|
||||
# 文档标题保留自然换行,避免混排标题因语言检测在中文折行处插入空格。
|
||||
content = "\n".join(
|
||||
normalized for line in component_lines if (normalized := _normalize_native_run_text(line.text))
|
||||
)
|
||||
else:
|
||||
content = _merge_text_line_content([line.text for line in component_lines])
|
||||
if not content:
|
||||
continue
|
||||
visual_row_ids = {line.visual_row_id for line in component_lines if line.visual_row_id is not None}
|
||||
single_run_row_id = (
|
||||
component_lines[0].visual_row_id
|
||||
if len(component_lines) == 1
|
||||
and component_lines[0].split_from_row
|
||||
and component_lines[0].visual_row_id is not None
|
||||
else None
|
||||
)
|
||||
local_output_line_bboxes, output_bbox_repaired = _local_tight_output_line_bboxes(
|
||||
component_lines,
|
||||
page_size,
|
||||
angle,
|
||||
)
|
||||
blocks.append(
|
||||
{
|
||||
"type": component_lines[0].semantic_type or "text",
|
||||
"bbox": _bbox_union_many([line.bbox for line in component_lines]),
|
||||
"angle": angle,
|
||||
"content": content,
|
||||
"_visual_row_ids": visual_row_ids,
|
||||
"_single_run_row_id": single_run_row_id,
|
||||
"_local_line_bboxes": [bbox for _line, bbox in component_geometry],
|
||||
"_local_output_line_bboxes": local_output_line_bboxes,
|
||||
"_output_bbox_repaired": output_bbox_repaired,
|
||||
"_line_heights": [_line_effective_height(line, bbox) for line, bbox in component_geometry],
|
||||
"_font_signatures": {
|
||||
line.font_signature
|
||||
for line in component_lines
|
||||
if line.font_signature is not None and line.font_coverage >= 0.5
|
||||
},
|
||||
"_inline_math_regions": [region for line in component_lines for region in line.inline_math_regions],
|
||||
_PARAGRAPH_FORMULA_CONTEXT_MARKER: any(line.paragraph_formula_context for line in component_lines),
|
||||
"_lane_interval": (
|
||||
component_local_lane.left,
|
||||
component_local_lane.right,
|
||||
),
|
||||
"_lane_is_span": component_local_lane.is_span,
|
||||
"_hard_break_before": (component_lines[0].source_index in structured_break_sources),
|
||||
"_protected_hard_break_before": (component_lines[0].source_index in protected_break_sources),
|
||||
"_hanging_indent_group": hanging_indent_groups.get(
|
||||
component_lines[0].source_index,
|
||||
),
|
||||
"_leading_emphasis_start": _component_starts_with_emphasized_row(
|
||||
component_lines,
|
||||
),
|
||||
}
|
||||
)
|
||||
blocks = _merge_short_same_baseline_prefix_blocks(
|
||||
blocks,
|
||||
page_size,
|
||||
)
|
||||
blocks = _merge_spatial_text_components(blocks, page_size)
|
||||
blocks = _merge_list_intro_text_components(blocks)
|
||||
blocks = _merge_unterminated_text_components(blocks)
|
||||
blocks = _merge_overlapping_same_line_text_blocks(blocks, page_size)
|
||||
blocks = _merge_inline_math_fragment_text_blocks(
|
||||
blocks,
|
||||
page_size,
|
||||
)
|
||||
return _merge_paragraph_formula_context_blocks(
|
||||
blocks,
|
||||
page_size,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["_build_text_blocks"]
|
||||
@@ -0,0 +1,482 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""共享正文块连接的几何、内容拼接和来源规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .....utils.language import detect_lang
|
||||
from .....utils.text import merge_text_line_contents
|
||||
from ..geometry import (
|
||||
_bbox_axis_overlap_ratio,
|
||||
_bbox_center_y,
|
||||
_bbox_union_many,
|
||||
)
|
||||
from ..native_text import _normalize_native_run_text
|
||||
|
||||
_REFERENCE_ENTRY_RE = re.compile(r"^[[\[]\s*\d+\s*[]\]]")
|
||||
|
||||
|
||||
_FIGURE_CAPTION_MARKER_RE = re.compile(
|
||||
r"^(?:图\s*[0-90-9一二三四五六七八九十]|fig(?:ure)?\.?\s*[0-9])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_INLINE_MATH_RECOVERY_MARKER = "_recovered_inline_math_fragments"
|
||||
|
||||
|
||||
_PARAGRAPH_FORMULA_CONTEXT_MARKER = "_paragraph_formula_context"
|
||||
|
||||
|
||||
_FRONT_MATTER_FIELD_RE = re.compile(
|
||||
r"^\s*(?:keywords?|key\s+words?|关键词|中图分类号|文献标识码|文章编号)\s*[::]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_LIST_ITEM_RE = re.compile(
|
||||
r"^\s*(?:[((]\s*(?:\d+|[ivxlcdm]+)\s*[))]|[①-⑳]|[•●▪])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_BULLET_ITEM_RE = re.compile(
|
||||
r"^\s*[•●▪]",
|
||||
)
|
||||
|
||||
|
||||
_EMAIL_METADATA_RE = re.compile(
|
||||
r"^\s*e[\s-]*mail\s*[::]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_ABSTRACT_METADATA_RE = re.compile(
|
||||
r"^\s*(?:abstract|摘\s*要)\s*[::]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_LABELLED_METADATA_RE = re.compile(
|
||||
r"^\s*(?P<label>[A-Za-z\u3400-\u9fff]{1,12})\s*[::]",
|
||||
)
|
||||
|
||||
|
||||
_URL_LINE_RE = re.compile(
|
||||
r"^\s*(?:https?://|www\.)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_SHORT_SAME_BASELINE_PREFIX_RE = re.compile(
|
||||
r"^(?:[((]\s*\d{1,3}|\d{1,2}\s*[::]\s*\d{2})$",
|
||||
)
|
||||
|
||||
|
||||
def _merge_internal_text_block_group(
|
||||
blocks: list[dict[str, Any]],
|
||||
indices: list[int],
|
||||
*,
|
||||
preserve_visual_spaces: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""合并内部文本块及其版面元数据,最终输出阶段仍会统一移除这些字段。"""
|
||||
|
||||
ordered_indices = sorted(
|
||||
indices,
|
||||
key=(
|
||||
(lambda index: blocks[index]["bbox"][0])
|
||||
if preserve_visual_spaces
|
||||
else (lambda index: _text_component_sort_key(blocks[index]))
|
||||
),
|
||||
)
|
||||
merged = dict(blocks[ordered_indices[0]])
|
||||
merged["bbox"] = _bbox_union_many([blocks[index]["bbox"] for index in ordered_indices])
|
||||
contents = [str(blocks[index].get("content", "")) for index in ordered_indices]
|
||||
merged["content"] = (
|
||||
" ".join(content.strip() for content in contents if content.strip())
|
||||
if preserve_visual_spaces
|
||||
else _merge_text_line_content(contents)
|
||||
)
|
||||
merged["_visual_row_ids"] = set().union(
|
||||
*[row_ids for index in ordered_indices if isinstance((row_ids := blocks[index].get("_visual_row_ids")), set)]
|
||||
)
|
||||
merged["_single_run_row_id"] = None
|
||||
merged["_local_line_bboxes"] = [bbox for index in ordered_indices for bbox in blocks[index].get("_local_line_bboxes", [])]
|
||||
merged["_local_output_line_bboxes"] = [
|
||||
bbox for index in ordered_indices for bbox in blocks[index].get("_local_output_line_bboxes", [])
|
||||
]
|
||||
merged["_output_bbox_repaired"] = any(blocks[index].get("_output_bbox_repaired") is True for index in ordered_indices)
|
||||
merged["_line_heights"] = [height for index in ordered_indices for height in blocks[index].get("_line_heights", [])]
|
||||
merged["_font_signatures"] = set().union(
|
||||
*[
|
||||
signatures
|
||||
for index in ordered_indices
|
||||
if isinstance(
|
||||
(signatures := blocks[index].get("_font_signatures")),
|
||||
set,
|
||||
)
|
||||
]
|
||||
)
|
||||
merged["_inline_math_regions"] = [
|
||||
region for index in ordered_indices for region in blocks[index].get("_inline_math_regions", [])
|
||||
]
|
||||
merged[_PARAGRAPH_FORMULA_CONTEXT_MARKER] = any(
|
||||
blocks[index].get(_PARAGRAPH_FORMULA_CONTEXT_MARKER) is True for index in ordered_indices
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def _component_declared_lane_interval(
|
||||
block: dict[str, Any],
|
||||
) -> tuple[float, float] | None:
|
||||
"""读取组件声明的有效栏带区间,不区分普通栏或跨栏。"""
|
||||
|
||||
interval = block.get("_lane_interval")
|
||||
if (
|
||||
not isinstance(interval, (list, tuple))
|
||||
or len(interval) != 2
|
||||
or not all(isinstance(value, (int, float)) for value in interval)
|
||||
):
|
||||
return None
|
||||
left, right = float(interval[0]), float(interval[1])
|
||||
return (left, right) if right > left else None
|
||||
|
||||
|
||||
def _component_lane_interval(
|
||||
block: dict[str, Any],
|
||||
) -> tuple[float, float] | None:
|
||||
"""读取普通文本组件所属的有效非跨栏栏带区间。"""
|
||||
|
||||
if block.get("_lane_is_span") is not False:
|
||||
return None
|
||||
return _component_declared_lane_interval(block)
|
||||
|
||||
|
||||
def _component_reference_width(
|
||||
block: dict[str, Any],
|
||||
local_page_width: float,
|
||||
) -> float:
|
||||
"""优先返回组件的局部栏宽,缺少可靠栏带时回退页面宽度。"""
|
||||
|
||||
interval = _component_lane_interval(block)
|
||||
return interval[1] - interval[0] if interval is not None else local_page_width
|
||||
|
||||
|
||||
def _compatible_component_lane_width(
|
||||
first_block: dict[str, Any],
|
||||
second_block: dict[str, Any],
|
||||
local_page_width: float,
|
||||
median_height: float,
|
||||
) -> float:
|
||||
"""同一局部栏带的两个组件使用栏宽,否则继续使用页面宽度。"""
|
||||
|
||||
first_interval = _component_lane_interval(first_block)
|
||||
second_interval = _component_lane_interval(second_block)
|
||||
if first_interval is None or second_interval is None:
|
||||
return local_page_width
|
||||
tolerance = 0.75 * median_height
|
||||
if abs(first_interval[0] - second_interval[0]) > tolerance or abs(first_interval[1] - second_interval[1]) > tolerance:
|
||||
return local_page_width
|
||||
return min(
|
||||
first_interval[1] - first_interval[0],
|
||||
second_interval[1] - second_interval[0],
|
||||
)
|
||||
|
||||
|
||||
def _components_share_lane_role(
|
||||
first_block: dict[str, Any],
|
||||
second_block: dict[str, Any],
|
||||
median_height: float,
|
||||
) -> bool:
|
||||
"""要求二次合并组件同为跨栏或属于同一普通栏带。"""
|
||||
|
||||
first_role = first_block.get("_lane_is_span")
|
||||
second_role = second_block.get("_lane_is_span")
|
||||
if isinstance(first_role, bool) and isinstance(second_role, bool):
|
||||
if first_role != second_role:
|
||||
return False
|
||||
if first_role:
|
||||
return True
|
||||
first_interval = _component_lane_interval(first_block)
|
||||
second_interval = _component_lane_interval(second_block)
|
||||
if first_interval is None or second_interval is None:
|
||||
return False
|
||||
tolerance = 0.75 * median_height
|
||||
return (
|
||||
abs(first_interval[0] - second_interval[0]) <= tolerance
|
||||
and abs(first_interval[1] - second_interval[1]) <= tolerance
|
||||
)
|
||||
# 兼容缺少内部栏元数据的旧调用;生产路径始终会携带该字段。
|
||||
return not isinstance(first_role, bool) and not isinstance(second_role, bool)
|
||||
|
||||
|
||||
def _block_starts_with_short_wide_rows(
|
||||
block: dict[str, Any],
|
||||
local_page_width: float,
|
||||
) -> bool:
|
||||
"""判断组件内部是否以短首行和紧邻宽正文形成明确分组起点。"""
|
||||
|
||||
line_bboxes = block.get("_local_line_bboxes")
|
||||
if not isinstance(line_bboxes, list) or len(line_bboxes) < 2:
|
||||
return False
|
||||
first_bbox, second_bbox = line_bboxes[:2]
|
||||
first_width = first_bbox[2] - first_bbox[0]
|
||||
second_width = second_bbox[2] - second_bbox[0]
|
||||
reference_width = _component_reference_width(block, local_page_width)
|
||||
return first_width <= 0.35 * reference_width and second_width >= max(0.25 * reference_width, 1.8 * first_width)
|
||||
|
||||
|
||||
def _find_short_opener_pairs(
|
||||
blocks: list[dict[str, Any]],
|
||||
candidate_indices: list[int],
|
||||
local_page_width: float,
|
||||
median_height: float,
|
||||
) -> list[tuple[int, int]]:
|
||||
"""查找跨栏拆开的短首行与紧邻满宽正文组件。"""
|
||||
|
||||
output: list[tuple[int, int]] = []
|
||||
for opener_index in candidate_indices:
|
||||
opener_bboxes = blocks[opener_index]["_local_line_bboxes"]
|
||||
if len(opener_bboxes) != 1:
|
||||
continue
|
||||
opener_bbox = opener_bboxes[0]
|
||||
opener_width = opener_bbox[2] - opener_bbox[0]
|
||||
matches: list[tuple[float, int]] = []
|
||||
for body_index in candidate_indices:
|
||||
if body_index == opener_index:
|
||||
continue
|
||||
if blocks[body_index].get("_hard_break_before") is True:
|
||||
continue
|
||||
if not _components_share_lane_role(
|
||||
blocks[opener_index],
|
||||
blocks[body_index],
|
||||
median_height,
|
||||
):
|
||||
continue
|
||||
body_bbox = blocks[body_index]["_local_line_bboxes"][0]
|
||||
gap = body_bbox[1] - opener_bbox[3]
|
||||
body_width = body_bbox[2] - body_bbox[0]
|
||||
reference_width = _compatible_component_lane_width(
|
||||
blocks[opener_index],
|
||||
blocks[body_index],
|
||||
local_page_width,
|
||||
median_height,
|
||||
)
|
||||
if (
|
||||
not 0.0 <= gap <= 0.75 * median_height
|
||||
or abs(body_bbox[0] - opener_bbox[0]) > 0.75 * median_height
|
||||
or opener_width > 0.35 * reference_width
|
||||
or body_width < 0.6 * reference_width
|
||||
or _component_connection_skips_block(
|
||||
blocks,
|
||||
opener_index,
|
||||
body_index,
|
||||
median_height,
|
||||
)
|
||||
):
|
||||
continue
|
||||
matches.append((gap, body_index))
|
||||
if matches:
|
||||
output.append((opener_index, min(matches)[1]))
|
||||
return output
|
||||
|
||||
|
||||
def _nearest_following_text_component(
|
||||
blocks: list[dict[str, Any]],
|
||||
current_index: int,
|
||||
candidate_indices: list[int],
|
||||
*,
|
||||
maximum_gap: float,
|
||||
section_starts: set[int],
|
||||
) -> int | None:
|
||||
"""返回同一水平流中紧邻且不是新分组起点的下一正文组件。"""
|
||||
|
||||
current_bbox = blocks[current_index]["_local_line_bboxes"][-1]
|
||||
matches: list[tuple[float, float, int]] = []
|
||||
for candidate_index in candidate_indices:
|
||||
if candidate_index == current_index or candidate_index in section_starts:
|
||||
continue
|
||||
if blocks[candidate_index].get("_hard_break_before") is True:
|
||||
continue
|
||||
if not _components_share_lane_role(
|
||||
blocks[current_index],
|
||||
blocks[candidate_index],
|
||||
maximum_gap / 0.75,
|
||||
):
|
||||
continue
|
||||
candidate_bbox = blocks[candidate_index]["_local_line_bboxes"][0]
|
||||
gap = candidate_bbox[1] - current_bbox[3]
|
||||
if not -0.25 * maximum_gap <= gap <= maximum_gap:
|
||||
continue
|
||||
overlap = _bbox_axis_overlap_ratio(current_bbox, candidate_bbox, axis="x")
|
||||
left_gap = abs(candidate_bbox[0] - current_bbox[0])
|
||||
if overlap < 0.5 and left_gap > 3.0 * maximum_gap:
|
||||
continue
|
||||
if _component_connection_skips_block(
|
||||
blocks,
|
||||
current_index,
|
||||
candidate_index,
|
||||
maximum_gap / 0.75,
|
||||
):
|
||||
continue
|
||||
matches.append((max(0.0, gap), left_gap, candidate_index))
|
||||
return min(matches)[2] if matches else None
|
||||
|
||||
|
||||
def _has_parallel_text_component(
|
||||
blocks: list[dict[str, Any]],
|
||||
current_index: int,
|
||||
candidate_indices: list[int],
|
||||
) -> bool:
|
||||
"""检查当前组件同一纵向带内是否存在水平分离的并栏组件。"""
|
||||
|
||||
current_bbox = blocks[current_index]["bbox"]
|
||||
for candidate_index in candidate_indices:
|
||||
if candidate_index == current_index:
|
||||
continue
|
||||
candidate_bbox = blocks[candidate_index]["bbox"]
|
||||
vertical_overlap = max(
|
||||
0.0,
|
||||
min(current_bbox[3], candidate_bbox[3]) - max(current_bbox[1], candidate_bbox[1]),
|
||||
)
|
||||
horizontally_separate = candidate_bbox[0] >= current_bbox[2] or current_bbox[0] >= candidate_bbox[2]
|
||||
if vertical_overlap > 0 and horizontally_separate:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _nearest_tapered_tail_component(
|
||||
blocks: list[dict[str, Any]],
|
||||
current_index: int,
|
||||
candidate_indices: list[int],
|
||||
median_height: float,
|
||||
section_starts: set[int],
|
||||
) -> int | None:
|
||||
"""查找左对齐、行宽递减且行距略大的信息组尾行组件。"""
|
||||
|
||||
previous_bbox = blocks[current_index]["_local_line_bboxes"][-1]
|
||||
previous_width = previous_bbox[2] - previous_bbox[0]
|
||||
matches: list[tuple[float, int]] = []
|
||||
for candidate_index in candidate_indices:
|
||||
if candidate_index == current_index or candidate_index in section_starts:
|
||||
continue
|
||||
if blocks[candidate_index].get("_hard_break_before") is True:
|
||||
continue
|
||||
if not _components_share_lane_role(
|
||||
blocks[current_index],
|
||||
blocks[candidate_index],
|
||||
median_height,
|
||||
):
|
||||
continue
|
||||
candidate_bboxes = blocks[candidate_index]["_local_line_bboxes"]
|
||||
first_bbox = candidate_bboxes[0]
|
||||
last_bbox = candidate_bboxes[-1]
|
||||
gap = first_bbox[1] - previous_bbox[3]
|
||||
first_width = first_bbox[2] - first_bbox[0]
|
||||
last_width = last_bbox[2] - last_bbox[0]
|
||||
if (
|
||||
not 0.75 * median_height < gap <= 1.5 * median_height
|
||||
or len(candidate_bboxes) > 2
|
||||
or abs(first_bbox[0] - previous_bbox[0]) > 0.75 * median_height
|
||||
or first_width > 0.85 * previous_width
|
||||
or last_width > first_width + 0.5 * median_height
|
||||
or _component_connection_skips_block(
|
||||
blocks,
|
||||
current_index,
|
||||
candidate_index,
|
||||
median_height,
|
||||
)
|
||||
):
|
||||
continue
|
||||
matches.append((gap, candidate_index))
|
||||
return min(matches)[1] if matches else None
|
||||
|
||||
|
||||
def _component_connection_skips_block(
|
||||
blocks: list[dict[str, Any]],
|
||||
first_index: int,
|
||||
second_index: int,
|
||||
median_height: float,
|
||||
) -> bool:
|
||||
"""检查两个正文组件之间是否已有同水平流的中间块,禁止二次合并跨越它。"""
|
||||
|
||||
first_bbox = blocks[first_index]["_local_line_bboxes"][-1]
|
||||
second_bbox = blocks[second_index]["_local_line_bboxes"][0]
|
||||
first_center = _bbox_center_y(first_bbox)
|
||||
second_center = _bbox_center_y(second_bbox)
|
||||
corridor_top, corridor_bottom = sorted((first_center, second_center))
|
||||
corridor_bbox = (
|
||||
min(first_bbox[0], second_bbox[0]),
|
||||
corridor_top,
|
||||
max(first_bbox[2], second_bbox[2]),
|
||||
corridor_bottom,
|
||||
)
|
||||
for index, block in enumerate(blocks):
|
||||
if index in {first_index, second_index}:
|
||||
continue
|
||||
local_bboxes = block.get("_local_line_bboxes")
|
||||
if not isinstance(local_bboxes, list) or not local_bboxes:
|
||||
continue
|
||||
other_bbox = local_bboxes[0]
|
||||
other_center = _bbox_center_y(other_bbox)
|
||||
if not (corridor_top + 0.1 * median_height < other_center < corridor_bottom - 0.1 * median_height):
|
||||
continue
|
||||
if _bbox_axis_overlap_ratio(corridor_bbox, other_bbox, axis="x") >= 0.2:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text_component_sort_key(block: dict[str, Any]) -> tuple[float, float]:
|
||||
"""返回正文组件按局部首行位置排序的稳定键。"""
|
||||
|
||||
first_bbox = block["_local_line_bboxes"][0]
|
||||
return first_bbox[1], first_bbox[0]
|
||||
|
||||
|
||||
def _merge_text_line_content(line_texts: Sequence[str]) -> str:
|
||||
"""按 Hybrid 语言与行末连字规则折叠普通文本行。"""
|
||||
|
||||
normalized_lines = [_normalize_native_run_text(str(text or "")) for text in line_texts]
|
||||
normalized_lines = [text for text in normalized_lines if text]
|
||||
if not normalized_lines:
|
||||
return ""
|
||||
try:
|
||||
block_language = detect_lang("".join(normalized_lines))
|
||||
except Exception:
|
||||
block_language = ""
|
||||
return merge_text_line_contents(
|
||||
normalized_lines,
|
||||
block_language=block_language,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_REFERENCE_ENTRY_RE",
|
||||
"_FIGURE_CAPTION_MARKER_RE",
|
||||
"_INLINE_MATH_RECOVERY_MARKER",
|
||||
"_PARAGRAPH_FORMULA_CONTEXT_MARKER",
|
||||
"_FRONT_MATTER_FIELD_RE",
|
||||
"_LIST_ITEM_RE",
|
||||
"_BULLET_ITEM_RE",
|
||||
"_EMAIL_METADATA_RE",
|
||||
"_ABSTRACT_METADATA_RE",
|
||||
"_LABELLED_METADATA_RE",
|
||||
"_URL_LINE_RE",
|
||||
"_SHORT_SAME_BASELINE_PREFIX_RE",
|
||||
"_merge_internal_text_block_group",
|
||||
"_component_declared_lane_interval",
|
||||
"_component_lane_interval",
|
||||
"_component_reference_width",
|
||||
"_compatible_component_lane_width",
|
||||
"_components_share_lane_role",
|
||||
"_block_starts_with_short_wide_rows",
|
||||
"_find_short_opener_pairs",
|
||||
"_nearest_following_text_component",
|
||||
"_has_parallel_text_component",
|
||||
"_nearest_tapered_tail_component",
|
||||
"_component_connection_skips_block",
|
||||
"_text_component_sort_key",
|
||||
"_merge_text_line_content",
|
||||
]
|
||||
@@ -0,0 +1,405 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""按来源行和标记组装页面脚注。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import (
|
||||
_bbox_center_y,
|
||||
_bbox_union_many,
|
||||
_rotate_bbox_from_upright,
|
||||
_rotate_bbox_to_upright,
|
||||
)
|
||||
from ..line_layout import (
|
||||
_effective_text_row_gap,
|
||||
_line_effective_height,
|
||||
_lines_tight_output_bbox,
|
||||
_title_fonts_compatible,
|
||||
)
|
||||
from ..models import _LineItem
|
||||
from .common import _merge_text_line_content
|
||||
|
||||
|
||||
def _build_grouped_page_footnote_blocks(
|
||||
lines: list[_LineItem],
|
||||
source_groups: Sequence[set[int]],
|
||||
page_size: tuple[float, float],
|
||||
) -> tuple[list[dict[str, Any]], set[int]]:
|
||||
"""按分隔线组切分几何脚注条目,并返回已消费的来源编号。"""
|
||||
|
||||
footnote_by_source = {line.source_index: line for line in lines if line.semantic_type == "page_footnote"}
|
||||
blocks: list[dict[str, Any]] = []
|
||||
consumed_source_indices: set[int] = set()
|
||||
for source_group in source_groups:
|
||||
group_lines = [footnote_by_source[source_index] for source_index in source_group if source_index in footnote_by_source]
|
||||
for angle in sorted({line.angle for line in group_lines}):
|
||||
directional_lines = [line for line in group_lines if line.angle == angle]
|
||||
tight_bboxes = _tight_page_footnote_bboxes(
|
||||
directional_lines,
|
||||
page_size,
|
||||
)
|
||||
for entry_lines in _split_page_footnote_entries(
|
||||
directional_lines,
|
||||
page_size,
|
||||
):
|
||||
ordered_lines = sorted(
|
||||
entry_lines,
|
||||
key=lambda line: (
|
||||
_rotate_bbox_to_upright(line.bbox, page_size, angle)[1],
|
||||
_rotate_bbox_to_upright(line.bbox, page_size, angle)[0],
|
||||
line.source_index,
|
||||
),
|
||||
)
|
||||
content = _merge_text_line_content([line.text for line in ordered_lines])
|
||||
if not content:
|
||||
continue
|
||||
visual_row_ids = {line.visual_row_id for line in ordered_lines if line.visual_row_id is not None}
|
||||
single_run_row_id = (
|
||||
ordered_lines[0].visual_row_id
|
||||
if len(ordered_lines) == 1
|
||||
and ordered_lines[0].split_from_row
|
||||
and ordered_lines[0].visual_row_id is not None
|
||||
else None
|
||||
)
|
||||
block = {
|
||||
"type": "page_footnote",
|
||||
"bbox": _bbox_union_many([tight_bboxes[line.source_index] for line in ordered_lines]),
|
||||
"angle": angle,
|
||||
"content": content,
|
||||
"_visual_row_ids": visual_row_ids,
|
||||
"_single_run_row_id": single_run_row_id,
|
||||
"_inline_math_regions": [region for line in ordered_lines for region in line.inline_math_regions],
|
||||
}
|
||||
tight_output_bbox = _lines_tight_output_bbox(
|
||||
ordered_lines,
|
||||
page_size,
|
||||
)
|
||||
if tight_output_bbox is not None:
|
||||
block["_tight_output_bbox"] = tight_output_bbox
|
||||
blocks.append(block)
|
||||
consumed_source_indices.update(line.source_index for line in ordered_lines)
|
||||
return blocks, consumed_source_indices
|
||||
|
||||
|
||||
def _split_page_footnote_entries(
|
||||
lines: list[_LineItem],
|
||||
page_size: tuple[float, float],
|
||||
) -> list[list[_LineItem]]:
|
||||
"""按首行或续行缩进模式,把同一分隔线下的脚注行切成条目。"""
|
||||
|
||||
if not lines:
|
||||
return []
|
||||
angle = lines[0].angle
|
||||
line_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in lines]
|
||||
line_geometry.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
||||
if len(line_geometry) == 1:
|
||||
return [[line_geometry[0][0]]]
|
||||
|
||||
effective_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
|
||||
median_height = statistics.median(effective_heights)
|
||||
glyph_widths = [line.median_glyph_width for line, _bbox in line_geometry if line.median_glyph_width is not None]
|
||||
median_glyph_width = statistics.median(glyph_widths) if glyph_widths else 0.5 * median_height
|
||||
indent_threshold = max(1.5 * median_height, 2.0 * median_glyph_width)
|
||||
base_left = min(bbox[0] for _line, bbox in line_geometry)
|
||||
maximum_row_width = max(bbox[2] - bbox[0] for _line, bbox in line_geometry)
|
||||
marker_rows = _find_page_footnote_marker_rows(
|
||||
line_geometry,
|
||||
median_height,
|
||||
median_glyph_width,
|
||||
base_left,
|
||||
)
|
||||
if marker_rows:
|
||||
return _split_marked_page_footnote_entries(
|
||||
line_geometry,
|
||||
marker_rows,
|
||||
median_height,
|
||||
median_glyph_width,
|
||||
base_left,
|
||||
maximum_row_width,
|
||||
indent_threshold,
|
||||
)
|
||||
return _split_unmarked_page_footnote_entries(
|
||||
line_geometry,
|
||||
base_left,
|
||||
maximum_row_width,
|
||||
indent_threshold,
|
||||
)
|
||||
|
||||
|
||||
def _find_page_footnote_marker_rows(
|
||||
line_geometry: list[tuple[_LineItem, BBox]],
|
||||
median_height: float,
|
||||
median_glyph_width: float,
|
||||
base_left: float,
|
||||
) -> list[tuple[tuple[_LineItem, BBox], tuple[_LineItem, BBox]]]:
|
||||
"""用同视觉行的窄左片段和右侧正文识别脚注编号锚点。"""
|
||||
|
||||
by_visual_row: dict[int, list[tuple[_LineItem, BBox]]] = {}
|
||||
for item in line_geometry:
|
||||
line = item[0]
|
||||
if line.visual_row_id is None or not line.split_from_row:
|
||||
continue
|
||||
by_visual_row.setdefault(line.visual_row_id, []).append(item)
|
||||
|
||||
marker_rows: list[tuple[tuple[_LineItem, BBox], tuple[_LineItem, BBox]]] = []
|
||||
marker_width_limit = max(1.5 * median_glyph_width, 0.75 * median_height)
|
||||
for members in by_visual_row.values():
|
||||
ordered = sorted(members, key=lambda item: item[1][0])
|
||||
if len(ordered) < 2:
|
||||
continue
|
||||
marker, body = ordered[0], ordered[1]
|
||||
marker_width = marker[1][2] - marker[1][0]
|
||||
body_width = body[1][2] - body[1][0]
|
||||
horizontal_gap = body[1][0] - marker[1][2]
|
||||
if (
|
||||
marker_width > marker_width_limit
|
||||
or marker[1][0] - base_left > 0.5 * median_height
|
||||
or not 0.0 <= horizontal_gap <= 1.5 * median_height
|
||||
or body[1][0] - base_left < max(1.5 * marker_width, 0.75 * median_height)
|
||||
or body_width < max(4.0 * marker_width, 4.0 * median_glyph_width)
|
||||
or abs(_bbox_center_y(marker[1]) - _bbox_center_y(body[1])) > 0.4 * median_height
|
||||
):
|
||||
continue
|
||||
marker_rows.append((marker, body))
|
||||
marker_rows.sort(key=lambda pair: (min(pair[0][1][1], pair[1][1][1]), pair[0][1][0]))
|
||||
if not marker_rows:
|
||||
marker_rows = _find_geometric_page_footnote_marker_rows(
|
||||
line_geometry,
|
||||
median_height,
|
||||
median_glyph_width,
|
||||
base_left,
|
||||
)
|
||||
return marker_rows
|
||||
|
||||
|
||||
def _find_geometric_page_footnote_marker_rows(
|
||||
line_geometry: list[tuple[_LineItem, BBox]],
|
||||
median_height: float,
|
||||
median_glyph_width: float,
|
||||
base_left: float,
|
||||
) -> list[tuple[tuple[_LineItem, BBox], tuple[_LineItem, BBox]]]:
|
||||
"""在 row id 缺失时用同基线窄标记和右侧正文恢复脚注首行。"""
|
||||
|
||||
marker_width_limit = max(
|
||||
1.5 * median_glyph_width,
|
||||
0.75 * median_height,
|
||||
)
|
||||
candidates: list[
|
||||
tuple[
|
||||
float,
|
||||
tuple[_LineItem, BBox],
|
||||
tuple[_LineItem, BBox],
|
||||
]
|
||||
] = []
|
||||
for marker in line_geometry:
|
||||
marker_width = marker[1][2] - marker[1][0]
|
||||
if marker_width > marker_width_limit or marker[1][0] - base_left > 0.5 * median_height:
|
||||
continue
|
||||
for body in line_geometry:
|
||||
if body is marker:
|
||||
continue
|
||||
body_width = body[1][2] - body[1][0]
|
||||
horizontal_gap = body[1][0] - marker[1][2]
|
||||
if (
|
||||
not 0.0 <= horizontal_gap <= 1.5 * median_height
|
||||
or body[1][0] - base_left
|
||||
< max(
|
||||
1.5 * marker_width,
|
||||
0.4 * median_height,
|
||||
)
|
||||
or body_width
|
||||
< max(
|
||||
4.0 * marker_width,
|
||||
4.0 * median_glyph_width,
|
||||
)
|
||||
or abs(_bbox_center_y(marker[1]) - _bbox_center_y(body[1])) > 0.4 * median_height
|
||||
or not _title_fonts_compatible(
|
||||
marker[0],
|
||||
body[0],
|
||||
)
|
||||
):
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
horizontal_gap,
|
||||
marker,
|
||||
body,
|
||||
)
|
||||
)
|
||||
|
||||
output = []
|
||||
consumed_sources: set[int] = set()
|
||||
for _gap, marker, body in sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
min(item[1][1][1], item[2][1][1]),
|
||||
item[0],
|
||||
item[1][0].source_index,
|
||||
item[2][0].source_index,
|
||||
),
|
||||
):
|
||||
pair_sources = {
|
||||
marker[0].source_index,
|
||||
body[0].source_index,
|
||||
}
|
||||
if pair_sources & consumed_sources:
|
||||
continue
|
||||
output.append((marker, body))
|
||||
consumed_sources.update(pair_sources)
|
||||
return output
|
||||
|
||||
|
||||
def _split_marked_page_footnote_entries(
|
||||
line_geometry: list[tuple[_LineItem, BBox]],
|
||||
marker_rows: list[tuple[tuple[_LineItem, BBox], tuple[_LineItem, BBox]]],
|
||||
median_height: float,
|
||||
median_glyph_width: float,
|
||||
base_left: float,
|
||||
maximum_row_width: float,
|
||||
indent_threshold: float,
|
||||
) -> list[list[_LineItem]]:
|
||||
"""把编号、右侧首行和对齐续行聚合,并保留编号区之前的独立脚注。"""
|
||||
|
||||
first_marker_top = min(marker_rows[0][0][1][1], marker_rows[0][1][1][1])
|
||||
prefix_geometry = [item for item in line_geometry if item[1][1] < first_marker_top]
|
||||
entries = _split_unmarked_page_footnote_entries(
|
||||
prefix_geometry,
|
||||
base_left,
|
||||
maximum_row_width,
|
||||
indent_threshold,
|
||||
)
|
||||
consumed = {line.source_index for entry in entries for line in entry}
|
||||
continuation_tolerance = max(0.75 * median_height, 2.0 * median_glyph_width)
|
||||
for marker_index, (marker, body) in enumerate(marker_rows):
|
||||
next_marker_top = (
|
||||
min(marker_rows[marker_index + 1][0][1][1], marker_rows[marker_index + 1][1][1][1])
|
||||
if marker_index + 1 < len(marker_rows)
|
||||
else float("inf")
|
||||
)
|
||||
entry_items = [marker, body]
|
||||
consumed.update((marker[0].source_index, body[0].source_index))
|
||||
previous = body
|
||||
for candidate in line_geometry:
|
||||
if candidate[0].source_index in consumed:
|
||||
continue
|
||||
if candidate[1][1] < body[1][1] or candidate[1][1] >= next_marker_top:
|
||||
continue
|
||||
if abs(candidate[1][0] - body[1][0]) > continuation_tolerance:
|
||||
continue
|
||||
if _effective_text_row_gap(previous, candidate) > 1.5 * median_height:
|
||||
continue
|
||||
entry_items.append(candidate)
|
||||
consumed.add(candidate[0].source_index)
|
||||
previous = candidate
|
||||
entry_items.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
||||
entries.append([line for line, _bbox in entry_items])
|
||||
|
||||
leftovers = [item for item in line_geometry if item[0].source_index not in consumed]
|
||||
entries.extend(
|
||||
_split_unmarked_page_footnote_entries(
|
||||
leftovers,
|
||||
base_left,
|
||||
maximum_row_width,
|
||||
indent_threshold,
|
||||
)
|
||||
)
|
||||
entries.sort(
|
||||
key=lambda entry: min(
|
||||
bbox[1] for line in entry for candidate, bbox in line_geometry if candidate.source_index == line.source_index
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _split_unmarked_page_footnote_entries(
|
||||
line_geometry: list[tuple[_LineItem, BBox]],
|
||||
base_left: float,
|
||||
maximum_row_width: float,
|
||||
indent_threshold: float,
|
||||
) -> list[list[_LineItem]]:
|
||||
"""沿用首行和续行缩进模式切分没有稳定编号锚点的脚注行。"""
|
||||
|
||||
if not line_geometry:
|
||||
return []
|
||||
first_line_is_indented = line_geometry[0][1][0] - base_left > indent_threshold
|
||||
|
||||
entries: list[list[_LineItem]] = [[line_geometry[0][0]]]
|
||||
for previous, current in zip(line_geometry, line_geometry[1:]):
|
||||
previous_width = previous[1][2] - previous[1][0]
|
||||
previous_is_near_full = previous_width >= 0.8 * maximum_row_width
|
||||
current_is_indented = current[1][0] - base_left > indent_threshold
|
||||
same_left_compact_continuation = (
|
||||
not first_line_is_indented
|
||||
and previous_width >= 0.7 * maximum_row_width
|
||||
and abs(current[1][0] - previous[1][0]) <= 0.5 * indent_threshold
|
||||
and _title_fonts_compatible(previous[0], current[0])
|
||||
and _effective_text_row_gap(previous, current) <= indent_threshold
|
||||
)
|
||||
# 满行后必然续接;其余行按首页观测到的首行/续行缩进模式决定边界。
|
||||
continues_previous = (
|
||||
previous_is_near_full
|
||||
or same_left_compact_continuation
|
||||
or (not current_is_indented if first_line_is_indented else current_is_indented)
|
||||
)
|
||||
if continues_previous:
|
||||
entries[-1].append(current[0])
|
||||
else:
|
||||
entries.append([current[0]])
|
||||
return entries
|
||||
|
||||
|
||||
def _tight_page_footnote_bboxes(
|
||||
lines: list[_LineItem],
|
||||
page_size: tuple[float, float],
|
||||
) -> dict[int, BBox]:
|
||||
"""以有效行高和相邻行距收紧脚注框,避免异常字体框跨入相邻条目。"""
|
||||
|
||||
if not lines:
|
||||
return {}
|
||||
angle = lines[0].angle
|
||||
line_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in lines]
|
||||
line_geometry.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
||||
median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in line_geometry)
|
||||
row_pitches = [
|
||||
current[1][1] - previous[1][1]
|
||||
for previous, current in zip(line_geometry, line_geometry[1:])
|
||||
if current[1][1] - previous[1][1] > 0.25 * median_height
|
||||
]
|
||||
median_pitch = statistics.median(row_pitches) if row_pitches else None
|
||||
|
||||
output: dict[int, BBox] = {}
|
||||
for line, bbox in line_geometry:
|
||||
tight_height = min(
|
||||
bbox[3] - bbox[1],
|
||||
_line_effective_height(line, bbox),
|
||||
)
|
||||
if median_pitch is not None:
|
||||
tight_height = min(tight_height, 0.9 * median_pitch)
|
||||
tight_height = max(0.1, tight_height)
|
||||
center_y = _bbox_center_y(bbox)
|
||||
tight_local_bbox = (
|
||||
bbox[0],
|
||||
center_y - 0.5 * tight_height,
|
||||
bbox[2],
|
||||
center_y + 0.5 * tight_height,
|
||||
)
|
||||
output[line.source_index] = _rotate_bbox_from_upright(
|
||||
tight_local_bbox,
|
||||
page_size,
|
||||
angle,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_build_grouped_page_footnote_blocks",
|
||||
"_split_page_footnote_entries",
|
||||
"_find_page_footnote_marker_rows",
|
||||
"_find_geometric_page_footnote_marker_rows",
|
||||
"_split_marked_page_footnote_entries",
|
||||
"_split_unmarked_page_footnote_entries",
|
||||
"_tight_page_footnote_bboxes",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,697 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""依据栏带、缩进和排版重置寻找正文行分组边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import statistics
|
||||
from typing import Sequence
|
||||
|
||||
from .....types import BBox
|
||||
from .....utils.text import is_hyphen_at_line_end
|
||||
from ..geometry import (
|
||||
_bbox_axis_overlap_ratio,
|
||||
_bbox_center_x,
|
||||
_rotate_bbox_to_upright,
|
||||
)
|
||||
from ..line_layout import (
|
||||
_connection_crosses_table,
|
||||
_effective_body_text_row_gap,
|
||||
_effective_text_row_gap,
|
||||
_horizontal_rule_separates_rows,
|
||||
_line_effective_height,
|
||||
_line_tight_output_bbox,
|
||||
_title_fonts_compatible,
|
||||
)
|
||||
from ..models import _LineItem, _LocalAxisLine, _TextLane
|
||||
from .common import (
|
||||
_ABSTRACT_METADATA_RE,
|
||||
_BULLET_ITEM_RE,
|
||||
_EMAIL_METADATA_RE,
|
||||
_FRONT_MATTER_FIELD_RE,
|
||||
_LABELLED_METADATA_RE,
|
||||
_LIST_ITEM_RE,
|
||||
_REFERENCE_ENTRY_RE,
|
||||
_URL_LINE_RE,
|
||||
)
|
||||
|
||||
|
||||
def _local_tight_output_line_bboxes(
|
||||
lines: Sequence[_LineItem],
|
||||
page_size: tuple[float, float],
|
||||
angle: int,
|
||||
) -> tuple[list[BBox], bool]:
|
||||
"""返回与原行顺序一致的 tight+1pt 局部框及是否存在可靠候选。"""
|
||||
|
||||
output = []
|
||||
changed = False
|
||||
for line in lines:
|
||||
candidate = _line_tight_output_bbox(line, page_size)
|
||||
output.append(
|
||||
_rotate_bbox_to_upright(
|
||||
candidate or line.bbox,
|
||||
page_size,
|
||||
angle,
|
||||
)
|
||||
)
|
||||
changed = changed or candidate is not None
|
||||
return output, changed
|
||||
|
||||
|
||||
def _starts_structural_reference_entry(
|
||||
previous: tuple[_LineItem, BBox],
|
||||
current: tuple[_LineItem, BBox],
|
||||
) -> bool:
|
||||
"""仅在编号行相对续行明显左突时确认新的参考文献条目。"""
|
||||
|
||||
if _REFERENCE_ENTRY_RE.match(current[0].text.strip()) is None:
|
||||
return False
|
||||
previous_height = _line_effective_height(*previous)
|
||||
current_height = _line_effective_height(*current)
|
||||
pair_height = max(previous_height, current_height)
|
||||
return (
|
||||
current[1][0] <= previous[1][0] - max(5.0, 0.6 * min(previous_height, current_height))
|
||||
and -0.75 * pair_height <= _effective_text_row_gap(previous, current) <= 1.5 * pair_height
|
||||
)
|
||||
|
||||
|
||||
def _build_hanging_indent_group_map(
|
||||
lane: _TextLane,
|
||||
table_bboxes: list[BBox],
|
||||
axis_lines: list[_LocalAxisLine],
|
||||
) -> dict[int, int]:
|
||||
"""仅按重复的左突首行和稳定续行缩进识别悬挂缩进条目。"""
|
||||
|
||||
if len(lane.lines) < 4:
|
||||
return {}
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
if len(rows) < 4:
|
||||
return {}
|
||||
median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in rows)
|
||||
start_tolerance = max(5.0, 0.65 * median_height)
|
||||
minimum_indent = max(7.0, 0.8 * median_height)
|
||||
continuation_tolerance = max(4.0, 0.55 * median_height)
|
||||
|
||||
def rows_are_adjacent(
|
||||
previous: tuple[_LineItem, BBox],
|
||||
current: tuple[_LineItem, BBox],
|
||||
) -> bool:
|
||||
"""检查相邻行的净空和几何障碍是否允许组成同一缩进序列。"""
|
||||
|
||||
effective_gap = _effective_text_row_gap(previous, current)
|
||||
top_pitch = current[1][1] - previous[1][1]
|
||||
robust_pitch_fallback = 0.5 * median_height <= top_pitch <= 1.8 * median_height
|
||||
if not -0.6 * median_height <= effective_gap <= 1.3 * median_height and not robust_pitch_fallback:
|
||||
return False
|
||||
if _connection_crosses_table(
|
||||
previous[0].bbox,
|
||||
current[0].bbox,
|
||||
table_bboxes,
|
||||
):
|
||||
return False
|
||||
return not _horizontal_rule_separates_rows(
|
||||
previous[1],
|
||||
current[1],
|
||||
lane,
|
||||
axis_lines,
|
||||
)
|
||||
|
||||
def consume_entry(
|
||||
start_index: int,
|
||||
start_left: float,
|
||||
expected_continuation_left: float | None,
|
||||
*,
|
||||
require_next_start: bool,
|
||||
) -> tuple[int, float] | None:
|
||||
"""消费一个左突首行及其续行,并返回下一条首行位置。"""
|
||||
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
full_width_midparagraph_entry = (
|
||||
rows[start_index][1][2] - rows[start_index][1][0] >= 0.8 * lane_width
|
||||
and start_index + 1 < len(rows)
|
||||
and rows[start_index + 1][1][2] - rows[start_index + 1][1][0] <= 0.75 * lane_width
|
||||
)
|
||||
if (
|
||||
start_index > 0
|
||||
and rows_are_adjacent(
|
||||
rows[start_index - 1],
|
||||
rows[start_index],
|
||||
)
|
||||
and abs(rows[start_index - 1][1][0] - start_left) <= start_tolerance
|
||||
and not full_width_midparagraph_entry
|
||||
):
|
||||
# 同左缘正文仍在连续时不能从段落中部启动悬挂条目序列。
|
||||
return None
|
||||
if (
|
||||
start_index > 0
|
||||
and is_hyphen_at_line_end(rows[start_index - 1][0].text)
|
||||
and rows_are_adjacent(rows[start_index - 1], rows[start_index])
|
||||
):
|
||||
# 排版断词后的下一物理行属于前文,不能被缩进几何误当成新条目首行。
|
||||
return None
|
||||
continuation_index = start_index + 1
|
||||
if continuation_index >= len(rows):
|
||||
return None
|
||||
first_continuation = rows[continuation_index]
|
||||
if not rows_are_adjacent(rows[start_index], first_continuation):
|
||||
return None
|
||||
continuation_left = first_continuation[1][0]
|
||||
if continuation_left < start_left + minimum_indent:
|
||||
return None
|
||||
if (
|
||||
expected_continuation_left is not None
|
||||
and abs(continuation_left - expected_continuation_left) > continuation_tolerance
|
||||
):
|
||||
return None
|
||||
|
||||
continuation_index += 1
|
||||
while continuation_index < len(rows):
|
||||
previous = rows[continuation_index - 1]
|
||||
current = rows[continuation_index]
|
||||
current_left = current[1][0]
|
||||
if not rows_are_adjacent(previous, current):
|
||||
break
|
||||
if current_left < start_left + minimum_indent:
|
||||
break
|
||||
if abs(current_left - continuation_left) > continuation_tolerance:
|
||||
break
|
||||
continuation_index += 1
|
||||
|
||||
if not require_next_start:
|
||||
return continuation_index, continuation_left
|
||||
if continuation_index >= len(rows):
|
||||
return None
|
||||
if not rows_are_adjacent(rows[continuation_index - 1], rows[continuation_index]):
|
||||
return None
|
||||
if abs(rows[continuation_index][1][0] - start_left) > start_tolerance:
|
||||
return None
|
||||
return continuation_index, continuation_left
|
||||
|
||||
group_map: dict[int, int] = {}
|
||||
group_index = 0
|
||||
row_index = 0
|
||||
while row_index < len(rows) - 3:
|
||||
start_left = rows[row_index][1][0]
|
||||
first_entry = consume_entry(
|
||||
row_index,
|
||||
start_left,
|
||||
None,
|
||||
require_next_start=True,
|
||||
)
|
||||
if first_entry is None:
|
||||
row_index += 1
|
||||
continue
|
||||
|
||||
_next_start_index, continuation_left = first_entry
|
||||
start_indices = [row_index]
|
||||
current_start_index = row_index
|
||||
end_index: int | None = None
|
||||
while True:
|
||||
next_entry = consume_entry(
|
||||
current_start_index,
|
||||
start_left,
|
||||
continuation_left,
|
||||
require_next_start=True,
|
||||
)
|
||||
if next_entry is None:
|
||||
final_entry = consume_entry(
|
||||
current_start_index,
|
||||
start_left,
|
||||
continuation_left,
|
||||
require_next_start=False,
|
||||
)
|
||||
if final_entry is not None:
|
||||
end_index = final_entry[0]
|
||||
break
|
||||
next_start_index, _continuation_left = next_entry
|
||||
prospective_entry = consume_entry(
|
||||
next_start_index,
|
||||
start_left,
|
||||
continuation_left,
|
||||
require_next_start=False,
|
||||
)
|
||||
if prospective_entry is None:
|
||||
# 当前条目已经完整确认;后面的普通左对齐段落只作为终止边界,
|
||||
# 不能让它反向使此前所有悬挂缩进条目失效。
|
||||
end_index = next_start_index
|
||||
break
|
||||
start_indices.append(next_start_index)
|
||||
current_start_index = next_start_index
|
||||
if len(start_indices) < 2 or end_index is None:
|
||||
row_index += 1
|
||||
continue
|
||||
|
||||
entry_ranges = [
|
||||
(start, end)
|
||||
for start, end in zip(
|
||||
start_indices,
|
||||
[*start_indices[1:], end_index],
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
for start, end in entry_ranges:
|
||||
for line, _bbox in rows[start:end]:
|
||||
group_map[line.source_index] = group_index
|
||||
group_index += 1
|
||||
row_index = end_index
|
||||
|
||||
return group_map
|
||||
|
||||
|
||||
def _infer_local_text_lane_map(lane: _TextLane) -> dict[int, _TextLane]:
|
||||
"""从连续同左缘正文推导局部栏宽,修正跨栏上文污染的全宽栏带。"""
|
||||
|
||||
if lane.is_span or len(lane.lines) < 3:
|
||||
return {}
|
||||
rows = sorted(
|
||||
lane.lines,
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in rows)
|
||||
left_tolerance = max(3.0, 0.75 * median_height)
|
||||
height_ratio_limit = 1.25
|
||||
runs: list[list[tuple[_LineItem, BBox]]] = []
|
||||
current_run: list[tuple[_LineItem, BBox]] = []
|
||||
|
||||
def submit_run() -> None:
|
||||
"""提交当前连续正文行,语义行和明显左缘变化都会结束该局部区段。"""
|
||||
|
||||
nonlocal current_run
|
||||
if current_run:
|
||||
runs.append(current_run)
|
||||
current_run = []
|
||||
|
||||
for item in rows:
|
||||
line, bbox = item
|
||||
if line.semantic_type is not None:
|
||||
submit_run()
|
||||
continue
|
||||
if not current_run:
|
||||
current_run = [item]
|
||||
continue
|
||||
run_left = statistics.median(member[1][0] for member in current_run)
|
||||
run_heights = [_line_effective_height(member, member_bbox) for member, member_bbox in current_run]
|
||||
current_height = _line_effective_height(line, bbox)
|
||||
if (
|
||||
abs(bbox[0] - run_left) <= left_tolerance
|
||||
and max([*run_heights, current_height]) / max(0.1, min([*run_heights, current_height])) <= height_ratio_limit
|
||||
):
|
||||
current_run.append(item)
|
||||
else:
|
||||
submit_run()
|
||||
current_run = [item]
|
||||
submit_run()
|
||||
|
||||
global_width = max(0.1, lane.right - lane.left)
|
||||
local_by_source: dict[int, _TextLane] = {}
|
||||
for run in runs:
|
||||
if len(run) < 3:
|
||||
continue
|
||||
local_left = statistics.median(bbox[0] for _line, bbox in run)
|
||||
local_right = max(bbox[2] for _line, bbox in run)
|
||||
local_width = max(0.1, local_right - local_left)
|
||||
wide_support = sum(bbox[2] - bbox[0] >= 0.7 * local_width for _line, bbox in run)
|
||||
if global_width < 1.4 * local_width or wide_support < 3:
|
||||
continue
|
||||
local_lane = _TextLane(
|
||||
left=local_left,
|
||||
right=local_right,
|
||||
lines=run,
|
||||
is_span=False,
|
||||
)
|
||||
for line, _bbox in run:
|
||||
local_by_source[line.source_index] = local_lane
|
||||
return local_by_source
|
||||
|
||||
|
||||
def _structured_text_break_sources(
|
||||
lane: _TextLane,
|
||||
regular_gap: float,
|
||||
gap_mad: float,
|
||||
) -> set[int]:
|
||||
"""用重复强调首行和前行右侧留白确认结构化正文的新段起点。"""
|
||||
|
||||
rows = sorted(
|
||||
lane.lines,
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
break_sources: set[int] = set()
|
||||
regions: list[list[tuple[_LineItem, BBox]]] = []
|
||||
for row in rows:
|
||||
if row[0].semantic_type is not None:
|
||||
if regions and regions[-1]:
|
||||
regions.append([])
|
||||
continue
|
||||
if not regions:
|
||||
regions.append([])
|
||||
regions[-1].append(row)
|
||||
|
||||
for region in regions:
|
||||
candidates: list[int] = []
|
||||
for index, (line, bbox) in enumerate(region):
|
||||
height = _line_effective_height(line, bbox)
|
||||
line_width = bbox[2] - bbox[0]
|
||||
if (
|
||||
line.leading_emphasis_width is not None
|
||||
and line.leading_emphasis_width <= 0.2 * lane_width
|
||||
and line_width >= 0.95 * lane_width
|
||||
and abs(bbox[0] - lane.left) <= 0.75 * height
|
||||
):
|
||||
candidates.append(index)
|
||||
if len(candidates) < 3:
|
||||
continue
|
||||
for index in candidates:
|
||||
if index == 0:
|
||||
continue
|
||||
previous = region[index - 1]
|
||||
current = region[index]
|
||||
pair_height = max(
|
||||
_line_effective_height(*previous),
|
||||
_line_effective_height(*current),
|
||||
)
|
||||
previous_fill = (previous[1][2] - lane.left) / lane_width
|
||||
vertical_gap = _effective_text_row_gap(previous, current)
|
||||
if previous_fill <= 0.8 and -0.25 * pair_height <= vertical_gap <= regular_gap + max(
|
||||
0.75 * pair_height, 3.0 * gap_mad
|
||||
):
|
||||
break_sources.add(current[0].source_index)
|
||||
return break_sources
|
||||
|
||||
|
||||
def _isolated_indented_paragraph_break_sources(
|
||||
lane: _TextLane,
|
||||
regular_gap: float,
|
||||
gap_mad: float,
|
||||
) -> set[int]:
|
||||
"""识别短终止尾行之后的缩进首行,并要求下一行回到稳定栏左缘。"""
|
||||
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
output: set[int] = set()
|
||||
terminal_re = re.compile(r"[.!?。!?::;;][\]\)})】》”’'\"]*$")
|
||||
for previous, current, following in zip(
|
||||
rows,
|
||||
rows[1:],
|
||||
rows[2:],
|
||||
):
|
||||
previous_height = _line_effective_height(*previous)
|
||||
current_height = _line_effective_height(*current)
|
||||
following_height = _line_effective_height(*following)
|
||||
pair_height = max(
|
||||
previous_height,
|
||||
current_height,
|
||||
following_height,
|
||||
)
|
||||
current_indent = current[1][0] - lane.left
|
||||
if (
|
||||
previous[1][2] - previous[1][0] > 0.3 * lane_width
|
||||
or terminal_re.search(previous[0].text.rstrip()) is None
|
||||
or not max(5.0, 0.65 * pair_height) <= current_indent <= 3.0 * pair_height
|
||||
or current[1][2] - current[1][0] < 0.75 * lane_width
|
||||
or abs(following[1][0] - lane.left) > 0.75 * pair_height
|
||||
or following[1][2] - following[1][0] < 0.65 * lane_width
|
||||
or not _title_fonts_compatible(current[0], following[0])
|
||||
):
|
||||
continue
|
||||
first_gap = _effective_body_text_row_gap(previous, current)
|
||||
second_gap = _effective_body_text_row_gap(current, following)
|
||||
gap_limit = regular_gap + max(
|
||||
0.75 * pair_height,
|
||||
3.0 * gap_mad,
|
||||
)
|
||||
if -0.25 * pair_height <= first_gap <= gap_limit and -0.25 * pair_height <= second_gap <= gap_limit:
|
||||
output.add(current[0].source_index)
|
||||
return output
|
||||
|
||||
|
||||
def _centered_visual_reset_break_sources(
|
||||
lane: _TextLane,
|
||||
visual_bboxes: Sequence[BBox],
|
||||
local_page_height: float,
|
||||
) -> set[int]:
|
||||
"""识别视觉主体下方短居中行到更宽居中行的独立注释重启。"""
|
||||
|
||||
if not visual_bboxes:
|
||||
return set()
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
output: set[int] = set()
|
||||
for previous, current in zip(rows, rows[1:]):
|
||||
previous_bbox = previous[1]
|
||||
current_bbox = current[1]
|
||||
previous_width = previous_bbox[2] - previous_bbox[0]
|
||||
current_width = current_bbox[2] - current_bbox[0]
|
||||
pair_height = max(
|
||||
_line_effective_height(*previous),
|
||||
_line_effective_height(*current),
|
||||
)
|
||||
if (
|
||||
previous_width > 0.7 * current_width
|
||||
or current_bbox[0] > previous_bbox[0] - 0.25 * pair_height
|
||||
or current_bbox[2] < previous_bbox[2] + 0.25 * pair_height
|
||||
or abs(_bbox_center_x(previous_bbox) - _bbox_center_x(current_bbox)) > 0.1 * current_width
|
||||
):
|
||||
continue
|
||||
vertical_gap = _effective_text_row_gap(previous, current)
|
||||
if not -0.25 * pair_height <= vertical_gap <= 0.75 * pair_height:
|
||||
continue
|
||||
if any(
|
||||
-0.25 * pair_height <= previous_bbox[1] - visual_bbox[3] <= max(2.0 * pair_height, 0.03 * local_page_height)
|
||||
and _bbox_axis_overlap_ratio(current_bbox, visual_bbox, axis="x") >= 0.8
|
||||
and abs(_bbox_center_x(current_bbox) - _bbox_center_x(visual_bbox))
|
||||
<= 0.12 * max(current_width, visual_bbox[2] - visual_bbox[0])
|
||||
for visual_bbox in visual_bboxes
|
||||
):
|
||||
output.add(current[0].source_index)
|
||||
return output
|
||||
|
||||
|
||||
def _leading_typography_reset_break_sources(
|
||||
lane: _TextLane,
|
||||
regular_gap: float,
|
||||
gap_mad: float,
|
||||
) -> set[int]:
|
||||
"""识别短尾之后以独立行首字体 run 开启的宽行结构段。"""
|
||||
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
output: set[int] = set()
|
||||
for previous, current in zip(rows, rows[1:]):
|
||||
previous_width = previous[1][2] - previous[1][0]
|
||||
current_width = current[1][2] - current[1][0]
|
||||
pair_height = max(
|
||||
_line_effective_height(*previous),
|
||||
_line_effective_height(*current),
|
||||
)
|
||||
if (
|
||||
current[0].leading_typography_width is None
|
||||
or current[0].leading_typography_width > 0.2 * lane_width
|
||||
or previous_width > 0.45 * lane_width
|
||||
or current_width < 0.75 * lane_width
|
||||
or abs(previous[1][0] - lane.left) > 0.75 * pair_height
|
||||
or abs(current[1][0] - lane.left) > 0.75 * pair_height
|
||||
or current[0].formula_candidate_only
|
||||
or current[0].compact_formula_cluster
|
||||
or current[0].inline_math_regions
|
||||
):
|
||||
continue
|
||||
vertical_gap = _effective_body_text_row_gap(previous, current)
|
||||
if (
|
||||
-0.25 * pair_height
|
||||
<= vertical_gap
|
||||
<= regular_gap
|
||||
+ max(
|
||||
0.75 * pair_height,
|
||||
3.0 * gap_mad,
|
||||
)
|
||||
):
|
||||
output.add(current[0].source_index)
|
||||
return output
|
||||
|
||||
|
||||
def _formula_style_text_row_break_sources(
|
||||
lane: _TextLane,
|
||||
) -> set[int]:
|
||||
"""按相邻显示行几何拆分被公式检测回退为正文的独立文本行。"""
|
||||
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
matching_edges: set[int] = set()
|
||||
for index, (previous, current) in enumerate(zip(rows, rows[1:])):
|
||||
previous_line, previous_bbox = previous
|
||||
current_line, current_bbox = current
|
||||
if not (previous_line.paragraph_formula_context and current_line.paragraph_formula_context):
|
||||
continue
|
||||
previous_height = _line_effective_height(*previous)
|
||||
current_height = _line_effective_height(*current)
|
||||
minimum_height = min(previous_height, current_height)
|
||||
maximum_height = max(previous_height, current_height)
|
||||
if minimum_height < 0.75 * maximum_height:
|
||||
continue
|
||||
previous_width = previous_bbox[2] - previous_bbox[0]
|
||||
current_width = current_bbox[2] - current_bbox[0]
|
||||
if min(previous_width, current_width) < 0.45 * lane_width or max(previous_width, current_width) > 0.95 * lane_width:
|
||||
continue
|
||||
lane_center = 0.5 * (lane.left + lane.right)
|
||||
if (
|
||||
abs(_bbox_center_x(previous_bbox) - lane_center) > 0.15 * lane_width
|
||||
or abs(_bbox_center_x(current_bbox) - lane_center) > 0.15 * lane_width
|
||||
):
|
||||
continue
|
||||
vertical_overlap = max(
|
||||
0.0,
|
||||
min(previous_bbox[3], current_bbox[3]) - max(previous_bbox[1], current_bbox[1]),
|
||||
)
|
||||
top_pitch = current_bbox[1] - previous_bbox[1]
|
||||
pair_height = statistics.median((previous_height, current_height))
|
||||
if vertical_overlap <= 0.2 * minimum_height and 0.9 * pair_height <= top_pitch <= 2.0 * pair_height:
|
||||
matching_edges.add(index)
|
||||
|
||||
output: set[int] = set()
|
||||
for index in matching_edges:
|
||||
output.add(rows[index][0].source_index)
|
||||
output.add(rows[index + 1][0].source_index)
|
||||
if index + 2 < len(rows):
|
||||
# 同时保护显示行组后的正文起点,避免上下文恢复阶段重新跨界合并。
|
||||
output.add(rows[index + 2][0].source_index)
|
||||
return output
|
||||
|
||||
|
||||
def _front_matter_keyword_break_sources(
|
||||
lane: _TextLane,
|
||||
local_page_height: float,
|
||||
page_index: int | None,
|
||||
) -> set[int]:
|
||||
"""把首页关键词和文献元数据行固定为独立文本块起点。"""
|
||||
|
||||
if page_index != 0:
|
||||
return set()
|
||||
return {
|
||||
line.source_index
|
||||
for line, bbox in lane.lines
|
||||
if line.semantic_type is None
|
||||
and bbox[1] <= 0.65 * local_page_height
|
||||
and _FRONT_MATTER_FIELD_RE.match(line.text) is not None
|
||||
}
|
||||
|
||||
|
||||
def _component_starts_with_emphasized_row(
|
||||
lines: list[_LineItem],
|
||||
) -> bool:
|
||||
"""识别行内强调或首行字重显著高于后续正文的组件起点。"""
|
||||
|
||||
if not lines:
|
||||
return False
|
||||
if lines[0].leading_emphasis_width is not None:
|
||||
return True
|
||||
first_weight = lines[0].dominant_font_weight
|
||||
following_weights = [line.dominant_font_weight for line in lines[1:] if line.dominant_font_weight is not None]
|
||||
if first_weight is None or not following_weights:
|
||||
return False
|
||||
body_weight = statistics.median(following_weights)
|
||||
return first_weight - body_weight >= 100.0 and first_weight >= 1.15 * max(1.0, body_weight)
|
||||
|
||||
|
||||
def _explicit_text_break_sources(
|
||||
lane: _TextLane,
|
||||
) -> set[int]:
|
||||
"""用通用列表标记和 E-mail 元数据确认正文中的显式硬分段。"""
|
||||
|
||||
rows = sorted(
|
||||
(item for item in lane.lines if item[0].semantic_type is None),
|
||||
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
||||
)
|
||||
output = {line.source_index for line, _bbox in rows if _ABSTRACT_METADATA_RE.match(line.text) is not None}
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
output.update(
|
||||
line.source_index
|
||||
for line, bbox in rows
|
||||
if _BULLET_ITEM_RE.match(line.text) is not None and bbox[2] - bbox[0] >= 0.8 * lane_width
|
||||
)
|
||||
for index, (line, bbox) in enumerate(rows):
|
||||
if _EMAIL_METADATA_RE.match(line.text) is None or index == 0:
|
||||
continue
|
||||
previous_line, previous_bbox = rows[index - 1]
|
||||
pair_height = max(
|
||||
_line_effective_height(previous_line, previous_bbox),
|
||||
_line_effective_height(line, bbox),
|
||||
)
|
||||
if abs(bbox[0] - previous_bbox[0]) <= 0.75 * pair_height:
|
||||
output.add(line.source_index)
|
||||
for row_index, (previous, current) in enumerate(
|
||||
zip(rows, rows[1:]),
|
||||
):
|
||||
previous_is_label = _LABELLED_METADATA_RE.match(
|
||||
previous[0].text,
|
||||
)
|
||||
current_is_label = _LABELLED_METADATA_RE.match(
|
||||
current[0].text,
|
||||
)
|
||||
label_pair_height = max(
|
||||
_line_effective_height(*previous),
|
||||
_line_effective_height(*current),
|
||||
)
|
||||
if (
|
||||
previous_is_label is not None
|
||||
and current_is_label is not None
|
||||
and _URL_LINE_RE.match(current[0].text) is None
|
||||
and len(previous_is_label.group("label")) >= 4
|
||||
and len(current_is_label.group("label")) >= 4
|
||||
and any("\u3400" <= char <= "\u9fff" for char in previous_is_label.group("label"))
|
||||
and any("\u3400" <= char <= "\u9fff" for char in current_is_label.group("label"))
|
||||
and previous_is_label.group("label").casefold() != current_is_label.group("label").casefold()
|
||||
and current[1][1] - previous[1][1] <= 2.0 * label_pair_height
|
||||
and previous[1][2] - previous[1][0] <= 0.75 * lane_width
|
||||
and current[1][2] - current[1][0] <= 0.75 * lane_width
|
||||
):
|
||||
output.add(current[0].source_index)
|
||||
pair_height = max(
|
||||
_line_effective_height(*previous),
|
||||
_line_effective_height(*current),
|
||||
)
|
||||
next_row = rows[row_index + 2] if row_index + 2 < len(rows) else None
|
||||
indented_item_continuation = (
|
||||
current[1][0] - lane.left >= max(5.0, 0.65 * pair_height)
|
||||
and next_row is not None
|
||||
and next_row[1][0] - lane.left <= 0.5 * pair_height
|
||||
and 0.5 * pair_height <= next_row[1][1] - current[1][1] <= 2.25 * pair_height
|
||||
)
|
||||
if (
|
||||
_LIST_ITEM_RE.match(current[0].text) is not None
|
||||
and previous[0].text.rstrip().endswith((":", ":"))
|
||||
and previous[1][2] - previous[1][0] <= 0.8 * lane_width
|
||||
and indented_item_continuation
|
||||
):
|
||||
output.add(current[0].source_index)
|
||||
return output
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_local_tight_output_line_bboxes",
|
||||
"_starts_structural_reference_entry",
|
||||
"_build_hanging_indent_group_map",
|
||||
"_infer_local_text_lane_map",
|
||||
"_structured_text_break_sources",
|
||||
"_isolated_indented_paragraph_break_sources",
|
||||
"_centered_visual_reset_break_sources",
|
||||
"_leading_typography_reset_break_sources",
|
||||
"_formula_style_text_row_break_sources",
|
||||
"_front_matter_keyword_break_sources",
|
||||
"_component_starts_with_emphasized_row",
|
||||
"_explicit_text_break_sources",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""按职责拆分的 Flash PDF 内部实现。"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,227 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""统计全文和栏内正文排版基线。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
|
||||
from ..geometry import _rotate_bbox_to_upright
|
||||
from ..inline.types import PDF_FONT_FORCE_BOLD_FLAG, PDF_FONT_ITALIC_FLAG
|
||||
from ..line_layout import (
|
||||
_estimate_lane_gap,
|
||||
_font_signatures_share_family,
|
||||
_line_canonical_style_scale,
|
||||
_line_effective_height,
|
||||
)
|
||||
from ..models import (
|
||||
_DocumentBodyProfile,
|
||||
_LaneBodyProfile,
|
||||
_LineItem,
|
||||
_PreparedPage,
|
||||
_TextLane,
|
||||
)
|
||||
|
||||
|
||||
def _infer_document_body_profile(
|
||||
prepared_pages: list[_PreparedPage],
|
||||
*,
|
||||
use_canonical_scale: bool = False,
|
||||
) -> _DocumentBodyProfile | None:
|
||||
"""按跨页覆盖和累计行宽推断全文正文行高及常规字体集合。"""
|
||||
|
||||
samples: list[
|
||||
tuple[
|
||||
float,
|
||||
int,
|
||||
float,
|
||||
tuple[str, int] | None,
|
||||
float | None,
|
||||
]
|
||||
] = []
|
||||
for page_index, prepared in enumerate(prepared_pages):
|
||||
for line in prepared.remaining_lines:
|
||||
if line.semantic_type is not None:
|
||||
continue
|
||||
local_bbox = _rotate_bbox_to_upright(
|
||||
line.bbox,
|
||||
prepared.page_size,
|
||||
line.angle,
|
||||
)
|
||||
local_page_width = prepared.page_size[1] if line.angle in {90, 270} else prepared.page_size[0]
|
||||
height = (
|
||||
_line_canonical_style_scale(line, local_bbox)
|
||||
if use_canonical_scale
|
||||
else _line_effective_height(line, local_bbox)
|
||||
)
|
||||
normalized_width = max(0.0, local_bbox[2] - local_bbox[0]) / max(
|
||||
0.1,
|
||||
local_page_width,
|
||||
)
|
||||
if height <= 0 or normalized_width <= 0:
|
||||
continue
|
||||
samples.append(
|
||||
(
|
||||
height,
|
||||
page_index,
|
||||
normalized_width,
|
||||
line.font_signature if line.font_coverage >= 0.75 else None,
|
||||
line.dominant_font_weight,
|
||||
)
|
||||
)
|
||||
if not samples:
|
||||
return None
|
||||
|
||||
height_clusters: list[list[tuple[float, int, float]]] = []
|
||||
for height, page_index, normalized_width, _font, _weight in sorted(
|
||||
samples,
|
||||
key=lambda item: item[0],
|
||||
):
|
||||
target = next(
|
||||
(
|
||||
cluster
|
||||
for cluster in height_clusters
|
||||
if abs(height - statistics.median(item[0] for item in cluster))
|
||||
<= 0.1 * statistics.median(item[0] for item in cluster)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
height_clusters.append([(height, page_index, normalized_width)])
|
||||
else:
|
||||
target.append((height, page_index, normalized_width))
|
||||
cross_page_clusters = [cluster for cluster in height_clusters if len({item[1] for item in cluster}) >= 2]
|
||||
eligible_clusters = cross_page_clusters or height_clusters
|
||||
body_cluster = max(
|
||||
eligible_clusters,
|
||||
key=lambda cluster: (
|
||||
sum(item[2] for item in cluster),
|
||||
len({item[1] for item in cluster}),
|
||||
len(cluster),
|
||||
),
|
||||
)
|
||||
body_height = statistics.median(item[0] for item in body_cluster)
|
||||
|
||||
body_weights = [
|
||||
weight
|
||||
for height, _page_index, _width, _font, weight in samples
|
||||
if weight is not None and 0.9 <= height / body_height <= 1.1
|
||||
]
|
||||
body_weight = statistics.median(body_weights) if body_weights else None
|
||||
|
||||
font_pages: dict[tuple[str, int], set[int]] = {}
|
||||
font_widths: dict[tuple[str, int], float] = {}
|
||||
font_weights: dict[tuple[str, int], list[float]] = {}
|
||||
for height, page_index, width, font, weight in samples:
|
||||
if font is None or not 0.9 <= height / body_height <= 1.1:
|
||||
continue
|
||||
# 常规字体支持必须来自正文高度带;跨页重复的大标题不能反向污染正文画像。
|
||||
font_pages.setdefault(font, set()).add(page_index)
|
||||
font_widths[font] = font_widths.get(font, 0.0) + width
|
||||
if weight is not None:
|
||||
font_weights.setdefault(font, []).append(weight)
|
||||
|
||||
regular_fonts = frozenset(
|
||||
font
|
||||
for font, pages in font_pages.items()
|
||||
if len(pages) >= 3
|
||||
and font_widths.get(font, 0.0) >= 2.0
|
||||
and font_widths.get(font, 0.0) >= 0.75 * len(pages)
|
||||
and _document_font_is_regular(
|
||||
font,
|
||||
font_weights.get(font, []),
|
||||
body_weight,
|
||||
)
|
||||
)
|
||||
return _DocumentBodyProfile(
|
||||
body_height=max(0.1, body_height),
|
||||
body_weight=body_weight,
|
||||
regular_fonts=regular_fonts,
|
||||
has_style_scale_repairs=any(
|
||||
line.style_scale_repaired for prepared in prepared_pages for line in prepared.remaining_lines
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _document_font_is_regular(
|
||||
font: tuple[str, int],
|
||||
weights: list[float],
|
||||
body_weight: float | None,
|
||||
) -> bool:
|
||||
"""用字体样式位和全文正文基准过滤斜体、粗体等强调字体。"""
|
||||
|
||||
if font[1] & (PDF_FONT_ITALIC_FLAG | PDF_FONT_FORCE_BOLD_FLAG):
|
||||
return False
|
||||
if not weights or body_weight is None:
|
||||
return True
|
||||
median_weight = statistics.median(weights)
|
||||
return median_weight < max(body_weight + 100.0, 1.15 * body_weight)
|
||||
|
||||
|
||||
def _infer_lane_body_profile(lane: _TextLane) -> _LaneBodyProfile:
|
||||
"""从栏带的长行主体估计正文行高、主字体、字重、常规行距和样式占比。"""
|
||||
|
||||
available = [item for item in lane.lines if item[0].semantic_type is None]
|
||||
if not available:
|
||||
return _LaneBodyProfile(1.0, None, None, 0.35, {})
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
long_lines = [item for item in available if item[1][2] - item[1][0] >= 0.45 * lane_width]
|
||||
body_rows = long_lines or available
|
||||
body_line_ids = {id(line) for line, _bbox in body_rows}
|
||||
body_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in body_rows)
|
||||
|
||||
font_support: dict[tuple[str, int], float] = {}
|
||||
style_support: dict[tuple[str, int], float] = {}
|
||||
total_style_width = 0.0
|
||||
for line, bbox in available:
|
||||
if line.font_signature is None or line.font_coverage < 0.75:
|
||||
continue
|
||||
line_width = max(0.1, bbox[2] - bbox[0])
|
||||
style_support[line.font_signature] = style_support.get(line.font_signature, 0.0) + line_width
|
||||
total_style_width += line_width
|
||||
if id(line) in body_line_ids and 0.75 <= _line_effective_height(line, bbox) / body_height <= 1.35:
|
||||
font_support[line.font_signature] = font_support.get(line.font_signature, 0.0) + line_width
|
||||
body_font = max(font_support, key=font_support.get) if font_support else None
|
||||
if total_style_width > 0:
|
||||
style_support = {signature: width / total_style_width for signature, width in style_support.items()}
|
||||
|
||||
body_weights = [
|
||||
line.dominant_font_weight
|
||||
for line, bbox in body_rows
|
||||
if line.dominant_font_weight is not None
|
||||
and (body_font is None or line.font_signature == body_font)
|
||||
and 0.75 <= _line_effective_height(line, bbox) / body_height <= 1.35
|
||||
]
|
||||
regular_gap, _gap_mad = _estimate_lane_gap(lane)
|
||||
return _LaneBodyProfile(
|
||||
body_height=max(0.1, body_height),
|
||||
body_font=body_font,
|
||||
body_weight=statistics.median(body_weights) if body_weights else None,
|
||||
regular_gap=regular_gap,
|
||||
style_support=style_support,
|
||||
body_row_count=len(body_rows),
|
||||
)
|
||||
|
||||
|
||||
def _line_uses_document_regular_font(
|
||||
line: _LineItem,
|
||||
document_body_profile: _DocumentBodyProfile | None,
|
||||
) -> bool:
|
||||
"""判断当前行是否使用跨页反复出现且未加粗的常规字体。"""
|
||||
|
||||
return (
|
||||
document_body_profile is not None
|
||||
and line.font_signature is not None
|
||||
and line.font_coverage >= 0.5
|
||||
and any(
|
||||
_font_signatures_share_family(line.font_signature, regular_font)
|
||||
for regular_font in document_body_profile.regular_fonts
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_infer_document_body_profile",
|
||||
"_document_font_is_regular",
|
||||
"_infer_lane_body_profile",
|
||||
"_line_uses_document_regular_font",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""提供标题分类共享的几何与文本规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import _bbox_axis_overlap_ratio, _bbox_center_x, _bbox_center_y
|
||||
from ..line_layout import (
|
||||
_effective_text_row_gap,
|
||||
)
|
||||
from ..models import (
|
||||
_LineItem,
|
||||
)
|
||||
|
||||
_NUMBERED_SECTION_TITLE_RE = re.compile(
|
||||
r"^(?P<number>\d+(?:\s*\.\s*\d+)*)\s+(?P<label>\S.*)$",
|
||||
)
|
||||
|
||||
|
||||
_SECTION_NUMBER_ONLY_RE = re.compile(
|
||||
r"^\d+(?:\s*\.\s*\d+)*\.?$",
|
||||
)
|
||||
|
||||
|
||||
_SECTION_TITLE_TERMINAL_RE = re.compile(
|
||||
r"[.!?。!?::;;,,]$",
|
||||
)
|
||||
|
||||
|
||||
_UNNUMBERED_SECTION_HEADING_RE = re.compile(
|
||||
r"^(?:introduction|references?|bibliography|acknowledg(?:e)?ments?|引言|绪论|参考文献|参考资料)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _build_physical_title_gap_map(
|
||||
line_geometry: list[tuple[_LineItem, BBox]],
|
||||
) -> dict[int, tuple[float | None, float | None]]:
|
||||
"""为每行记录同方向且水平投影相交的最近上、下物理行净空。"""
|
||||
|
||||
output: dict[int, tuple[float | None, float | None]] = {}
|
||||
for line, bbox in line_geometry:
|
||||
line_center = _bbox_center_y(bbox)
|
||||
above_gaps: list[float] = []
|
||||
below_gaps: list[float] = []
|
||||
for other_line, other_bbox in line_geometry:
|
||||
if other_line is line:
|
||||
continue
|
||||
if _bbox_axis_overlap_ratio(bbox, other_bbox, axis="x") < 0.1:
|
||||
# 双栏同高度正文并非当前行的物理上下文,不能抹掉真实标题留白。
|
||||
continue
|
||||
if line.visual_row_id is not None and other_line.visual_row_id == line.visual_row_id:
|
||||
continue
|
||||
other_center = _bbox_center_y(other_bbox)
|
||||
if other_center < line_center:
|
||||
above_gaps.append(
|
||||
max(
|
||||
0.0,
|
||||
_effective_text_row_gap(
|
||||
(other_line, other_bbox),
|
||||
(line, bbox),
|
||||
),
|
||||
)
|
||||
)
|
||||
elif other_center > line_center:
|
||||
below_gaps.append(
|
||||
max(
|
||||
0.0,
|
||||
_effective_text_row_gap(
|
||||
(line, bbox),
|
||||
(other_line, other_bbox),
|
||||
),
|
||||
)
|
||||
)
|
||||
output[line.source_index] = (
|
||||
min(above_gaps) if above_gaps else None,
|
||||
min(below_gaps) if below_gaps else None,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _line_inside_visual_container(
|
||||
line_bbox: BBox,
|
||||
container_bboxes: list[BBox],
|
||||
) -> bool:
|
||||
"""检查文本行中心是否落入视觉容器,容器内标签不得借标题原型晋升。"""
|
||||
|
||||
center_x = _bbox_center_x(line_bbox)
|
||||
center_y = _bbox_center_y(line_bbox)
|
||||
return any(bbox[0] <= center_x <= bbox[2] and bbox[1] <= center_y <= bbox[3] for bbox in container_bboxes)
|
||||
|
||||
|
||||
def _line_near_visual_container(
|
||||
line_bbox: BBox,
|
||||
container_bboxes: list[BBox],
|
||||
body_height: float,
|
||||
) -> bool:
|
||||
"""检查短行是否紧邻图、表等容器,以纯几何方式抑制 caption 误判。"""
|
||||
|
||||
for container_bbox in container_bboxes:
|
||||
if _bbox_axis_overlap_ratio(line_bbox, container_bbox, axis="x") < 0.35:
|
||||
continue
|
||||
container_width = max(0.1, container_bbox[2] - container_bbox[0])
|
||||
if line_bbox[2] - line_bbox[0] > 0.8 * container_width:
|
||||
continue
|
||||
vertical_gap = max(line_bbox[1] - container_bbox[3], container_bbox[1] - line_bbox[3], 0.0)
|
||||
if vertical_gap <= 2.0 * body_height:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_NUMBERED_SECTION_TITLE_RE",
|
||||
"_SECTION_NUMBER_ONLY_RE",
|
||||
"_SECTION_TITLE_TERMINAL_RE",
|
||||
"_UNNUMBERED_SECTION_HEADING_RE",
|
||||
"_build_physical_title_gap_map",
|
||||
"_line_inside_visual_container",
|
||||
"_line_near_visual_container",
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""使用既有页面分类探测构建全文标题原型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from dataclasses import replace
|
||||
from typing import Literal
|
||||
|
||||
from ..geometry import _rotate_bbox_to_upright
|
||||
from ..line_layout import (
|
||||
_infer_text_lanes,
|
||||
_line_effective_height,
|
||||
_normalized_font_family,
|
||||
)
|
||||
from ..models import (
|
||||
_DocumentBodyProfile,
|
||||
_DocumentTitleProfile,
|
||||
_PreparedPage,
|
||||
_TitleStylePrototype,
|
||||
)
|
||||
from .common import _line_inside_visual_container
|
||||
from .page_titles import _classify_page_titles
|
||||
from .prototype import _title_profile_alignment, _title_profile_seed_matches_cluster
|
||||
|
||||
|
||||
def _infer_document_title_profile(
|
||||
prepared_pages: list[_PreparedPage],
|
||||
document_body_profile: _DocumentBodyProfile | None,
|
||||
) -> _DocumentTitleProfile | None:
|
||||
"""在副本上复用现有标题判定,并把跨页稳定样式聚成标题原型。"""
|
||||
|
||||
if document_body_profile is None:
|
||||
return None
|
||||
seeds: list[
|
||||
tuple[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
float | None,
|
||||
Literal["left", "center"],
|
||||
float,
|
||||
int,
|
||||
]
|
||||
] = []
|
||||
for page_index, prepared in enumerate(prepared_pages):
|
||||
probe_lines = [replace(line) for line in prepared.remaining_lines]
|
||||
container_bboxes = [
|
||||
block["bbox"] for block in prepared.fixed_blocks if not isinstance(block.get("_inline_visual_row_id"), int)
|
||||
]
|
||||
_classify_page_titles(
|
||||
probe_lines,
|
||||
prepared.page_size,
|
||||
page_index=page_index,
|
||||
container_bboxes=container_bboxes,
|
||||
document_body_profile=document_body_profile,
|
||||
)
|
||||
seen_rows: set[tuple[int, int]] = set()
|
||||
for angle in sorted({line.angle for line in probe_lines}):
|
||||
geometry = [
|
||||
(
|
||||
line,
|
||||
_rotate_bbox_to_upright(
|
||||
line.bbox,
|
||||
prepared.page_size,
|
||||
angle,
|
||||
),
|
||||
)
|
||||
for line in probe_lines
|
||||
if line.angle == angle
|
||||
]
|
||||
if not geometry:
|
||||
continue
|
||||
median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in geometry)
|
||||
local_page_width = prepared.page_size[1] if angle in {90, 270} else prepared.page_size[0]
|
||||
lanes = _infer_text_lanes(
|
||||
geometry,
|
||||
local_page_width,
|
||||
median_height,
|
||||
)
|
||||
local_container_bboxes = [
|
||||
_rotate_bbox_to_upright(
|
||||
bbox,
|
||||
prepared.page_size,
|
||||
angle,
|
||||
)
|
||||
for bbox in container_bboxes
|
||||
]
|
||||
for lane in lanes:
|
||||
for line, bbox in lane.lines:
|
||||
line_height_ratio = _line_effective_height(line, bbox) / document_body_profile.body_height
|
||||
large_unresolved_seed = (
|
||||
page_index > 0
|
||||
and line.semantic_type is None
|
||||
and line_height_ratio >= 1.3
|
||||
and bbox[2] - bbox[0] <= 0.8 * local_page_width
|
||||
and not _line_inside_visual_container(
|
||||
bbox,
|
||||
local_container_bboxes,
|
||||
)
|
||||
)
|
||||
if (
|
||||
(line.semantic_type != "paragraph_title" and not large_unresolved_seed)
|
||||
or line.font_signature is None
|
||||
or line.font_coverage < 0.75
|
||||
):
|
||||
continue
|
||||
row_identity = line.visual_row_id if line.visual_row_id is not None else line.source_index
|
||||
row_key = (angle, row_identity)
|
||||
if row_key in seen_rows:
|
||||
continue
|
||||
alignment = _title_profile_alignment(
|
||||
bbox,
|
||||
lane,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
if alignment is None:
|
||||
continue
|
||||
font_family = _normalized_font_family(line.font_signature)
|
||||
if font_family is None:
|
||||
continue
|
||||
mode, anchor_offset = alignment
|
||||
seeds.append(
|
||||
(
|
||||
font_family,
|
||||
line.font_signature[1],
|
||||
line_height_ratio,
|
||||
line.dominant_font_weight,
|
||||
mode,
|
||||
anchor_offset,
|
||||
page_index,
|
||||
)
|
||||
)
|
||||
seen_rows.add(row_key)
|
||||
|
||||
clusters: list[list[tuple[str, int, float, float | None, Literal["left", "center"], float, int]]] = []
|
||||
for seed in sorted(seeds, key=lambda item: (item[0], item[1], item[4], item[2])):
|
||||
target = next(
|
||||
(cluster for cluster in clusters if _title_profile_seed_matches_cluster(seed, cluster)),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
clusters.append([seed])
|
||||
else:
|
||||
target.append(seed)
|
||||
|
||||
prototypes: list[_TitleStylePrototype] = []
|
||||
for cluster in clusters:
|
||||
support_pages = len({seed[6] for seed in cluster})
|
||||
if len(cluster) < 3 and support_pages < 2:
|
||||
continue
|
||||
weights = [seed[3] for seed in cluster if seed[3] is not None]
|
||||
prototypes.append(
|
||||
_TitleStylePrototype(
|
||||
font_family=cluster[0][0],
|
||||
font_flags=cluster[0][1],
|
||||
height_ratio=statistics.median(seed[2] for seed in cluster),
|
||||
weight=statistics.median(weights) if weights else None,
|
||||
alignment=cluster[0][4],
|
||||
anchor_offset=statistics.median(seed[5] for seed in cluster),
|
||||
support_count=len(cluster),
|
||||
support_pages=support_pages,
|
||||
)
|
||||
)
|
||||
if not prototypes:
|
||||
return None
|
||||
prototypes.sort(
|
||||
key=lambda item: (item.support_pages, item.support_count),
|
||||
reverse=True,
|
||||
)
|
||||
return _DocumentTitleProfile(tuple(prototypes))
|
||||
|
||||
|
||||
__all__ = ["_infer_document_title_profile"]
|
||||
@@ -0,0 +1,765 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""在既有正文统计和结构证据下分类栏内标题。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Literal
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import _bbox_axis_overlap_ratio, _bbox_center_x, _bbox_center_y, _bbox_union_many
|
||||
from ..line_layout import (
|
||||
_effective_text_row_gap,
|
||||
_font_signatures_share_family,
|
||||
_font_weights_conflict,
|
||||
_line_effective_height,
|
||||
_should_connect_semantic_rows,
|
||||
_title_fonts_compatible,
|
||||
)
|
||||
from ..models import (
|
||||
_DocumentBodyProfile,
|
||||
_DocumentTitleProfile,
|
||||
_LaneBodyProfile,
|
||||
_LineItem,
|
||||
_TextLane,
|
||||
)
|
||||
from .body_profile import _line_uses_document_regular_font
|
||||
from .common import _line_inside_visual_container, _line_near_visual_container
|
||||
from .prototype import _line_conflicts_document_title_profile, _matching_document_title_prototype
|
||||
|
||||
|
||||
def _classify_paragraph_titles_in_lane(
|
||||
lane: _TextLane,
|
||||
profile: _LaneBodyProfile,
|
||||
local_page_width: float,
|
||||
local_page_height: float,
|
||||
container_bboxes: list[BBox],
|
||||
*,
|
||||
document_title_bottom: float | None,
|
||||
preserve_front_matter_boundaries: bool,
|
||||
physical_gaps: dict[int, tuple[float | None, float | None]],
|
||||
grid_title_suppressions: set[int],
|
||||
document_body_profile: _DocumentBodyProfile | None,
|
||||
document_title_profile: _DocumentTitleProfile | None,
|
||||
page_index: int,
|
||||
) -> None:
|
||||
"""以字号、样式、留白、对齐、栏宽和容器邻接判定段落标题。"""
|
||||
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
rows = lane.lines
|
||||
selected_indices: set[int] = set()
|
||||
front_matter_boundary = _infer_front_matter_boundary(
|
||||
lane,
|
||||
profile,
|
||||
local_page_height,
|
||||
document_title_bottom=document_title_bottom,
|
||||
)
|
||||
for index, (line, bbox) in enumerate(rows):
|
||||
if line.semantic_type is not None:
|
||||
continue
|
||||
if line.source_index in grid_title_suppressions:
|
||||
continue
|
||||
if not 0.07 * local_page_height <= _bbox_center_y(bbox) <= 0.93 * local_page_height:
|
||||
continue
|
||||
inside_front_matter = front_matter_boundary is not None and _bbox_center_y(bbox) <= front_matter_boundary
|
||||
if inside_front_matter and not preserve_front_matter_boundaries:
|
||||
continue
|
||||
if (
|
||||
document_title_bottom is not None
|
||||
and document_title_bottom <= 0.6 * local_page_height
|
||||
and _bbox_center_y(bbox) >= 0.84 * local_page_height
|
||||
):
|
||||
# 首页底部短行通常是版本、日期等封面元数据,不属于正文标题层级。
|
||||
continue
|
||||
line_height = _line_effective_height(line, bbox)
|
||||
title_prototype = _matching_document_title_prototype(
|
||||
line,
|
||||
bbox,
|
||||
lane,
|
||||
document_body_profile,
|
||||
document_title_profile,
|
||||
)
|
||||
title_profile_size_conflict = title_prototype is None and _line_conflicts_document_title_profile(
|
||||
line,
|
||||
bbox,
|
||||
lane,
|
||||
document_body_profile,
|
||||
document_title_profile,
|
||||
)
|
||||
inside_visual_container = _line_inside_visual_container(
|
||||
bbox,
|
||||
container_bboxes,
|
||||
)
|
||||
near_visual_container = _line_near_visual_container(
|
||||
bbox,
|
||||
container_bboxes,
|
||||
profile.body_height,
|
||||
)
|
||||
if inside_visual_container or (near_visual_container and title_prototype is None):
|
||||
continue
|
||||
|
||||
recurrent_regular_font = _line_uses_document_regular_font(
|
||||
line,
|
||||
document_body_profile,
|
||||
)
|
||||
document_regular_body_candidate = (
|
||||
recurrent_regular_font
|
||||
and document_body_profile is not None
|
||||
and line_height >= 0.9 * document_body_profile.body_height
|
||||
)
|
||||
reference_body_height = profile.body_height
|
||||
if profile.body_row_count < 3 and document_body_profile is not None:
|
||||
reference_body_height = document_body_profile.body_height
|
||||
if document_regular_body_candidate and document_body_profile is not None:
|
||||
reference_body_height = max(
|
||||
reference_body_height,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
height_ratio = line_height / reference_body_height
|
||||
width_ratio = (bbox[2] - bbox[0]) / lane_width
|
||||
centered = abs(_bbox_center_x(bbox) - (lane.left + lane.right) / 2.0) <= 0.12 * lane_width
|
||||
left_aligned = abs(bbox[0] - lane.left) <= 0.65 * profile.body_height
|
||||
if line.median_glyph_width is not None and bbox[2] - bbox[0] <= 1.25 * line.median_glyph_width and height_ratio < 1.18:
|
||||
continue
|
||||
style_differs = (
|
||||
not recurrent_regular_font
|
||||
and profile.body_font is not None
|
||||
and line.font_signature is not None
|
||||
and line.font_coverage >= 0.75
|
||||
and not _font_signatures_share_family(
|
||||
line.font_signature,
|
||||
profile.body_font,
|
||||
)
|
||||
)
|
||||
low_coverage_style_differs = (
|
||||
not recurrent_regular_font
|
||||
and profile.body_font is not None
|
||||
and line.font_signature is not None
|
||||
and 0.5 <= line.font_coverage < 0.75
|
||||
and not _font_signatures_share_family(
|
||||
line.font_signature,
|
||||
profile.body_font,
|
||||
)
|
||||
)
|
||||
style_support = profile.style_support.get(line.font_signature, 1.0) if line.font_signature is not None else 1.0
|
||||
weight_emphasized = (
|
||||
profile.body_weight is not None
|
||||
and line.dominant_font_weight is not None
|
||||
and line.dominant_font_weight >= max(profile.body_weight + 100.0, 1.15 * profile.body_weight)
|
||||
)
|
||||
gap_above = _normalized_title_gap(
|
||||
rows,
|
||||
index,
|
||||
direction=-1,
|
||||
body_height=profile.body_height,
|
||||
physical_gaps=physical_gaps,
|
||||
)
|
||||
gap_below = _normalized_title_gap(
|
||||
rows,
|
||||
index,
|
||||
direction=1,
|
||||
body_height=profile.body_height,
|
||||
physical_gaps=physical_gaps,
|
||||
)
|
||||
regular_gap_ratio = profile.regular_gap / max(0.1, profile.body_height)
|
||||
gap_above_excess = max(0.0, gap_above - regular_gap_ratio)
|
||||
gap_below_excess = max(0.0, gap_below - regular_gap_ratio)
|
||||
has_spacing_signal = gap_above >= 0.35
|
||||
|
||||
if _visual_row_has_body_style_sibling(rows, index):
|
||||
continue
|
||||
if _is_near_full_mixed_inline_row(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
profile,
|
||||
):
|
||||
continue
|
||||
if (
|
||||
index > 0
|
||||
and rows[index - 1][0].semantic_type == "paragraph_title"
|
||||
and width_ratio >= 0.85
|
||||
and gap_above < 1.0
|
||||
and not _title_fonts_compatible(rows[index - 1][0], line)
|
||||
):
|
||||
continue
|
||||
if _continues_local_body_row(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
profile,
|
||||
):
|
||||
continue
|
||||
has_following_body_row = _has_following_body_row(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
lane.left,
|
||||
profile,
|
||||
)
|
||||
compact_local_transition = (
|
||||
low_coverage_style_differs
|
||||
and left_aligned
|
||||
and width_ratio <= 0.65
|
||||
and gap_above >= 0.1
|
||||
and gap_below >= 0.2
|
||||
and has_following_body_row
|
||||
)
|
||||
compact_text_section = (
|
||||
height_ratio < 0.9
|
||||
and centered
|
||||
and width_ratio <= 0.7
|
||||
and gap_above >= 0.35
|
||||
and gap_below >= 0.2
|
||||
and (page_index != 0 or style_differs or weight_emphasized or title_prototype is not None)
|
||||
and _has_following_compact_text_section(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
)
|
||||
)
|
||||
weak_regular_pitch_body_candidate = (
|
||||
document_body_profile is not None
|
||||
and 0.9 <= line_height / document_body_profile.body_height <= 1.1
|
||||
and title_prototype is None
|
||||
and (not centered or width_ratio >= 0.75)
|
||||
and not weight_emphasized
|
||||
and gap_above_excess < 0.1
|
||||
and gap_below_excess < 0.1
|
||||
)
|
||||
if weak_regular_pitch_body_candidate:
|
||||
continue
|
||||
if (
|
||||
title_profile_size_conflict
|
||||
and document_body_profile is not None
|
||||
and line_height <= 1.18 * document_body_profile.body_height
|
||||
and not weight_emphasized
|
||||
):
|
||||
continue
|
||||
prototype_promotion = (
|
||||
title_prototype is not None
|
||||
and (width_ratio <= 0.9 or (bbox[2] - bbox[0]) / max(0.1, local_page_width) <= 0.8)
|
||||
and (centered or left_aligned)
|
||||
and (
|
||||
(document_body_profile is not None and line_height >= 1.18 * document_body_profile.body_height)
|
||||
or near_visual_container
|
||||
or (has_following_body_row and gap_above_excess >= 0.1)
|
||||
or gap_above_excess >= 0.35
|
||||
)
|
||||
)
|
||||
prototype_inline_heading = (
|
||||
document_body_profile is not None
|
||||
and line_height <= 1.15 * document_body_profile.body_height
|
||||
and _is_full_width_inline_heading(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
profile,
|
||||
)
|
||||
)
|
||||
if prototype_promotion and not prototype_inline_heading:
|
||||
line.semantic_type = "paragraph_title"
|
||||
selected_indices.add(index)
|
||||
continue
|
||||
if (
|
||||
document_body_profile is not None
|
||||
and 0.9 <= line_height / document_body_profile.body_height <= 1.1
|
||||
and title_prototype is None
|
||||
and centered
|
||||
and not has_following_body_row
|
||||
and not compact_text_section
|
||||
):
|
||||
continue
|
||||
if height_ratio < 0.9 and not inside_front_matter and not has_following_body_row and not compact_text_section:
|
||||
continue
|
||||
if width_ratio >= 0.9 and height_ratio < 1.15 and not (style_differs and gap_above >= 0.65):
|
||||
continue
|
||||
score = 0.0
|
||||
if style_differs or compact_local_transition:
|
||||
score += 2.0
|
||||
if compact_local_transition or style_support <= 0.2:
|
||||
score += 0.75
|
||||
if weight_emphasized:
|
||||
score += 1.0
|
||||
if height_ratio >= 1.18:
|
||||
score += 2.0
|
||||
elif height_ratio <= 0.9 and centered:
|
||||
score += 0.75
|
||||
if width_ratio <= 0.78:
|
||||
score += 0.75
|
||||
if centered and width_ratio <= 0.85:
|
||||
score += 1.25
|
||||
if gap_above >= 0.65:
|
||||
score += 1.25
|
||||
elif gap_above >= 0.35:
|
||||
score += 0.5
|
||||
if gap_below >= 0.35:
|
||||
score += 0.75
|
||||
if left_aligned:
|
||||
score += 0.25
|
||||
|
||||
precisely_centered = abs(_bbox_center_x(bbox) - (lane.left + lane.right) / 2.0) <= 0.05 * lane_width
|
||||
centered_structural_fallback = (
|
||||
0.75 <= height_ratio <= 1.15
|
||||
and precisely_centered
|
||||
and width_ratio <= 0.7
|
||||
and not inside_front_matter
|
||||
and not (page_index == 0 and _bbox_center_y(bbox) <= 0.2 * local_page_height)
|
||||
and 0.35 <= gap_above <= 1.5
|
||||
and 0.2 <= gap_below <= 1.5
|
||||
and has_following_body_row
|
||||
and not _is_continuous_field_row(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
profile,
|
||||
)
|
||||
)
|
||||
strong_layout_signal = (
|
||||
height_ratio >= 1.18
|
||||
or style_differs
|
||||
or compact_local_transition
|
||||
or weight_emphasized
|
||||
or centered_structural_fallback
|
||||
or compact_text_section
|
||||
)
|
||||
if score >= 4.0 and (has_spacing_signal or compact_local_transition) and strong_layout_signal:
|
||||
if _is_full_width_inline_heading(
|
||||
rows,
|
||||
index,
|
||||
lane_width,
|
||||
profile,
|
||||
):
|
||||
continue
|
||||
line.semantic_type = "paragraph_title"
|
||||
selected_indices.add(index)
|
||||
|
||||
_unify_visual_row_title_types(
|
||||
lane,
|
||||
selected_indices,
|
||||
)
|
||||
_expand_paragraph_title_neighbors(
|
||||
lane,
|
||||
selected_indices,
|
||||
profile,
|
||||
)
|
||||
if front_matter_boundary is not None and preserve_front_matter_boundaries:
|
||||
_protect_front_matter_title_types(
|
||||
lane,
|
||||
profile,
|
||||
front_matter_boundary,
|
||||
)
|
||||
|
||||
|
||||
def _protect_front_matter_title_types(
|
||||
lane: _TextLane,
|
||||
profile: _LaneBodyProfile,
|
||||
front_matter_boundary: float,
|
||||
) -> None:
|
||||
"""把作者区误命中的标题降为正文,并仅保留原本需要跨行聚合的内部标记。"""
|
||||
|
||||
front_title_indices = [
|
||||
index
|
||||
for index, (line, bbox) in enumerate(lane.lines)
|
||||
if line.semantic_type == "paragraph_title" and _bbox_center_y(bbox) <= front_matter_boundary
|
||||
]
|
||||
connected_indices: set[int] = set()
|
||||
for position, first_index in enumerate(front_title_indices):
|
||||
for second_index in front_title_indices[position + 1 :]:
|
||||
if _should_connect_semantic_rows(
|
||||
lane.lines[first_index],
|
||||
lane.lines[second_index],
|
||||
lane,
|
||||
profile.regular_gap,
|
||||
[],
|
||||
[],
|
||||
):
|
||||
connected_indices.update((first_index, second_index))
|
||||
for index in front_title_indices:
|
||||
lane.lines[index][0].semantic_type = "text" if index in connected_indices else None
|
||||
|
||||
|
||||
def _visual_row_has_body_style_sibling(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
) -> bool:
|
||||
"""检查同一完整视觉行是否同时包含标题样式与正文样式 run。"""
|
||||
|
||||
line, _bbox = rows[index]
|
||||
if line.visual_row_id is None:
|
||||
return False
|
||||
siblings = [
|
||||
sibling for sibling, _sibling_bbox in rows if sibling is not line and sibling.visual_row_id == line.visual_row_id
|
||||
]
|
||||
return any(not _title_fonts_compatible(line, sibling) for sibling in siblings)
|
||||
|
||||
|
||||
def _is_near_full_mixed_inline_row(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
profile: _LaneBodyProfile,
|
||||
) -> bool:
|
||||
"""识别近满栏混合字体行内强调,避免把粗体条目头单独标成标题。"""
|
||||
|
||||
line, bbox = rows[index]
|
||||
line_height = _line_effective_height(line, bbox)
|
||||
if (
|
||||
line.font_coverage >= 0.95
|
||||
or (bbox[2] - bbox[0]) < 0.85 * lane_width
|
||||
or not 0.85 <= line_height / profile.body_height <= 1.15
|
||||
or index + 1 >= len(rows)
|
||||
):
|
||||
return False
|
||||
next_line, next_bbox = rows[index + 1]
|
||||
next_height = _line_effective_height(next_line, next_bbox)
|
||||
gap_below = _effective_text_row_gap(rows[index], rows[index + 1])
|
||||
return (
|
||||
next_line.semantic_type is None
|
||||
and 0.8 <= next_height / profile.body_height <= 1.2
|
||||
and gap_below <= profile.regular_gap + 0.25 * profile.body_height
|
||||
)
|
||||
|
||||
|
||||
def _continues_local_body_row(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
profile: _LaneBodyProfile,
|
||||
) -> bool:
|
||||
"""识别紧随同字体满行的正文尾行,阻止全局小字号基线造成标题误判。"""
|
||||
|
||||
if index <= 0:
|
||||
return False
|
||||
previous_line, previous_bbox = rows[index - 1]
|
||||
line, bbox = rows[index]
|
||||
if previous_line.semantic_type is not None:
|
||||
return False
|
||||
previous_visual_row = (
|
||||
[item for item in rows[:index] if item[0].visual_row_id == previous_line.visual_row_id]
|
||||
if previous_line.visual_row_id is not None
|
||||
else [rows[index - 1]]
|
||||
)
|
||||
previous_style_members = [
|
||||
member
|
||||
for member, _member_bbox in previous_visual_row
|
||||
if member.font_signature is not None and member.font_coverage >= 0.5
|
||||
]
|
||||
if (
|
||||
line.font_signature is not None
|
||||
and previous_style_members
|
||||
and not any(
|
||||
_font_signatures_share_family(member.font_signature, line.font_signature)
|
||||
and not _font_weights_conflict(member, line)
|
||||
for member in previous_style_members
|
||||
)
|
||||
):
|
||||
return False
|
||||
previous_bbox = _bbox_union_many([member_bbox for _member, member_bbox in previous_visual_row])
|
||||
previous_height = statistics.median(
|
||||
_line_effective_height(member, member_bbox) for member, member_bbox in previous_visual_row
|
||||
)
|
||||
line_height = _line_effective_height(line, bbox)
|
||||
pair_height = max(previous_height, line_height)
|
||||
gap = _effective_text_row_gap(
|
||||
(previous_line, previous_bbox),
|
||||
rows[index],
|
||||
)
|
||||
return (
|
||||
0.8 <= line_height / previous_height <= 1.25
|
||||
and previous_bbox[2] - previous_bbox[0] >= 0.75 * lane_width
|
||||
and abs(bbox[0] - previous_bbox[0]) <= 2.0 * pair_height
|
||||
and -0.25 * pair_height <= gap <= profile.regular_gap + pair_height
|
||||
)
|
||||
|
||||
|
||||
def _is_continuous_field_row(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
profile: _LaneBodyProfile,
|
||||
) -> bool:
|
||||
"""识别同缩进、同尺度且紧邻的连续字段行,阻止其借居中误差成为标题。"""
|
||||
|
||||
_line, bbox = rows[index]
|
||||
line_height = _line_effective_height(*rows[index])
|
||||
if not 0.85 <= line_height / profile.body_height <= 1.15:
|
||||
return False
|
||||
for neighbor_index in (index - 1, index + 1):
|
||||
if not 0 <= neighbor_index < len(rows):
|
||||
continue
|
||||
neighbor_line, neighbor_bbox = rows[neighbor_index]
|
||||
neighbor_height = _line_effective_height(neighbor_line, neighbor_bbox)
|
||||
if (
|
||||
neighbor_line.semantic_type is None
|
||||
and 0.85 <= neighbor_height / profile.body_height <= 1.15
|
||||
and neighbor_bbox[2] - neighbor_bbox[0] >= 0.35 * lane_width
|
||||
and abs(neighbor_bbox[0] - bbox[0]) <= 0.75 * profile.body_height
|
||||
and max(
|
||||
_effective_text_row_gap(rows[neighbor_index], rows[index]),
|
||||
_effective_text_row_gap(rows[index], rows[neighbor_index]),
|
||||
)
|
||||
<= 1.5 * profile.body_height
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_full_width_inline_heading(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
profile: _LaneBodyProfile,
|
||||
) -> bool:
|
||||
"""识别后接常规正文续行的满栏正常字号行内标题。"""
|
||||
|
||||
line, bbox = rows[index]
|
||||
line_height = _line_effective_height(line, bbox)
|
||||
if (
|
||||
not 0.85 <= line_height / profile.body_height <= 1.15
|
||||
or (bbox[2] - bbox[0]) < 0.9 * lane_width
|
||||
or index + 1 >= len(rows)
|
||||
):
|
||||
return False
|
||||
|
||||
next_line, next_bbox = rows[index + 1]
|
||||
next_height = _line_effective_height(next_line, next_bbox)
|
||||
next_uses_body_font = (
|
||||
profile.body_font is None
|
||||
or next_line.font_signature is None
|
||||
or next_line.font_coverage < 0.75
|
||||
or _font_signatures_share_family(
|
||||
next_line.font_signature,
|
||||
profile.body_font,
|
||||
)
|
||||
)
|
||||
return (
|
||||
next_line.semantic_type is None
|
||||
and next_uses_body_font
|
||||
and 0.75 <= next_height / profile.body_height <= 1.15
|
||||
and (next_bbox[2] - next_bbox[0]) >= 0.75 * lane_width
|
||||
and _effective_text_row_gap(rows[index], rows[index + 1]) <= profile.regular_gap + 0.25 * profile.body_height
|
||||
)
|
||||
|
||||
|
||||
def _has_following_body_row(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
lane_left: float,
|
||||
profile: _LaneBodyProfile,
|
||||
) -> bool:
|
||||
"""只检查紧邻且空间相关的下一行,禁止用远处正文支撑小字号标题。"""
|
||||
|
||||
if index + 1 >= len(rows):
|
||||
return False
|
||||
current = rows[index]
|
||||
line, bbox = rows[index + 1]
|
||||
gap = _effective_text_row_gap(current, (line, bbox))
|
||||
horizontally_related = (
|
||||
_bbox_axis_overlap_ratio(current[1], bbox, axis="x") >= 0.35
|
||||
or abs(current[1][0] - bbox[0]) <= 0.75 * profile.body_height
|
||||
)
|
||||
current_centered = abs(_bbox_center_x(current[1]) - (lane_left + lane_width / 2.0)) <= 0.12 * lane_width
|
||||
compact_left_aligned = (
|
||||
current[1][2] - current[1][0] <= 0.35 * lane_width and abs(current[1][0] - lane_left) <= 0.65 * profile.body_height
|
||||
)
|
||||
maximum_gap = (1.5 if current_centered else 1.25 if compact_left_aligned else 0.75) * profile.body_height
|
||||
height_ratio = _line_effective_height(line, bbox) / profile.body_height
|
||||
width_ratio = (bbox[2] - bbox[0]) / lane_width
|
||||
return (
|
||||
line.semantic_type is None
|
||||
and horizontally_related
|
||||
and -0.25 * profile.body_height <= gap <= maximum_gap
|
||||
and 0.9 <= height_ratio <= 1.2
|
||||
and width_ratio >= 0.45
|
||||
)
|
||||
|
||||
|
||||
def _has_following_compact_text_section(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
lane_width: float,
|
||||
) -> bool:
|
||||
"""检查小字号候选后是否紧接三行同尺度的宽文本区段。"""
|
||||
|
||||
candidate = rows[index]
|
||||
candidate_height = _line_effective_height(*candidate)
|
||||
following_rows = rows[index + 1 : index + 4]
|
||||
if len(following_rows) < 3:
|
||||
return False
|
||||
|
||||
previous = candidate
|
||||
for current in following_rows:
|
||||
line, bbox = current
|
||||
height_ratio = _line_effective_height(line, bbox) / candidate_height
|
||||
width_ratio = (bbox[2] - bbox[0]) / lane_width
|
||||
gap = _effective_text_row_gap(previous, current)
|
||||
if (
|
||||
line.semantic_type is not None
|
||||
or not 0.75 <= height_ratio <= 1.25
|
||||
or width_ratio < 0.45
|
||||
or not -0.25 * candidate_height <= gap <= 0.75 * candidate_height
|
||||
):
|
||||
return False
|
||||
previous = current
|
||||
return True
|
||||
|
||||
|
||||
def _unify_visual_row_title_types(
|
||||
lane: _TextLane,
|
||||
selected_indices: set[int],
|
||||
) -> None:
|
||||
"""按完整 visual_row_id 统一标题类型:同样式整行晋升,混合样式整行降级。"""
|
||||
|
||||
row_indices: dict[int, list[int]] = {}
|
||||
for index, (line, _bbox) in enumerate(lane.lines):
|
||||
if line.visual_row_id is not None:
|
||||
row_indices.setdefault(line.visual_row_id, []).append(index)
|
||||
for indices in row_indices.values():
|
||||
title_indices = [index for index in indices if lane.lines[index][0].semantic_type == "paragraph_title"]
|
||||
if not title_indices:
|
||||
continue
|
||||
anchor_line, anchor_bbox = lane.lines[title_indices[0]]
|
||||
anchor_height = _line_effective_height(anchor_line, anchor_bbox)
|
||||
compatible = all(
|
||||
0.75 <= _line_effective_height(lane.lines[index][0], lane.lines[index][1]) / anchor_height <= 1.25
|
||||
and _title_fonts_compatible(anchor_line, lane.lines[index][0])
|
||||
for index in indices
|
||||
)
|
||||
if compatible:
|
||||
for index in indices:
|
||||
lane.lines[index][0].semantic_type = "paragraph_title"
|
||||
selected_indices.add(index)
|
||||
continue
|
||||
for index in title_indices:
|
||||
lane.lines[index][0].semantic_type = None
|
||||
selected_indices.discard(index)
|
||||
|
||||
|
||||
def _infer_front_matter_boundary(
|
||||
lane: _TextLane,
|
||||
profile: _LaneBodyProfile,
|
||||
local_page_height: float,
|
||||
*,
|
||||
document_title_bottom: float | None,
|
||||
) -> float | None:
|
||||
"""用短行后衔接满栏正文的几何转折点界定首页作者等前置信息。"""
|
||||
|
||||
if document_title_bottom is None:
|
||||
return None
|
||||
default_boundary = max(
|
||||
document_title_bottom + 2.5 * profile.body_height,
|
||||
0.39 * local_page_height,
|
||||
)
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
for index, (line, bbox) in enumerate(lane.lines[:-1]):
|
||||
if bbox[1] <= document_title_bottom:
|
||||
continue
|
||||
next_line, next_bbox = lane.lines[index + 1]
|
||||
width_ratio = (bbox[2] - bbox[0]) / lane_width
|
||||
next_width_ratio = (next_bbox[2] - next_bbox[0]) / lane_width
|
||||
left_aligned = abs(bbox[0] - lane.left) <= 0.65 * profile.body_height
|
||||
centered = abs(_bbox_center_x(bbox) - (lane.left + lane.right) / 2.0) <= 0.12 * lane_width
|
||||
next_height_ratio = _line_effective_height(next_line, next_bbox) / profile.body_height
|
||||
gap_below = max(0.0, _effective_text_row_gap((line, bbox), (next_line, next_bbox)))
|
||||
if (
|
||||
width_ratio <= 0.35
|
||||
and next_width_ratio >= 0.75
|
||||
and (left_aligned or centered)
|
||||
and 0.75 <= next_height_ratio <= 1.35
|
||||
and gap_below <= 1.5 * profile.body_height
|
||||
):
|
||||
return min(default_boundary, bbox[1] - 0.1)
|
||||
return default_boundary
|
||||
|
||||
|
||||
def _normalized_title_gap(
|
||||
rows: list[tuple[_LineItem, BBox]],
|
||||
index: int,
|
||||
*,
|
||||
direction: Literal[-1, 1],
|
||||
body_height: float,
|
||||
physical_gaps: dict[int, tuple[float | None, float | None]],
|
||||
) -> float:
|
||||
"""返回栏内邻行和页面最近物理邻行中较小的归一化净空。"""
|
||||
|
||||
neighbor_index = index + direction
|
||||
if 0 <= neighbor_index < len(rows):
|
||||
if direction < 0:
|
||||
lane_gap = _effective_text_row_gap(rows[neighbor_index], rows[index])
|
||||
else:
|
||||
lane_gap = _effective_text_row_gap(rows[index], rows[neighbor_index])
|
||||
else:
|
||||
lane_gap = 0.5 * body_height
|
||||
physical_pair = physical_gaps.get(rows[index][0].source_index, (None, None))
|
||||
physical_gap = physical_pair[0 if direction < 0 else 1]
|
||||
gap = lane_gap if physical_gap is None else min(lane_gap, physical_gap)
|
||||
return max(0.0, gap) / max(0.1, body_height)
|
||||
|
||||
|
||||
def _expand_paragraph_title_neighbors(
|
||||
lane: _TextLane,
|
||||
selected_indices: set[int],
|
||||
profile: _LaneBodyProfile,
|
||||
) -> None:
|
||||
"""用相邻行的字体、尺寸、对齐和紧凑净空补齐折行段落标题。"""
|
||||
|
||||
if not selected_indices:
|
||||
return
|
||||
rows = lane.lines
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
pending = list(selected_indices)
|
||||
while pending:
|
||||
selected_index = pending.pop()
|
||||
selected_line, selected_bbox = rows[selected_index]
|
||||
selected_height = _line_effective_height(selected_line, selected_bbox)
|
||||
for candidate_index in (selected_index - 1, selected_index + 1):
|
||||
if not 0 <= candidate_index < len(rows) or candidate_index in selected_indices:
|
||||
continue
|
||||
candidate_line, candidate_bbox = rows[candidate_index]
|
||||
if candidate_line.semantic_type is not None:
|
||||
continue
|
||||
candidate_height = _line_effective_height(candidate_line, candidate_bbox)
|
||||
if not 0.75 <= candidate_height / selected_height <= 1.25:
|
||||
continue
|
||||
if candidate_bbox[2] - candidate_bbox[0] > 0.8 * lane_width:
|
||||
continue
|
||||
if (
|
||||
selected_line.font_signature is not None
|
||||
and candidate_line.font_signature is not None
|
||||
and selected_line.font_signature != candidate_line.font_signature
|
||||
):
|
||||
continue
|
||||
if not _title_fonts_compatible(selected_line, candidate_line):
|
||||
continue
|
||||
vertical_gap = max(
|
||||
candidate_bbox[1] - selected_bbox[3],
|
||||
selected_bbox[1] - candidate_bbox[3],
|
||||
0.0,
|
||||
)
|
||||
centers_align = abs(_bbox_center_x(candidate_bbox) - _bbox_center_x(selected_bbox)) <= 0.15 * lane_width
|
||||
left_edges_align = abs(candidate_bbox[0] - selected_bbox[0]) <= 0.75 * profile.body_height
|
||||
wrapped_indent = abs(candidate_bbox[0] - selected_bbox[0]) <= 2.0 * profile.body_height
|
||||
if vertical_gap > 0.65 * profile.body_height or not (centers_align or left_edges_align or wrapped_indent):
|
||||
continue
|
||||
candidate_line.semantic_type = "paragraph_title"
|
||||
selected_indices.add(candidate_index)
|
||||
pending.append(candidate_index)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_classify_paragraph_titles_in_lane",
|
||||
"_protect_front_matter_title_types",
|
||||
"_visual_row_has_body_style_sibling",
|
||||
"_is_near_full_mixed_inline_row",
|
||||
"_continues_local_body_row",
|
||||
"_is_continuous_field_row",
|
||||
"_is_full_width_inline_heading",
|
||||
"_has_following_body_row",
|
||||
"_has_following_compact_text_section",
|
||||
"_unify_visual_row_title_types",
|
||||
"_infer_front_matter_boundary",
|
||||
"_normalized_title_gap",
|
||||
"_expand_paragraph_title_neighbors",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""匹配标题原型的字体、尺度和对齐特征。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from typing import Literal
|
||||
|
||||
from .....types import BBox
|
||||
from ..geometry import _bbox_center_x
|
||||
from ..line_layout import (
|
||||
_line_effective_height,
|
||||
_normalized_font_family,
|
||||
)
|
||||
from ..models import (
|
||||
_DocumentBodyProfile,
|
||||
_DocumentTitleProfile,
|
||||
_LineItem,
|
||||
_TextLane,
|
||||
_TitleStylePrototype,
|
||||
)
|
||||
|
||||
|
||||
def _title_profile_seed_matches_cluster(
|
||||
seed: tuple[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
float | None,
|
||||
Literal["left", "center"],
|
||||
float,
|
||||
int,
|
||||
],
|
||||
cluster: list[
|
||||
tuple[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
float | None,
|
||||
Literal["left", "center"],
|
||||
float,
|
||||
int,
|
||||
]
|
||||
],
|
||||
) -> bool:
|
||||
"""判断标题种子是否与已有字体、尺度和对齐簇兼容。"""
|
||||
|
||||
reference = cluster[0]
|
||||
median_ratio = statistics.median(item[2] for item in cluster)
|
||||
median_anchor = statistics.median(item[5] for item in cluster)
|
||||
weights = [item[3] for item in cluster if item[3] is not None]
|
||||
weight_compatible = (
|
||||
seed[3] is None
|
||||
or not weights
|
||||
or abs(seed[3] - statistics.median(weights)) < 100.0
|
||||
or max(seed[3], statistics.median(weights)) < 1.15 * min(seed[3], statistics.median(weights))
|
||||
)
|
||||
return (
|
||||
seed[0] == reference[0]
|
||||
and seed[1] == reference[1]
|
||||
and seed[4] == reference[4]
|
||||
and abs(seed[2] - median_ratio) <= 0.1 * median_ratio
|
||||
and abs(seed[5] - median_anchor) <= 0.12
|
||||
and weight_compatible
|
||||
)
|
||||
|
||||
|
||||
def _title_profile_alignment(
|
||||
bbox: BBox,
|
||||
lane: _TextLane,
|
||||
body_height: float,
|
||||
) -> tuple[Literal["left", "center"], float] | None:
|
||||
"""返回标题行的栏内对齐模式及归一化锚点偏移。"""
|
||||
|
||||
lane_width = max(0.1, lane.right - lane.left)
|
||||
center_offset = (_bbox_center_x(bbox) - (lane.left + lane.right) / 2.0) / lane_width
|
||||
left_offset = (bbox[0] - lane.left) / lane_width
|
||||
if abs(center_offset) <= 0.12 and bbox[2] - bbox[0] <= 0.85 * lane_width:
|
||||
return "center", center_offset
|
||||
if abs(bbox[0] - lane.left) <= 3.0 * body_height:
|
||||
return "left", left_offset
|
||||
return None
|
||||
|
||||
|
||||
def _matching_document_title_prototype(
|
||||
line: _LineItem,
|
||||
bbox: BBox,
|
||||
lane: _TextLane,
|
||||
document_body_profile: _DocumentBodyProfile | None,
|
||||
document_title_profile: _DocumentTitleProfile | None,
|
||||
) -> _TitleStylePrototype | None:
|
||||
"""返回与当前行字体、字号、字重和栏内锚点兼容的最强标题原型。"""
|
||||
|
||||
if (
|
||||
document_body_profile is None
|
||||
or document_title_profile is None
|
||||
or line.font_signature is None
|
||||
or line.font_coverage < 0.75
|
||||
):
|
||||
return None
|
||||
font_family = _normalized_font_family(line.font_signature)
|
||||
if font_family is None:
|
||||
return None
|
||||
height_ratio = _line_effective_height(line, bbox) / max(
|
||||
0.1,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
candidates: list[_TitleStylePrototype] = []
|
||||
for prototype in document_title_profile.prototypes:
|
||||
if (
|
||||
prototype.font_family != font_family
|
||||
or prototype.font_flags != line.font_signature[1]
|
||||
or abs(height_ratio - prototype.height_ratio) > 0.1 * prototype.height_ratio
|
||||
):
|
||||
continue
|
||||
if (
|
||||
prototype.weight is not None
|
||||
and line.dominant_font_weight is not None
|
||||
and abs(prototype.weight - line.dominant_font_weight) >= 100.0
|
||||
and max(prototype.weight, line.dominant_font_weight) >= 1.15 * min(prototype.weight, line.dominant_font_weight)
|
||||
):
|
||||
continue
|
||||
alignment = _title_profile_alignment(
|
||||
bbox,
|
||||
lane,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
if alignment is None or alignment[0] != prototype.alignment or abs(alignment[1] - prototype.anchor_offset) > 0.15:
|
||||
continue
|
||||
candidates.append(prototype)
|
||||
return max(
|
||||
candidates,
|
||||
key=lambda item: (item.support_pages, item.support_count),
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def _line_conflicts_document_title_profile(
|
||||
line: _LineItem,
|
||||
bbox: BBox,
|
||||
lane: _TextLane,
|
||||
document_body_profile: _DocumentBodyProfile | None,
|
||||
document_title_profile: _DocumentTitleProfile | None,
|
||||
) -> bool:
|
||||
"""识别字体与标题原型一致、但字号明显落入正文带的弱标题候选。"""
|
||||
|
||||
if (
|
||||
document_body_profile is None
|
||||
or document_title_profile is None
|
||||
or line.font_signature is None
|
||||
or line.font_coverage < 0.75
|
||||
):
|
||||
return False
|
||||
font_family = _normalized_font_family(line.font_signature)
|
||||
alignment = _title_profile_alignment(
|
||||
bbox,
|
||||
lane,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
if font_family is None or alignment is None:
|
||||
return False
|
||||
height_ratio = _line_effective_height(line, bbox) / max(
|
||||
0.1,
|
||||
document_body_profile.body_height,
|
||||
)
|
||||
for prototype in document_title_profile.prototypes:
|
||||
if (
|
||||
not _title_font_families_compatible(
|
||||
prototype.font_family,
|
||||
font_family,
|
||||
)
|
||||
or prototype.font_flags != line.font_signature[1]
|
||||
or prototype.alignment != alignment[0]
|
||||
or abs(prototype.anchor_offset - alignment[1]) > 0.15
|
||||
):
|
||||
continue
|
||||
if (
|
||||
prototype.weight is not None
|
||||
and line.dominant_font_weight is not None
|
||||
and abs(prototype.weight - line.dominant_font_weight) >= 100.0
|
||||
and max(prototype.weight, line.dominant_font_weight) >= 1.15 * min(prototype.weight, line.dominant_font_weight)
|
||||
):
|
||||
continue
|
||||
if abs(height_ratio - prototype.height_ratio) > 0.15 * prototype.height_ratio:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _title_font_families_compatible(first: str, second: str) -> bool:
|
||||
"""忽略已由 flags 和字重单独约束的常见字体样式后缀。"""
|
||||
|
||||
if first == second:
|
||||
return True
|
||||
style_suffixes = (",bold", "bold", ",regular", "regular", ",medium", "medium")
|
||||
return any(first.removesuffix(suffix) == second or second.removesuffix(suffix) == first for suffix in style_suffixes)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_title_profile_seed_matches_cluster",
|
||||
"_title_profile_alignment",
|
||||
"_matching_document_title_prototype",
|
||||
"_line_conflicts_document_title_profile",
|
||||
"_title_font_families_compatible",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
+72
-3443
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""提供原生文本与布局共享的字体族归一化。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _normalized_font_family(
|
||||
signature: tuple[str, int] | None,
|
||||
) -> str | None:
|
||||
"""移除 PDF 字体子集前缀并归一化字体族名称,供几何续行作软兼容判断。"""
|
||||
|
||||
if signature is None:
|
||||
return None
|
||||
name = re.sub(r"^[A-Z]{6}\+", "", signature[0])
|
||||
return re.sub(r"[\s_-]+", "", name).casefold() or None
|
||||
|
||||
|
||||
__all__ = ["_normalized_font_family"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
"""VLM model runtime helpers."""
|
||||
|
||||
__all__ = ["engine_utils", "runtime"]
|
||||
__all__ = ["client", "engine_utils", "runtime"]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""解析与预加载共享的 VLM 客户端构造入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...config import VlmConfig, config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mineru_vl_utils import MinerUClient
|
||||
|
||||
|
||||
def get_vlm_predictor(vlm_config: VlmConfig | None = None) -> tuple[MinerUClient, str]:
|
||||
"""按连接配置选择远程客户端或本地引擎,预加载与真实解析复用相同缓存。"""
|
||||
settings = (vlm_config if vlm_config is not None else config.model.vlm).model_copy(deep=True)
|
||||
settings.validate_environment()
|
||||
from .runtime import ModelSingleton
|
||||
|
||||
if settings.server_url:
|
||||
predictor = ModelSingleton().get_model(
|
||||
backend="http-client",
|
||||
model_path=None,
|
||||
server_url=settings.server_url,
|
||||
model_name=settings.model or None,
|
||||
server_headers={"Authorization": f"Bearer {settings.api_key}"} if settings.api_key else {},
|
||||
http_timeout=settings.http_timeout,
|
||||
max_concurrency=settings.max_concurrency,
|
||||
)
|
||||
return predictor, "http-client"
|
||||
|
||||
from .selector import get_vlm_engine
|
||||
|
||||
engine = get_vlm_engine("auto", is_async=False)
|
||||
return ModelSingleton().get_model(backend=engine, model_path=None, server_url=None), engine
|
||||
|
||||
|
||||
__all__ = ["get_vlm_predictor"]
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import atexit
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
@@ -51,11 +52,25 @@ class ModelSingleton:
|
||||
],
|
||||
model_path: str | None,
|
||||
server_url: str | None,
|
||||
*,
|
||||
model_name: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> MinerUClient:
|
||||
key = (backend, model_path, server_url)
|
||||
"""缓存模型实例,远程客户端按模型、凭据与连接选项隔离。"""
|
||||
cache_key = (backend, model_path, server_url)
|
||||
if backend == "http-client":
|
||||
connection_options = {
|
||||
"model_name": model_name,
|
||||
"server_headers": kwargs.get("server_headers") or {},
|
||||
"http_timeout": kwargs.get("http_timeout", 600),
|
||||
"max_concurrency": kwargs.get("max_concurrency", 100),
|
||||
"max_retries": kwargs.get("max_retries", 3),
|
||||
"retry_backoff_factor": kwargs.get("retry_backoff_factor", 0.5),
|
||||
}
|
||||
fingerprint = hashlib.sha256(json.dumps(connection_options, sort_keys=True).encode()).hexdigest()
|
||||
cache_key = (*cache_key, fingerprint)
|
||||
with self._lock:
|
||||
if key not in self._models:
|
||||
if cache_key not in self._models:
|
||||
start_time = time.time()
|
||||
model = None
|
||||
processor = None
|
||||
@@ -141,7 +156,7 @@ class ModelSingleton:
|
||||
from mineru_vl_utils.mlx_compat import load_mlx_model
|
||||
|
||||
model, processor = load_mlx_model(model_path)
|
||||
else:
|
||||
elif backend != "http-client":
|
||||
if os.getenv("OMP_NUM_THREADS") is None:
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
|
||||
@@ -256,6 +271,7 @@ class ModelSingleton:
|
||||
)
|
||||
predictor = MinerUClient(
|
||||
backend=backend,
|
||||
model_name=model_name,
|
||||
model=model,
|
||||
processor=processor,
|
||||
lmdeploy_engine=lmdeploy_engine,
|
||||
@@ -283,10 +299,10 @@ class ModelSingleton:
|
||||
"llama_cpp_engine": llama_cpp_engine,
|
||||
}
|
||||
_maybe_enable_serial_execution(predictor, backend)
|
||||
self._models[key] = predictor
|
||||
self._models[cache_key] = predictor
|
||||
elapsed = round(time.time() - start_time, 2)
|
||||
logger.info(f"get {backend} predictor cost: {elapsed}s")
|
||||
return self._models[key]
|
||||
return self._models[cache_key]
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._lock:
|
||||
|
||||
@@ -4,14 +4,16 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from ..config import VlmConfig
|
||||
from ..model.flash.html import HtmlSourceContext
|
||||
from ..types import Tier
|
||||
from .api_client import MinerUApiParser
|
||||
from .api_client import ApiJobStatus, MinerUApiParser
|
||||
from .base import MIDDLE_JSON_SCHEMA_VERSION, DocumentParser, ParseResult
|
||||
from .mineru_parser import MinerUParser
|
||||
from .tier import PARSER_BACKENDS, backend_for_tier
|
||||
|
||||
__all__ = [
|
||||
"ApiJobStatus",
|
||||
"backend_for_tier",
|
||||
"PARSER_BACKENDS",
|
||||
"DocumentParser",
|
||||
@@ -32,9 +34,10 @@ def parse(
|
||||
image_analysis: bool = True,
|
||||
page_range: str = "",
|
||||
source_context: HtmlSourceContext | None = None,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> ParseResult:
|
||||
"""同步解析文档;source_context 仅供保留 HTML 原始来源的内部调用方使用。"""
|
||||
parser = MinerUParser(tier=tier, parse_mode=ocr_mode, image_analysis=image_analysis)
|
||||
parser = MinerUParser(tier=tier, parse_mode=ocr_mode, image_analysis=image_analysis, vlm_config=vlm_config)
|
||||
return parser.parse(path, page_range=page_range, source_context=source_context)
|
||||
|
||||
|
||||
@@ -46,7 +49,8 @@ async def parse_async(
|
||||
image_analysis: bool = True,
|
||||
page_range: str = "",
|
||||
source_context: HtmlSourceContext | None = None,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> ParseResult:
|
||||
"""异步解析文档;source_context 仅供保留 HTML 原始来源的内部调用方使用。"""
|
||||
parser = MinerUParser(tier=tier, parse_mode=ocr_mode, image_analysis=image_analysis)
|
||||
parser = MinerUParser(tier=tier, parse_mode=ocr_mode, image_analysis=image_analysis, vlm_config=vlm_config)
|
||||
return await parser.parse_async(path, page_range=page_range, source_context=source_context)
|
||||
|
||||
+61
-11
@@ -19,8 +19,9 @@ import os
|
||||
import random
|
||||
import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -41,6 +42,19 @@ _TRANSPORT_RETRY_MAX_DELAY_SECONDS = 2.0
|
||||
logger = logging.getLogger("mineru.api_client")
|
||||
|
||||
_RequestMethod = Literal["GET", "POST", "PUT"]
|
||||
ApiJobStatus = Literal["queued", "running", "completed", "partial", "failed", "canceled"]
|
||||
_JOB_STATUSES = {"queued", "running", *_TERMINAL_JOB_STATUSES}
|
||||
|
||||
|
||||
def _notify_job_status(job: dict[str, Any], callback: Callable[[ApiJobStatus], None] | None) -> None:
|
||||
"""通知观察者真实任务状态,隔离回调错误并忽略未知的扩展状态。"""
|
||||
status = job.get("status")
|
||||
if callback is None or not isinstance(status, str) or status not in _JOB_STATUSES:
|
||||
return
|
||||
try:
|
||||
callback(cast(ApiJobStatus, status))
|
||||
except Exception:
|
||||
logger.exception("Parse job status callback failed")
|
||||
|
||||
|
||||
class _APITransportError(Exception):
|
||||
@@ -74,6 +88,7 @@ class MinerUApiParser(DocumentParser):
|
||||
Constructor parameters:
|
||||
|
||||
- ``tier`` → v1 ``tier`` (``"flash"`` / ``"basic"`` / ``"standard"`` / ``"advanced"``); ``None`` omits the field
|
||||
- ``ocr_mode`` → v1 request OCR mode; ``None`` omits the field (server default: ``"auto"``)
|
||||
- ``page_range`` → per-file v1 ``page_range``
|
||||
"""
|
||||
|
||||
@@ -85,38 +100,57 @@ class MinerUApiParser(DocumentParser):
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
tier: Tier | None = None,
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] | None = None,
|
||||
include_images: bool = False,
|
||||
include_model_output: bool = False,
|
||||
) -> None:
|
||||
"""保存请求配置;未指定 OCR 模式时省略字段,保留既有客户端的请求结构。"""
|
||||
if ocr_mode is not None and ocr_mode not in ("auto", "txt", "ocr"):
|
||||
raise ValueError(f"Unsupported OCR mode '{ocr_mode}'")
|
||||
self._base_url = (api_url or os.environ.get("MINERU_API_URL") or self.DEFAULT_API_URL).rstrip("/")
|
||||
self._api_key = (api_key if api_key is not None else os.environ.get("MINERU_API_KEY")) or None
|
||||
self._local = _is_local_network_url(self._base_url)
|
||||
self._trust_env = should_trust_env_for_url(self._base_url)
|
||||
self._source_features: set[str] | None = None
|
||||
self.tier = validate_tier(tier) if tier is not None else None
|
||||
self.ocr_mode = ocr_mode
|
||||
self.include_images = include_images
|
||||
self.include_model_output = include_model_output
|
||||
|
||||
# ── DocumentParser interface ─────────────────────────────────────
|
||||
|
||||
def parse(self, path: str | Path, *, page_range: str = "") -> ParseResult:
|
||||
def parse(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
page_range: str = "",
|
||||
status_callback: Callable[[ApiJobStatus], None] | None = None,
|
||||
) -> ParseResult:
|
||||
"""同步解析文件;可选回调观察任务状态,服务端完成后仍需下载并构造结果。"""
|
||||
page_range = normalize_page_range_input(page_range)
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(file_path)
|
||||
|
||||
payload = self._build_payload(self._build_source(file_path), page_range)
|
||||
job = self._do_parse(payload)
|
||||
job = self._do_parse(payload, status_callback=status_callback)
|
||||
return self._build_result(job, file_path.name)
|
||||
|
||||
async def parse_async(self, path: str | Path, *, page_range: str = "") -> ParseResult:
|
||||
async def parse_async(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
page_range: str = "",
|
||||
status_callback: Callable[[ApiJobStatus], None] | None = None,
|
||||
) -> ParseResult:
|
||||
"""异步解析文件;回调属于本次调用,不在复用的 parser 实例上保存。"""
|
||||
page_range = normalize_page_range_input(page_range)
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(file_path)
|
||||
|
||||
payload = self._build_payload(await self._async_build_source(file_path), page_range)
|
||||
job = await self._async_do_parse(payload)
|
||||
job = await self._async_do_parse(payload, status_callback=status_callback)
|
||||
return await self._async_build_result(job, file_path.name)
|
||||
|
||||
def get_usage(self) -> dict[str, Any]:
|
||||
@@ -174,6 +208,8 @@ class MinerUApiParser(DocumentParser):
|
||||
}
|
||||
if self.tier is not None:
|
||||
payload["tier"] = self.tier
|
||||
if self.ocr_mode is not None:
|
||||
payload["ocr_mode"] = self.ocr_mode
|
||||
return payload
|
||||
|
||||
def _supports_local_source(self) -> bool:
|
||||
@@ -433,7 +469,10 @@ class MinerUApiParser(DocumentParser):
|
||||
|
||||
# ── parse execution ──────────────────────────────────────────────
|
||||
|
||||
def _do_parse(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _do_parse(
|
||||
self, payload: dict[str, Any], *, status_callback: Callable[[ApiJobStatus], None] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""提交同步任务,并从提交响应开始通知实际状态。"""
|
||||
with httpx.Client(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli:
|
||||
r = _request_once(
|
||||
cli,
|
||||
@@ -444,11 +483,15 @@ class MinerUApiParser(DocumentParser):
|
||||
json_body=payload,
|
||||
)
|
||||
job = self._check(r)
|
||||
_notify_job_status(job, status_callback)
|
||||
if job.get("status") not in _TERMINAL_JOB_STATUSES:
|
||||
job = self._poll(cli, job["job_id"])
|
||||
job = self._poll(cli, job["job_id"], status_callback=status_callback)
|
||||
return job
|
||||
|
||||
async def _async_do_parse(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async def _async_do_parse(
|
||||
self, payload: dict[str, Any], *, status_callback: Callable[[ApiJobStatus], None] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""提交异步任务,并从提交响应开始通知实际状态。"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=30), trust_env=self._trust_env) as cli:
|
||||
r = await _async_request_once(
|
||||
cli,
|
||||
@@ -459,11 +502,13 @@ class MinerUApiParser(DocumentParser):
|
||||
json_body=payload,
|
||||
)
|
||||
job = self._check(r)
|
||||
_notify_job_status(job, status_callback)
|
||||
if job.get("status") not in _TERMINAL_JOB_STATUSES:
|
||||
job = await self._async_poll(cli, job["job_id"])
|
||||
job = await self._async_poll(cli, job["job_id"], status_callback=status_callback)
|
||||
return job
|
||||
|
||||
def _poll(self, cli: Any, job_id: str) -> dict[str, Any]:
|
||||
def _poll(self, cli: Any, job_id: str, *, status_callback: Callable[[ApiJobStatus], None] | None = None) -> dict[str, Any]:
|
||||
"""保持每秒一次的同步轮询,并通知每次响应中的任务状态。"""
|
||||
for _ in range(_POLL_MAX_ATTEMPTS): # max 1 hour
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
r = _request_with_retry(
|
||||
@@ -474,11 +519,15 @@ class MinerUApiParser(DocumentParser):
|
||||
headers=self._headers(),
|
||||
)
|
||||
job = self._check(r)
|
||||
_notify_job_status(job, status_callback)
|
||||
if job.get("status") in _TERMINAL_JOB_STATUSES:
|
||||
return job
|
||||
raise _V1APIError("timeout", f"Job {job_id} did not complete within timeout")
|
||||
|
||||
async def _async_poll(self, cli: Any, job_id: str) -> dict[str, Any]:
|
||||
async def _async_poll(
|
||||
self, cli: Any, job_id: str, *, status_callback: Callable[[ApiJobStatus], None] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""保持每秒一次的异步轮询,状态刷新不额外增加网络请求。"""
|
||||
for _ in range(_POLL_MAX_ATTEMPTS):
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
r = await _async_request_with_retry(
|
||||
@@ -489,6 +538,7 @@ class MinerUApiParser(DocumentParser):
|
||||
headers=self._headers(),
|
||||
)
|
||||
job = self._check(r)
|
||||
_notify_job_status(job, status_callback)
|
||||
if job.get("status") in _TERMINAL_JOB_STATUSES:
|
||||
return job
|
||||
raise _V1APIError("timeout", f"Job {job_id} did not complete within timeout")
|
||||
|
||||
+80
-41
@@ -39,6 +39,7 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from starlette.datastructures import State
|
||||
|
||||
from ..config import VlmConfig, config as mineru_config
|
||||
from ..errors import MineruError
|
||||
from ..filetypes import (
|
||||
PARSEABLE_EXTENSIONS,
|
||||
@@ -464,6 +465,9 @@ class CreateJobRequest(BaseModel):
|
||||
model_config = _PYDANTIC_CONFIG
|
||||
files: list[JobFileEntry] = Field(min_length=1)
|
||||
tier: Tier | None = None
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] = Field(
|
||||
default="auto", description="OCR mode for this parse job: auto-detect, native text, or forced OCR."
|
||||
)
|
||||
output_formats: list[str] = ["markdown"]
|
||||
callback: CallbackConfig | None = None
|
||||
|
||||
@@ -1323,14 +1327,15 @@ async def _run_job(
|
||||
req: CreateJobRequest,
|
||||
file_store: FileStore,
|
||||
*,
|
||||
ocr_mode: str,
|
||||
image_analysis: bool,
|
||||
url_timeout: int = 60,
|
||||
allow_local_source: bool = False,
|
||||
max_inline_bytes: int = _MAX_INLINE_BYTES_DEFAULT,
|
||||
allow_http_source: bool = False,
|
||||
flash_enabled: bool = True,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> None:
|
||||
"""执行解析任务,使用所属服务的 VLM 配置并记录每个文件的成功或失败。"""
|
||||
rec.status = "running"
|
||||
rec.started_at = JobStore._now()
|
||||
|
||||
@@ -1380,10 +1385,11 @@ async def _run_job(
|
||||
result = await parse_async(
|
||||
str(tmp_path),
|
||||
tier=effective_tier,
|
||||
ocr_mode=ocr_mode,
|
||||
ocr_mode=req.ocr_mode,
|
||||
image_analysis=image_analysis,
|
||||
page_range=page_range,
|
||||
source_context=source_context,
|
||||
vlm_config=vlm_config,
|
||||
)
|
||||
|
||||
# collect outputs
|
||||
@@ -1761,9 +1767,7 @@ async def create_job(
|
||||
try:
|
||||
entry.page_range = normalize_page_range_input(entry.page_range) or None
|
||||
except MineruError as exc:
|
||||
_raise_api_error(
|
||||
400, error_type=exc.type, code=exc.code, message=str(exc), param=f"files.{index}.page_range"
|
||||
)
|
||||
_raise_api_error(400, error_type=exc.type, code=exc.code, message=str(exc), param=f"files.{index}.page_range")
|
||||
_require_model_preload_ready(request)
|
||||
if body.callback is not None:
|
||||
_raise_api_error(
|
||||
@@ -1843,8 +1847,8 @@ async def create_job(
|
||||
max_inline_bytes_val: int = request.app.state.max_inline_bytes
|
||||
allow_http_source_val: bool = request.app.state.allow_http_source
|
||||
flash_enabled_val: bool = request.app.state.flash_enabled
|
||||
ocr_mode_val: str = request.app.state.ocr_mode
|
||||
image_analysis_val: bool = request.app.state.image_analysis
|
||||
vlm_config_val: VlmConfig = request.app.state.vlm_config
|
||||
|
||||
# async — fire and forget
|
||||
async def _bg_run() -> None:
|
||||
@@ -1853,13 +1857,13 @@ async def create_job(
|
||||
rec,
|
||||
body,
|
||||
file_store,
|
||||
ocr_mode=ocr_mode_val,
|
||||
image_analysis=image_analysis_val,
|
||||
url_timeout=url_timeout_val,
|
||||
allow_local_source=allow_local_source_val,
|
||||
max_inline_bytes=max_inline_bytes_val,
|
||||
allow_http_source=allow_http_source_val,
|
||||
flash_enabled=flash_enabled_val,
|
||||
vlm_config=vlm_config_val,
|
||||
)
|
||||
|
||||
asyncio.create_task(_bg_run())
|
||||
@@ -1949,9 +1953,6 @@ def _build_v1_router() -> APIRouter:
|
||||
return _router
|
||||
|
||||
|
||||
_OCR_MODES = ("auto", "txt", "ocr")
|
||||
|
||||
|
||||
def _normalize_server_tier(tier: str | None) -> ServerTier:
|
||||
"""规范化 API server 的单个启动能力档位。"""
|
||||
if tier is not None and not isinstance(tier, str):
|
||||
@@ -1963,11 +1964,15 @@ def _normalize_server_tier(tier: str | None) -> ServerTier:
|
||||
raise ValueError(f"Unsupported server tier '{tier}'. Supported server tiers: {supported}")
|
||||
|
||||
|
||||
def _request_tiers_for_server_tier(tier: ServerTier, *, no_flash: bool) -> list[Tier]:
|
||||
def _request_tiers_for_server_tier(tier: ServerTier, *, no_flash: bool, no_advanced: bool = False) -> list[Tier]:
|
||||
"""将单个启动档位展开为该进程可接受的请求 tier。"""
|
||||
if tier == "flash" and no_flash:
|
||||
raise ValueError("--tier flash cannot be combined with --no-flash")
|
||||
return [request_tier for request_tier in TIERS_BY_SERVER_TIER[tier] if not no_flash or request_tier != "flash"]
|
||||
return [
|
||||
request_tier
|
||||
for request_tier in TIERS_BY_SERVER_TIER[tier]
|
||||
if (not no_flash or request_tier != "flash") and (not no_advanced or request_tier != "advanced")
|
||||
]
|
||||
|
||||
|
||||
def _runtime_options_for_server_tiers(tiers: list[Tier]) -> dict[Tier, ParserRuntimeOptions]:
|
||||
@@ -2023,11 +2028,15 @@ def _model_ids_and_tiers_for_server_tiers(tiers: list[Tier]) -> tuple[list[str],
|
||||
return model_ids, tier_infos
|
||||
|
||||
|
||||
def _preflight_tier_dependencies(tier: ServerTier) -> None:
|
||||
def _preflight_tier_dependencies(tier: ServerTier, vlm_config: VlmConfig | None = None) -> None:
|
||||
"""远程 VLM 仅预检本地 Hybrid 依赖,避免要求安装本地 VLM serving 引擎。"""
|
||||
if tier == "flash":
|
||||
return
|
||||
settings = vlm_config if vlm_config is not None else mineru_config.model.vlm
|
||||
if tier == "standard":
|
||||
settings.validate_environment()
|
||||
try:
|
||||
ensure_tier_runtime_dependencies(tier)
|
||||
ensure_tier_runtime_dependencies("basic" if settings.server_url else tier)
|
||||
except TierDependencyError as exc:
|
||||
raise ParseServerStartupError(str(exc)) from exc
|
||||
|
||||
@@ -2082,17 +2091,15 @@ def _preload_local_models(language: str) -> None:
|
||||
manager.get_atom_model(atom_model_name=AtomicModelName.OCR, lang="seal")
|
||||
|
||||
|
||||
def _preload_server_models(tier: DeploymentTier, *, language: str) -> _ModelPreloadResult:
|
||||
def _preload_server_models(tier: DeploymentTier, *, language: str, vlm_config: VlmConfig | None = None) -> _ModelPreloadResult:
|
||||
"""使用与实际解析相同的 VLM 客户端,并预加载本地 Hybrid 模型。"""
|
||||
if tier == "basic":
|
||||
_preload_local_models(language)
|
||||
return _ModelPreloadResult(tier=tier, engine="hybrid-local")
|
||||
|
||||
from ..model.vlm.selector import get_vlm_engine
|
||||
from ..model.vlm.client import get_vlm_predictor
|
||||
|
||||
engine = get_vlm_engine("auto", is_async=True)
|
||||
from ..model.vlm.runtime import ModelSingleton
|
||||
|
||||
ModelSingleton().get_model(engine, None, None) # type: ignore[arg-type]
|
||||
_predictor, engine = get_vlm_predictor(vlm_config)
|
||||
_preload_local_models(language)
|
||||
return _ModelPreloadResult(tier=tier, engine=engine)
|
||||
|
||||
@@ -2122,6 +2129,7 @@ def create_app(
|
||||
upload_dir: str = "",
|
||||
tier: ServerTier | None = None,
|
||||
no_flash: bool = False,
|
||||
no_advanced: bool = False,
|
||||
concurrency: int = 1,
|
||||
url_timeout: int = 60,
|
||||
allow_local_source: bool = False,
|
||||
@@ -2129,9 +2137,9 @@ def create_app(
|
||||
allow_http_source: bool = False,
|
||||
api_key: str | None = None,
|
||||
language: str = "ch",
|
||||
ocr_mode: str = "auto",
|
||||
image_analysis: bool = True,
|
||||
preload_models: bool = False,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create a FastAPI application implementing the MinerU v1 REST API.
|
||||
|
||||
@@ -2144,6 +2152,8 @@ def create_app(
|
||||
exposes Flash and Basic, and ``"standard"`` exposes all request tiers.
|
||||
no_flash:
|
||||
Disable Flash advertisement and execution. Invalid with ``tier="flash"``.
|
||||
no_advanced:
|
||||
Disable Advanced advertisement and execution.
|
||||
concurrency:
|
||||
Maximum concurrent parse jobs (default 1).
|
||||
url_timeout:
|
||||
@@ -2159,22 +2169,23 @@ def create_app(
|
||||
to access list endpoints and advanced output formats.
|
||||
language:
|
||||
Hybrid medium OCR language hint; accepted by other efforts for compatibility.
|
||||
ocr_mode:
|
||||
PDF OCR/text extraction mode for Hybrid backends.
|
||||
image_analysis:
|
||||
Whether image analysis is enabled for Hybrid backends.
|
||||
preload_models:
|
||||
Load the configured local models during server startup. Flash has no local models to preload.
|
||||
Initialize the VLM client and local Hybrid models at startup. Ignored for Flash.
|
||||
vlm_config:
|
||||
Complete VLM connection configuration override; defaults to global model.vlm settings.
|
||||
"""
|
||||
upload_dir = upload_dir or ""
|
||||
tier = _normalize_server_tier(tier)
|
||||
server_tiers = _request_tiers_for_server_tier(tier, no_flash=no_flash)
|
||||
server_tiers = _request_tiers_for_server_tier(tier, no_flash=no_flash, no_advanced=no_advanced)
|
||||
tier_runtime_options = _runtime_options_for_server_tiers(server_tiers)
|
||||
default_tier = select_default_quality_tier(tier_runtime_options)
|
||||
startup_runtime = runtime_options_for_tier(tier)
|
||||
backend = startup_runtime.backend
|
||||
effort = effort_for_tier(tier)
|
||||
_preflight_tier_dependencies(tier)
|
||||
vlm_config = (vlm_config if vlm_config is not None else mineru_config.model.vlm).model_copy(deep=True)
|
||||
_preflight_tier_dependencies(tier, vlm_config)
|
||||
_api_key: str | None = api_key or None
|
||||
_upload_dir = pathlib.Path(upload_dir) if upload_dir else pathlib.Path(tempfile.mkdtemp(prefix="mineru_"))
|
||||
_upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2204,18 +2215,19 @@ def create_app(
|
||||
application.state.allow_http_source = allow_http_source
|
||||
application.state.api_key = _api_key
|
||||
application.state.language = language
|
||||
application.state.ocr_mode = ocr_mode
|
||||
application.state.effort = effort
|
||||
application.state.image_analysis = image_analysis
|
||||
application.state.preload_models = preload_models
|
||||
application.state.vlm_config = vlm_config
|
||||
application.state.model_preload_error = None
|
||||
if _preload_tier is not None:
|
||||
logger.info("Preloading local models for startup tier %s", _preload_tier)
|
||||
logger.info("Initializing VLM client and local models for startup tier %s", _preload_tier)
|
||||
try:
|
||||
preload_result = await asyncio.to_thread(
|
||||
_preload_server_models,
|
||||
_preload_tier,
|
||||
language=language,
|
||||
vlm_config=vlm_config,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_code, error_msg = _classify_model_preload_error(exc)
|
||||
@@ -2223,10 +2235,10 @@ def create_app(
|
||||
code=error_code,
|
||||
message=error_msg,
|
||||
)
|
||||
logger.exception("Local model preload failed for startup tier %s", _preload_tier)
|
||||
logger.exception("Model initialization failed for startup tier %s", _preload_tier)
|
||||
else:
|
||||
logger.info(
|
||||
"Local model preload completed for startup tier %s (engine=%s)",
|
||||
"Model initialization completed for startup tier %s (engine=%s)",
|
||||
_preload_tier,
|
||||
preload_result.engine,
|
||||
)
|
||||
@@ -2259,10 +2271,10 @@ def create_app(
|
||||
application.state.allow_http_source = allow_http_source
|
||||
application.state.api_key = _api_key
|
||||
application.state.language = language
|
||||
application.state.ocr_mode = ocr_mode
|
||||
application.state.effort = effort
|
||||
application.state.image_analysis = image_analysis
|
||||
application.state.preload_models = preload_models
|
||||
application.state.vlm_config = vlm_config
|
||||
application.state.model_preload_error = None
|
||||
FileStore(_upload_dir).install(application.state)
|
||||
JobStore(concurrency=concurrency).install(application.state)
|
||||
@@ -2353,6 +2365,11 @@ def create_app(
|
||||
is_flag=True,
|
||||
help="Disable Flash tier advertisement and execution.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-advanced",
|
||||
is_flag=True,
|
||||
help="Disable Advanced tier advertisement and execution.",
|
||||
)
|
||||
@click.option(
|
||||
"--concurrency",
|
||||
default=1,
|
||||
@@ -2388,12 +2405,6 @@ def create_app(
|
||||
metavar="[" + "|".join(_API_SERVER_LANGUAGES) + "]",
|
||||
help="Hybrid medium OCR language hint; accepted by other efforts for compatibility.",
|
||||
)
|
||||
@click.option(
|
||||
"--ocr-mode",
|
||||
default="auto",
|
||||
type=click.Choice(_OCR_MODES),
|
||||
help="PDF OCR/text extraction mode. Applies to hybrid-* backends.",
|
||||
)
|
||||
@click.option(
|
||||
"--disable-image-analysis",
|
||||
is_flag=True,
|
||||
@@ -2402,7 +2413,16 @@ def create_app(
|
||||
@click.option(
|
||||
"--preload-models",
|
||||
is_flag=True,
|
||||
help="Load local models during server startup. Ignored for Flash.",
|
||||
help="Initialize VLM client and local Hybrid models at startup. Ignored for Flash.",
|
||||
)
|
||||
@click.option("--vlm-server-url", default=None, type=str, help="Remote VLM URL; empty value selects local VLM.")
|
||||
@click.option("--vlm-api-key", default=None, type=str, help="Bearer key for the remote VLM server.")
|
||||
@click.option("--vlm-model", default=None, type=str, help="Remote VLM model name; empty value enables discovery.")
|
||||
@click.option(
|
||||
"--vlm-http-timeout", default=None, type=click.IntRange(min=1), help="VLM HTTP timeout in seconds (default: 600)."
|
||||
)
|
||||
@click.option(
|
||||
"--vlm-max-concurrency", default=None, type=click.IntRange(min=1), help="VLM inference concurrency (default: 100)."
|
||||
)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
@@ -2416,18 +2436,23 @@ def main(
|
||||
upload_dir: str,
|
||||
tier: ServerTier | None,
|
||||
no_flash: bool,
|
||||
no_advanced: bool,
|
||||
concurrency: int,
|
||||
url_timeout: int,
|
||||
allow_local_source: bool,
|
||||
max_inline_bytes: int,
|
||||
allow_http_source: bool,
|
||||
language: str,
|
||||
ocr_mode: str,
|
||||
disable_image_analysis: bool,
|
||||
preload_models: bool,
|
||||
api_key: str | None,
|
||||
vlm_server_url: str | None,
|
||||
vlm_api_key: str | None,
|
||||
vlm_model: str | None,
|
||||
vlm_http_timeout: int | None,
|
||||
vlm_max_concurrency: int | None,
|
||||
) -> None:
|
||||
"""Start the MinerU v1 REST API server."""
|
||||
"""合并显式 VLM 参数后启动 MinerU v1 REST API 服务,不修改全局配置。"""
|
||||
configure_standard_streams()
|
||||
shutdown_requested = threading.Event()
|
||||
server_ref: list[uvicorn.Server | None] = [None]
|
||||
@@ -2448,10 +2473,24 @@ def main(
|
||||
if control_watcher is not None:
|
||||
control_watcher.start()
|
||||
try:
|
||||
vlm_overrides = {
|
||||
"server_url": vlm_server_url,
|
||||
"api_key": vlm_api_key,
|
||||
"model": vlm_model,
|
||||
"http_timeout": vlm_http_timeout,
|
||||
"max_concurrency": vlm_max_concurrency,
|
||||
}
|
||||
vlm_settings = VlmConfig.model_validate(
|
||||
{
|
||||
**mineru_config.model.vlm.model_dump(),
|
||||
**{key: value for key, value in vlm_overrides.items() if value is not None},
|
||||
}
|
||||
)
|
||||
application = create_app(
|
||||
upload_dir=upload_dir,
|
||||
tier=tier,
|
||||
no_flash=no_flash,
|
||||
no_advanced=no_advanced,
|
||||
concurrency=concurrency,
|
||||
url_timeout=url_timeout,
|
||||
allow_local_source=allow_local_source,
|
||||
@@ -2459,9 +2498,9 @@ def main(
|
||||
allow_http_source=allow_http_source,
|
||||
api_key=api_key,
|
||||
language=language,
|
||||
ocr_mode=ocr_mode,
|
||||
image_analysis=not disable_image_analysis,
|
||||
preload_models=preload_models,
|
||||
vlm_config=vlm_settings,
|
||||
)
|
||||
except ParseServerStartupError as exc:
|
||||
if control_watcher is not None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from ..backend.analyze import aio_doc_analyze, doc_analyze
|
||||
from ..config import VlmConfig, config
|
||||
from ..errors import InvalidRequestError
|
||||
from ..filetypes import IMAGE_EXTENSIONS, PAGE_RANGE_PARSE_EXTENSIONS
|
||||
from ..model.flash.html import HtmlSourceContext
|
||||
@@ -49,11 +50,14 @@ class MinerUParser(DocumentParser):
|
||||
tier: Tier = "standard",
|
||||
parse_mode: _ParseMode = "auto",
|
||||
image_analysis: bool = True,
|
||||
vlm_config: VlmConfig | None = None,
|
||||
) -> None:
|
||||
"""保存当前解析器的 VLM 配置副本,避免其他应用或调用修改连接设置。"""
|
||||
self.tier: Tier = tier
|
||||
self.effort: _Effort = effort_for_tier(tier) # type: ignore[assignment]
|
||||
self.parse_mode: _ParseMode = parse_mode
|
||||
self.image_analysis: bool = image_analysis
|
||||
self.vlm_config = (vlm_config if vlm_config is not None else config.model.vlm).model_copy(deep=True)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
@@ -100,6 +104,7 @@ class MinerUParser(DocumentParser):
|
||||
return self._build_result(middle_json, model_output)
|
||||
|
||||
def _run_analysis(self, prepared: _PreparedInput) -> tuple[MiddleJson, ModelJson]:
|
||||
"""将准备后的输入和实例连接配置传入同步分析入口。"""
|
||||
return doc_analyze(
|
||||
prepared.file_bytes,
|
||||
effort=self.effort,
|
||||
@@ -108,9 +113,11 @@ class MinerUParser(DocumentParser):
|
||||
page_index_map=prepared.retained_page_indices,
|
||||
file_suffix=prepared.file_suffix,
|
||||
source_context=prepared.source_context,
|
||||
vlm_config=self.vlm_config,
|
||||
)
|
||||
|
||||
async def _arun_analysis(self, prepared: _PreparedInput) -> tuple[MiddleJson, ModelJson]:
|
||||
"""将准备后的输入和实例连接配置传入异步分析入口。"""
|
||||
return await aio_doc_analyze(
|
||||
prepared.file_bytes,
|
||||
effort=self.effort,
|
||||
@@ -119,6 +126,7 @@ class MinerUParser(DocumentParser):
|
||||
page_index_map=prepared.retained_page_indices,
|
||||
file_suffix=prepared.file_suffix,
|
||||
source_context=prepared.source_context,
|
||||
vlm_config=self.vlm_config,
|
||||
)
|
||||
|
||||
def _prepare_input(
|
||||
|
||||
Vendored
+44
@@ -52,6 +52,50 @@ body.dark .gradio-container {
|
||||
.mineru-tier-slider .max_value {
|
||||
display: none;
|
||||
}
|
||||
/* 两个原生 range 共用同一条轨道;仅滑块接收指针,保留原生键盘交互。 */
|
||||
.mineru-kit-page-range {
|
||||
display: none !important; position: relative; padding: 12px 12px 32px !important; overflow: visible !important;
|
||||
border: 1px solid var(--mineru-panel-border); border-radius: 8px;
|
||||
}
|
||||
/* 不依赖 Gradio 布局容器的 JS visible 更新,明确状态同时适用于 5/6。 */
|
||||
.mineru-kit-page-range:has([data-range-visible="true"]) { display: flex !important; }
|
||||
.mineru-page-summary, .mineru-page-summary .html-container { padding: 0 !important; }
|
||||
.mineru-page-values, .mineru-page-axis { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
|
||||
.mineru-page-values { font-size: 12px; }
|
||||
.mineru-page-selection { font-weight: 600; color: var(--mineru-accent); }
|
||||
.mineru-page-axis { position: absolute; bottom: 0; left: 0; right: 0; font-size: 12px; opacity: .7; }
|
||||
.mineru-page-summary { position: static !important; }
|
||||
.mineru-kit-page-range .mineru-page-axis { bottom: 8px; left: 12px; right: 12px; }
|
||||
.mineru-page-sliders { position: relative !important; height: 32px; min-height: 32px !important; gap: 0 !important; }
|
||||
.mineru-page-sliders::before {
|
||||
content: ""; position: absolute; left: 8px; right: 8px; top: 14px; height: 4px; border-radius: 4px;
|
||||
background: linear-gradient(to right,
|
||||
var(--border-color-primary, #ddd) var(--range-start, 0%),
|
||||
var(--mineru-accent) var(--range-start, 0%), var(--mineru-accent) var(--range-end, 100%),
|
||||
var(--border-color-primary, #ddd) var(--range-end, 100%));
|
||||
}
|
||||
.mineru-page-sliders > .form { position: static !important; }
|
||||
.mineru-page-handle-a, .mineru-page-handle-b {
|
||||
position: absolute !important; inset: 0; width: 100% !important; min-width: 0 !important;
|
||||
padding: 0 !important; border: 0 !important; background: transparent !important; pointer-events: none;
|
||||
}
|
||||
.mineru-page-handle-a .head, .mineru-page-handle-b .head,
|
||||
.mineru-page-sliders .min_value, .mineru-page-sliders .max_value { display: none !important; }
|
||||
.mineru-page-sliders input[type="range"] {
|
||||
position: absolute; inset: 0; width: 100%; height: 32px; padding: 0; margin: 0;
|
||||
pointer-events: none; background: transparent !important;
|
||||
}
|
||||
.mineru-page-sliders input[type="range"]::-webkit-slider-runnable-track { background: transparent !important; }
|
||||
.mineru-page-sliders input[type="range"]::-moz-range-track,
|
||||
.mineru-page-sliders input[type="range"]::-moz-range-progress { background: transparent !important; }
|
||||
.mineru-page-sliders input[type="range"]::-webkit-slider-thumb { pointer-events: auto; }
|
||||
.mineru-page-sliders input[type="range"]::-moz-range-thumb { pointer-events: auto; }
|
||||
.mineru-page-sliders input[type="range"]:disabled::-webkit-slider-thumb { pointer-events: none; }
|
||||
.mineru-page-sliders input[type="range"]:disabled::-moz-range-thumb { pointer-events: none; }
|
||||
.mineru-page-handle-a { z-index: 2; }
|
||||
.mineru-page-handle-b { z-index: 3; }
|
||||
.mineru-page-handle-a:focus-within, .mineru-page-handle-b:focus-within { z-index: 4; }
|
||||
.mineru-page-notice { font-size: 13px; color: var(--body-text-color-subdued); }
|
||||
.mineru-upload-file label[data-testid="block-label"] {
|
||||
align-items: flex-start !important;
|
||||
line-height: 1.35 !important;
|
||||
|
||||
Vendored
+50
-1
@@ -1,5 +1,5 @@
|
||||
() => {
|
||||
const APP_SCRIPT_VERSION = "v1-only-options";
|
||||
const APP_SCRIPT_VERSION = "v1-pdf-page-range-crossing";
|
||||
if (window.__mineruGradioAppInstalled === APP_SCRIPT_VERSION) {
|
||||
return;
|
||||
}
|
||||
@@ -83,10 +83,26 @@
|
||||
});
|
||||
};
|
||||
|
||||
// 原生 range 的值更新后重画共用轨道,不修改组件值或触发额外事件。
|
||||
const refreshPageRangeTrack = () => {
|
||||
const track = document.querySelector(".mineru-page-sliders");
|
||||
const handleA = track?.querySelector('.mineru-page-handle-a input[type="range"]');
|
||||
const handleB = track?.querySelector('.mineru-page-handle-b input[type="range"]');
|
||||
if (!handleA || !handleB) {
|
||||
return;
|
||||
}
|
||||
const span = Math.max(1, Number(handleA.max) - 1);
|
||||
const start = Math.min(Number(handleA.value), Number(handleB.value));
|
||||
const end = Math.max(Number(handleA.value), Number(handleB.value));
|
||||
track.style.setProperty("--range-start", `${(start - 1) / span * 100}%`);
|
||||
track.style.setProperty("--range-end", `${(end - 1) / span * 100}%`);
|
||||
};
|
||||
|
||||
// 自定义 HTML 由 Gradio 动态重绘,统一在 DOM 变更后补本地化和忽略状态。
|
||||
const refreshMineruCustomHtml = () => {
|
||||
localizeMineruCustomText();
|
||||
applyOfficePreviewNoticePreference();
|
||||
refreshPageRangeTrack();
|
||||
};
|
||||
|
||||
const findUploadFileInput = () => {
|
||||
@@ -254,4 +270,37 @@
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// 拖动时下一帧读取 Gradio 已同步的值;键盘输入也走同一条路径。
|
||||
document.addEventListener("input", (event) => {
|
||||
if (event.target instanceof Element && event.target.closest(".mineru-page-sliders")) {
|
||||
requestAnimationFrame(refreshPageRangeTrack);
|
||||
}
|
||||
});
|
||||
|
||||
// 优先命中离指针最近的物理滑块;重合时按当前起止角色决定左右半边的命中目标。
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
if (event.buttons || !(event.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const track = event.target.closest(".mineru-page-sliders");
|
||||
const rootA = track?.querySelector(".mineru-page-handle-a");
|
||||
const rootB = track?.querySelector(".mineru-page-handle-b");
|
||||
const handleA = rootA?.querySelector('input[type="range"]');
|
||||
const handleB = rootB?.querySelector('input[type="range"]');
|
||||
if (!handleA || !handleB) {
|
||||
return;
|
||||
}
|
||||
const bounds = handleA.getBoundingClientRect();
|
||||
const span = Math.max(1, Number(handleA.max) - 1);
|
||||
const centerA = bounds.left + 8 + (Number(handleA.value) - 1) / span * (bounds.width - 16);
|
||||
const centerB = bounds.left + 8 + (Number(handleB.value) - 1) / span * (bounds.width - 16);
|
||||
const startHandle = track.closest(".mineru-kit-page-range")
|
||||
?.querySelector("[data-start-handle]")?.getAttribute("data-start-handle") || "a";
|
||||
const preferA = handleA.value === handleB.value
|
||||
? (event.clientX < centerA ? startHandle === "a" : startHandle !== "a")
|
||||
: Math.abs(event.clientX - centerA) < Math.abs(event.clientX - centerB);
|
||||
rootA.style.zIndex = preferA ? "5" : "2";
|
||||
rootB.style.zIndex = preferA ? "2" : "5";
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
// 按结果标识协调下载请求、按钮状态和浏览器下载,丢弃旧文档的回执。
|
||||
(action, formats, format, label, ...args) => {
|
||||
const state = window.__mineruGradioDownloads ??= { runId: "", sequence: 0, pending: new Map() };
|
||||
// 用标准组件更新对象兼容 Gradio 5/6;空更新不会覆盖新文档的按钮状态。
|
||||
const skip = () => ({ __type__: "update" });
|
||||
const button = (value, interactive) => ({ __type__: "update", value, interactive });
|
||||
// 下载失败文案仅作为文本展示,不能把服务端错误插入为活动 HTML。
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[char]);
|
||||
|
||||
if (action === "reset") {
|
||||
state.runId = "";
|
||||
state.pending.clear();
|
||||
return [
|
||||
"", ...formats.map(() => null), ...formats.map(() => ""), ...formats.map(() => ""),
|
||||
...formats.map(([, name]) => button(name, false)), "",
|
||||
];
|
||||
}
|
||||
if (action === "activate") {
|
||||
if (state.runId !== args[0]) state.pending.clear();
|
||||
state.runId = args[0] || "";
|
||||
return [];
|
||||
}
|
||||
if (action === "begin") {
|
||||
const [runId] = args;
|
||||
if (!runId || state.runId !== runId || state.pending.has(format)) return [skip(), skip(), skip()];
|
||||
const token = JSON.stringify({ run_id: runId, sequence: ++state.sequence });
|
||||
state.pending.set(format, token);
|
||||
return [token, button(`${label} · 准备中…`, false), ""];
|
||||
}
|
||||
if (action === "busy") {
|
||||
const [token, runId] = args;
|
||||
// 即使开始回执晚于换文件或清除,也只能修改当前仍在等待的同一个请求。
|
||||
if (!token || state.pending.get(format) !== token || !runId || state.runId !== runId) return [skip(), skip()];
|
||||
return [button(`${label} · 准备中…`, false), ""];
|
||||
}
|
||||
if (action === "complete") {
|
||||
const [file, receiptText, runId] = args;
|
||||
const receipt = JSON.parse(receiptText || "{}");
|
||||
const token = state.pending.get(format);
|
||||
// 已清除、已换文档或已消费的回执不得再次下载,也不得恢复旧按钮。
|
||||
if (!token || token !== receipt.request || !runId || state.runId !== runId) return [skip(), skip()];
|
||||
const request = JSON.parse(token);
|
||||
if (request.run_id !== runId) return [skip(), skip()];
|
||||
state.pending.delete(format);
|
||||
if (receipt.error || !file?.url) {
|
||||
const message = receipt.error || "未获得下载文件,请重试。";
|
||||
return [button(label, true), `<div role="alert">${escapeHtml(label)} 下载失败:${escapeHtml(message)}</div>`];
|
||||
}
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = file.url;
|
||||
anchor.download = file.orig_name || "download";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
return [button(label, true), ""];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
<html><head>
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.15.4/css/all.css">
|
||||
<style>
|
||||
.link-block {
|
||||
/* 统一链接和菜单按钮的外框布局,避免行内基线额外撑高菜单按钮。 */
|
||||
.mineru-demo-header .link-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 24px;
|
||||
background-color: rgba(54, 54, 54, 1);
|
||||
@@ -11,12 +15,12 @@
|
||||
background-color: rgba(54, 54, 54, 0.75) !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.external-link {
|
||||
.mineru-demo-header .external-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
padding: 0 16px;
|
||||
padding: 0 16px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.external-link,
|
||||
@@ -41,7 +45,8 @@
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
padding: 0 16px !important;
|
||||
margin: 0;
|
||||
/* 覆盖 Gradio 为 HTML 按钮添加的底部外边距,保持外框高度一致。 */
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
border: 0;
|
||||
border-color: transparent !important;
|
||||
@@ -69,7 +74,8 @@
|
||||
left: 50%;
|
||||
top: calc(100% + 10px);
|
||||
z-index: 20;
|
||||
width: 280px;
|
||||
/* 按最长链接文字确定宽度,避免固定宽度产生过多左右留白。 */
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
@@ -83,9 +89,6 @@
|
||||
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
|
||||
visibility: hidden;
|
||||
}
|
||||
.mineru-model-popover {
|
||||
width: 160px;
|
||||
}
|
||||
.mineru-header-menu:hover .mineru-header-popover,
|
||||
.mineru-header-menu:focus-within .mineru-header-popover {
|
||||
opacity: 1;
|
||||
@@ -190,9 +193,6 @@
|
||||
</a>
|
||||
</p>
|
||||
<style>
|
||||
.link-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.link-block + .link-block {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
(tiers, flashOnlyExtensions, maxPages, file, position, metadata, previous, handleAValue, handleBValue, tierSelection) => {
|
||||
// 纯前端状态转换:经原生组件事件读写值,不逐次向 Python 发送拖动请求。
|
||||
// 内部元数据使用 JSON 文本,避免不同 Gradio 版本的 JSON 组件封装差异。
|
||||
metadata = JSON.parse(metadata || "{}");
|
||||
previous = JSON.parse(previous || "{}");
|
||||
tierSelection = JSON.parse(tierSelection);
|
||||
// 统一构造原生组件的局部更新,未指定属性保持不变。
|
||||
const update = (props = {}) => ({ __type__: "update", ...props });
|
||||
// 页数读取错误只作为普通文本显示,禁止把异常内容解释为 HTML。
|
||||
const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[char]);
|
||||
const path = (typeof file === "string" ? file : file?.path) || "";
|
||||
const flashOnly = flashOnlyExtensions.some((extension) => path.toLowerCase().endsWith(`.${extension}`));
|
||||
const flashPosition = tiers.indexOf("flash");
|
||||
const flashUnavailable = flashOnly && flashPosition < 0;
|
||||
// 只记住未锁定时的用户选择;文件切换和晚到元数据不能把临时 Flash 写回偏好。
|
||||
if (!tierSelection.locked) tierSelection.tier = tiers[position];
|
||||
const effectivePosition = flashOnly && !flashUnavailable ? flashPosition : tiers.indexOf(tierSelection.tier);
|
||||
const selectedTier = flashOnly ? "flash" : tiers[effectivePosition];
|
||||
tierSelection.locked = flashOnly;
|
||||
const isPdf = path.toLowerCase().endsWith(".pdf");
|
||||
const needsRange = isPdf && selectedTier !== "flash";
|
||||
const fileChanged = previous?.path !== path;
|
||||
const state = !fileChanged ? { ...previous } : {
|
||||
path, page_count: 0, handle_a: 1, handle_b: 1, start_handle: "a", error: "",
|
||||
};
|
||||
|
||||
// 只缓存当前文件的元数据;晚到的旧文件响应不能覆盖已经读好的新文件页数。
|
||||
let initialized = false;
|
||||
if (isPdf && metadata?.path === path && !state.page_count && !state.error) {
|
||||
state.page_count = metadata.page_count || 0;
|
||||
state.error = metadata.error || "";
|
||||
state.handle_a = 1;
|
||||
state.handle_b = Math.min(state.page_count || 1, maxPages ?? Infinity);
|
||||
state.start_handle = "a";
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
const count = state.page_count || 0;
|
||||
if (count > 0 && !fileChanged && !initialized) {
|
||||
// 物理滑块只受文档边界约束;交叉时不交换组件值,保持鼠标和焦点所在的实体。
|
||||
const clamp = (value) => Math.min(count, Math.max(1, Math.round(value)));
|
||||
// 超限时把另一滑块推到拖动端附近,方向由实际位置而不是起止角色决定。
|
||||
const linkedPosition = (active, other) => maxPages !== null && Math.abs(active - other) + 1 > maxPages
|
||||
? active + Math.sign(other - active) * (maxPages - 1)
|
||||
: other;
|
||||
if (handleAValue !== state.handle_a) {
|
||||
state.handle_a = clamp(handleAValue);
|
||||
state.handle_b = linkedPosition(state.handle_a, state.handle_b);
|
||||
} else if (handleBValue !== state.handle_b) {
|
||||
state.handle_b = clamp(handleBValue);
|
||||
state.handle_a = linkedPosition(state.handle_b, state.handle_a);
|
||||
}
|
||||
}
|
||||
|
||||
// 只有严格越过才交换角色;重合时沿用上一次角色,避免边界附近的标签抖动。
|
||||
if (state.handle_a < state.handle_b) {
|
||||
state.start_handle = "a";
|
||||
} else if (state.handle_b < state.handle_a) {
|
||||
state.start_handle = "b";
|
||||
}
|
||||
const visible = needsRange && count > 0;
|
||||
const interactive = visible && count > 1;
|
||||
const start = Math.min(state.handle_a, state.handle_b);
|
||||
const end = Math.max(state.handle_a, state.handle_b);
|
||||
const selected = end - start + 1;
|
||||
const limitText = maxPages === null ? "不限页数" : `最多 ${maxPages} 页`;
|
||||
const summary = `<div class="mineru-page-values" data-range-visible="${visible}" data-start-handle="${state.start_handle}">`
|
||||
+ `<span>起始页 <strong>${start}</strong></span>`
|
||||
+ `<span class="mineru-page-selection">[${start}-${end}] · ${selected} 页</span>`
|
||||
+ `<span>结束页 <strong>${end}</strong></span></div>`
|
||||
+ `<div class="mineru-page-axis"><span>1</span><span>${limitText}</span><span>${count}</span></div>`;
|
||||
const notice = flashUnavailable ? "该格式仅支持 Flash,当前服务不可用"
|
||||
: needsRange && !count ? (state.error || "正在读取 PDF 页数…") : "";
|
||||
const range = visible ? (start === end ? String(start) : `${start}-${end}`) : "";
|
||||
// 两个端点始终保留完整文档跨度,不能把轨道范围截短为页数上限。
|
||||
const slider = (value, label) => update({ minimum: 1, maximum: Math.max(1, count), value, interactive, label });
|
||||
return [
|
||||
slider(state.handle_a, state.start_handle === "a" ? "起始页" : "结束页"),
|
||||
slider(state.handle_b, state.start_handle === "b" ? "起始页" : "结束页"),
|
||||
summary, range, JSON.stringify(state),
|
||||
update({ interactive: Boolean(path) && !flashUnavailable && (!needsRange || count > 0) }),
|
||||
update({ value: escapeHtml(notice), visible: Boolean(notice) }),
|
||||
// 同一事件同时更新值、标签和页码,程序赋值无需再触发 tier.input。
|
||||
update({ value: effectivePosition, interactive: !flashOnly && tiers.length > 1 }),
|
||||
`解析 tier:${selectedTier}${flashUnavailable ? "(当前服务不可用)" : ""}`,
|
||||
JSON.stringify(tierSelection),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Flash PDF 等价性基准
|
||||
|
||||
在仓库根目录执行,默认覆盖版本化的 19 份布局语料和 12 份原生表格文档:
|
||||
|
||||
```bash
|
||||
.venv1/bin/python tests/benchmarks/flash_pdf.py --output output/flash_pdf_refactor/baseline
|
||||
.venv1/bin/python tests/benchmarks/flash_pdf.py --output output/flash_pdf_refactor/candidate --baseline output/flash_pdf_refactor/baseline
|
||||
```
|
||||
|
||||
每份文档在独立子进程中预热一次、计时五次,报告中位耗时与进程峰值 RSS。耗时包含 PDFDocument 打开、原生分析和关闭,不包含后处理、输出序列化、OCR 或渲染。RSS 在输出后处理和额外剖析之前采集;它包含解释器和已导入模块,不是文档的增量内存。
|
||||
|
||||
每份文档保存完整 `model_list` 和通过统一后处理入口生成的 `middle_json`。LLM 增强显式使用默认关闭配置,版本字符串固定,比较不移除任何输出字段。`report.json` 包含源码版本、依赖版本、文件指纹与历史清单差异;历史清单不会被改写。`comparison.json` 分别报告完整输出是否相等、耗时比例和 RSS 比例,输出不等价时命令失败。
|
||||
|
||||
使用 `--runs 0` 跳过预热和重复计时,只进行功能比较。`--path` 可重复指定固定子集;基线和候选必须使用同一份语料集合。`--profile` 额外生成逐函数调用次数、自身耗时与累计耗时,可定位提取、几何校准、页面准备、页面组装和样式物化阶段;剖析耗时不参与性能比较。性能测量期间避免同时执行测试、构建或其它 CPU 密集任务。
|
||||
|
||||
已有报告目录不能覆盖。先在原始版本建立基线,然后在候选版本使用新的输出目录;不可通过更新基线掩盖输出变化。超过 5% 的持续耗时或 RSS 回退应独立复测并定位。
|
||||
@@ -0,0 +1,220 @@
|
||||
# Copyright (c) Opendatalab. All rights reserved.
|
||||
# ruff: noqa: E402
|
||||
"""生成 Flash PDF 完整输出基线,并在独立进程中测量耗时和峰值内存。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import cProfile
|
||||
import gc
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import platform
|
||||
import pstats
|
||||
import resource
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "tests" / "unittest"))
|
||||
|
||||
from _flash_pdf_test_utils import _page_bbox_fingerprint, _page_fingerprint
|
||||
|
||||
from mineru.backend.postprocess.document import model_json_to_middle_json
|
||||
from mineru.config import LLMAidedConfig
|
||||
from mineru.model.flash.pdf.document import PDFDocument
|
||||
from mineru.model.flash.pdf.pipeline import _analyze_native_document
|
||||
from mineru.types import ModelJson
|
||||
|
||||
|
||||
def _read_pdf(path: Path) -> bytes:
|
||||
"""读取原始语料,在内存中解码版本化 XOR 测试文件。"""
|
||||
payload = path.read_bytes()
|
||||
if path.suffix == ".xor":
|
||||
key = b"MinerU flash layout fixture"
|
||||
return bytes(value ^ key[index % len(key)] for index, value in enumerate(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
"""对完整 JSON 值计算稳定摘要,不忽略几何、空格或任何字段。"""
|
||||
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
"""写入可审查 JSON 产物,父目录由本次运行独立创建。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _predict(payload: bytes) -> list[list[dict[str, Any]]]:
|
||||
"""在单次文档生命周期内执行原生分析,确保计时包含页面打开和关闭。"""
|
||||
with PDFDocument(payload) as document:
|
||||
return _analyze_native_document(document)
|
||||
|
||||
|
||||
def _worker(path: Path, destination: Path, runs: int, profile: bool) -> None:
|
||||
"""隔离一份文档的计时、完整输出和进程峰值内存,避免其它文档污染 RSS。"""
|
||||
from loguru import logger
|
||||
|
||||
logger.disable("mineru")
|
||||
payload = _read_pdf(path)
|
||||
if runs:
|
||||
_predict(payload)
|
||||
durations = []
|
||||
expected_digest = None
|
||||
for _ in range(max(1, runs)):
|
||||
gc.collect()
|
||||
started = time.perf_counter()
|
||||
pages = _predict(payload)
|
||||
durations.append(time.perf_counter() - started)
|
||||
digest = _digest(pages)
|
||||
if expected_digest is not None and digest != expected_digest:
|
||||
raise AssertionError(f"Non-deterministic model-list: {path}")
|
||||
expected_digest = digest
|
||||
del pages
|
||||
peak_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
pages = _predict(payload)
|
||||
middle = model_json_to_middle_json(
|
||||
ModelJson(
|
||||
pages=deepcopy(pages),
|
||||
page_index_map=[],
|
||||
file_suffix="pdf",
|
||||
effort="flash",
|
||||
parse_mode="txt",
|
||||
mineru_version="refactor-baseline",
|
||||
),
|
||||
llm_aided_config=LLMAidedConfig(),
|
||||
).model_dump(mode="json")
|
||||
output = {"model_list": pages, "middle_json": middle}
|
||||
_write_json(destination / "output.json", output)
|
||||
result = {
|
||||
"path": str(path.relative_to(ROOT)),
|
||||
"source_sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"pages": len(pages),
|
||||
"full_output_sha256": _digest(output),
|
||||
"page_fingerprints": [_page_fingerprint(page) for page in pages],
|
||||
"bbox_fingerprints": [_page_bbox_fingerprint(page) for page in pages],
|
||||
"seconds": durations,
|
||||
"median_seconds": statistics.median(durations),
|
||||
"peak_rss_bytes": peak_rss * (1024 if sys.platform != "darwin" else 1),
|
||||
}
|
||||
del pages, middle, output
|
||||
if profile:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.runcall(_predict, payload)
|
||||
stats = pstats.Stats(profiler)
|
||||
result["profile"] = [
|
||||
{
|
||||
"file": str(Path(filename).relative_to(ROOT)),
|
||||
"line": line,
|
||||
"function": name,
|
||||
"calls": values[1],
|
||||
"self_seconds": values[2],
|
||||
"cumulative_seconds": values[3],
|
||||
}
|
||||
for (filename, line, name), values in stats.stats.items()
|
||||
if "/mineru/model/flash/pdf/" in filename
|
||||
]
|
||||
_write_json(destination / "result.json", result)
|
||||
|
||||
|
||||
def _compare(output: Path, baseline: Path, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""核对完整语料集合、源文件指纹及输出,并单独报告性能变化。"""
|
||||
previous = json.loads((baseline / "report.json").read_text())
|
||||
by_path = {record["path"]: record for record in previous["documents"]}
|
||||
if set(by_path) != {record["path"] for record in results}:
|
||||
raise AssertionError("Baseline and candidate corpus differ")
|
||||
comparisons = []
|
||||
for record in results:
|
||||
old = by_path[record["path"]]
|
||||
if old["source_sha256"] != record["source_sha256"]:
|
||||
raise AssertionError(f"Input changed: {record['path']}")
|
||||
comparisons.append(
|
||||
{
|
||||
"path": record["path"],
|
||||
"equal": old["full_output_sha256"] == record["full_output_sha256"],
|
||||
"time_ratio": record["median_seconds"] / old["median_seconds"],
|
||||
"rss_ratio": record["peak_rss_bytes"] / old["peak_rss_bytes"],
|
||||
}
|
||||
)
|
||||
_write_json(output / "comparison.json", comparisons)
|
||||
return comparisons
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""执行完整回归或指定样本基准;每份文档单独启动子进程。"""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--baseline", type=Path)
|
||||
parser.add_argument("--runs", type=int, default=5, help="预热一次后计时次数;0 只运行功能校验")
|
||||
parser.add_argument("--path", action="append", default=[])
|
||||
parser.add_argument("--profile", action="store_true", help="额外运行剖析;不计入耗时或 RSS 指标")
|
||||
parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
if args.runs < 0:
|
||||
parser.error("--runs must be non-negative")
|
||||
if args.worker:
|
||||
_worker(args.worker, args.output, args.runs, args.profile)
|
||||
return
|
||||
if (args.output / "report.json").exists():
|
||||
parser.error("output already contains a report; choose a new directory")
|
||||
manifest = json.loads((ROOT / "tests/fixtures/flash_layout_geometry_manifest.json").read_text())
|
||||
paths = args.path or [
|
||||
*(item["path"] for item in manifest["documents"]),
|
||||
*(str(path.relative_to(ROOT)) for path in sorted((ROOT / "tests/unittest/pdfs/native_pdf_tables").glob("*.pdf"))),
|
||||
]
|
||||
results = []
|
||||
for index, relative in enumerate(dict.fromkeys(paths)):
|
||||
destination = args.output.resolve() / f"{index:02d}-{Path(relative).stem}"
|
||||
command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).resolve()),
|
||||
"--worker",
|
||||
str(ROOT / relative),
|
||||
"--output",
|
||||
str(destination),
|
||||
"--runs",
|
||||
str(args.runs),
|
||||
]
|
||||
if args.profile:
|
||||
command.append("--profile")
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
record = json.loads((destination / "result.json").read_text())
|
||||
record["artifact"] = str(destination.relative_to(args.output.resolve()))
|
||||
results.append(record)
|
||||
print(f"{index + 1}/{len(paths)} {relative}: {record['median_seconds']:.3f}s", flush=True)
|
||||
historical = {item["path"]: item for item in manifest["documents"]}
|
||||
history_differences = []
|
||||
for record in results:
|
||||
old = historical.get(record["path"])
|
||||
if old is not None:
|
||||
expected = [page["fingerprint"] for page in old["pages"]]
|
||||
expected_bbox = [page["bbox_fingerprint"] for page in old["pages"]]
|
||||
if expected != record["page_fingerprints"] or expected_bbox != record["bbox_fingerprints"]:
|
||||
history_differences.append(record["path"])
|
||||
report = {
|
||||
"git_sha": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||
"git_status": subprocess.check_output(["git", "status", "--short"], cwd=ROOT, text=True),
|
||||
"python": sys.version,
|
||||
"platform": platform.platform(),
|
||||
"dependencies": {name: importlib.metadata.version(name) for name in ("pdftext", "pypdfium2", "numpy", "pydantic")},
|
||||
"runs": args.runs,
|
||||
"historical_baseline_sha": manifest["baseline_git_sha"],
|
||||
"historical_differences": history_differences,
|
||||
"documents": results,
|
||||
}
|
||||
_write_json(args.output / "report.json", report)
|
||||
if args.baseline and any(not item["equal"] for item in _compare(args.output, args.baseline, results)):
|
||||
raise SystemExit("Full output differs; inspect output.json artifacts and comparison.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from mineru.model.flash.pdf import titles
|
||||
from mineru.model.flash.pdf import models, titles
|
||||
|
||||
from _flash_pdf_test_utils import _prepared_text_page, _text_line
|
||||
|
||||
@@ -173,7 +173,7 @@ def test_document_regular_font_suppresses_code_page_false_title() -> None:
|
||||
dominant_font_weight=400.0,
|
||||
),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({mono_font, regular_font}),
|
||||
@@ -213,14 +213,14 @@ def test_document_title_profile_promotes_table_adjacent_style_but_not_container_
|
||||
font_coverage=1.0,
|
||||
dominant_font_weight=600.0,
|
||||
)
|
||||
body_profile = titles._DocumentBodyProfile(
|
||||
body_profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({("Body", 0)}),
|
||||
)
|
||||
title_profile = titles._DocumentTitleProfile(
|
||||
title_profile = models._DocumentTitleProfile(
|
||||
(
|
||||
titles._TitleStylePrototype(
|
||||
models._TitleStylePrototype(
|
||||
font_family="heading",
|
||||
font_flags=0,
|
||||
height_ratio=1.4,
|
||||
@@ -268,14 +268,14 @@ def test_document_title_profile_promotes_body_height_centered_section() -> None:
|
||||
font_coverage=1.0,
|
||||
dominant_font_weight=400.0,
|
||||
)
|
||||
body_profile = titles._DocumentBodyProfile(
|
||||
body_profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({regular_font}),
|
||||
)
|
||||
title_profile = titles._DocumentTitleProfile(
|
||||
title_profile = models._DocumentTitleProfile(
|
||||
(
|
||||
titles._TitleStylePrototype(
|
||||
models._TitleStylePrototype(
|
||||
font_family="body",
|
||||
font_flags=0,
|
||||
height_ratio=1.0,
|
||||
@@ -343,7 +343,7 @@ def test_document_title_profile_infers_repeated_large_left_aligned_style() -> No
|
||||
page_size=(100.0, 100.0),
|
||||
)
|
||||
)
|
||||
body_profile = titles._DocumentBodyProfile(
|
||||
body_profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -385,7 +385,7 @@ def test_regular_pitch_style_change_does_not_become_title() -> None:
|
||||
font_coverage=1.0,
|
||||
),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -431,14 +431,14 @@ def test_title_font_at_body_size_does_not_override_document_size_profile() -> No
|
||||
font_coverage=1.0,
|
||||
),
|
||||
]
|
||||
body_profile = titles._DocumentBodyProfile(
|
||||
body_profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({("Body", 0)}),
|
||||
)
|
||||
title_profile = titles._DocumentTitleProfile(
|
||||
title_profile = models._DocumentTitleProfile(
|
||||
(
|
||||
titles._TitleStylePrototype(
|
||||
models._TitleStylePrototype(
|
||||
font_family="heading",
|
||||
font_flags=0,
|
||||
height_ratio=1.4,
|
||||
@@ -515,7 +515,7 @@ def test_smaller_recurrent_regular_font_does_not_gain_title_style_signal() -> No
|
||||
dominant_font_weight=400.0,
|
||||
),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font, mono_font}),
|
||||
@@ -575,7 +575,7 @@ def test_document_profile_finds_cover_title_without_promoting_bottom_metadata()
|
||||
dominant_font_weight=800.0,
|
||||
),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(10.0, 400.0, frozenset())
|
||||
profile = models._DocumentBodyProfile(10.0, 400.0, frozenset())
|
||||
|
||||
titles._classify_page_titles(
|
||||
lines,
|
||||
|
||||
@@ -48,6 +48,40 @@ def _line(
|
||||
}
|
||||
|
||||
|
||||
def test_inline_script_scale_cache_is_linear_and_local_to_each_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""每轮字号计算随行数线性增长,后续轮次重新读取已变化的字符字号。"""
|
||||
|
||||
lines = [
|
||||
models._LineItem(
|
||||
text="body",
|
||||
bbox=(10.0, index * 30.0, 30.0, index * 30.0 + 10.0),
|
||||
angle=0,
|
||||
source_index=index,
|
||||
visual_row_id=index,
|
||||
effective_height=10.0,
|
||||
chars=[{"char": "a", "font": {"size": 10.0}}],
|
||||
)
|
||||
for index in range(20)
|
||||
]
|
||||
observed: list[float] = []
|
||||
original = native_text._native_typographic_scale
|
||||
|
||||
def record_scale(line: models._LineItem) -> float:
|
||||
"""记录真实字号计算,验证缓存不跨越行变更边界。"""
|
||||
|
||||
value = original(line)
|
||||
observed.append(value)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(native_text, "_native_typographic_scale", record_scale)
|
||||
native_text._merge_native_inline_scripts(lines, (100.0, 700.0))
|
||||
assert observed == [10.0] * 20
|
||||
lines[0].chars[0]["font"]["size"] = 12.0
|
||||
observed.clear()
|
||||
native_text._merge_native_inline_scripts(lines, (100.0, 700.0))
|
||||
assert observed == [12.0, *([10.0] * 19)]
|
||||
|
||||
|
||||
def test_private_use_decorative_rule_becomes_axis_line() -> None:
|
||||
"""验证页首宽幅私用区重复字形不再进入文本输出。"""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -24,7 +25,7 @@ from mineru.model.flash.pdf import (
|
||||
titles,
|
||||
visual_annotations,
|
||||
)
|
||||
from mineru.model.flash.pdf.document import PDFImageInfo
|
||||
from mineru.model.flash.pdf.document import PDFImageInfo, PDFPageTextGeometry
|
||||
|
||||
from _flash_pdf_test_utils import (
|
||||
_prepared_text_page,
|
||||
@@ -761,3 +762,54 @@ def test_numbered_formula_rows_are_not_claimed_as_index() -> None:
|
||||
|
||||
assert sum(block["type"] == "equation" for block in blocks) == 6
|
||||
assert not [block for block in blocks if block["type"] == "index"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retain_formula", [False, True])
|
||||
def test_document_preparation_releases_sources_but_keeps_formula_evidence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
retain_formula: bool,
|
||||
) -> None:
|
||||
"""页面准备释放原始字符所有者,同时保留公式重建明确持有的独立副本。"""
|
||||
|
||||
class TrackedChar(dict):
|
||||
"""允许弱引用观察字符字典的实际存活期。"""
|
||||
|
||||
references: list[weakref.ReferenceType[TrackedChar]] = []
|
||||
|
||||
def sources() -> pipeline._DocumentSources:
|
||||
"""在独立作用域构建字符来源,避免测试局部变量额外延长存活期。"""
|
||||
|
||||
chars = [TrackedChar(char="x", char_idx=0, bbox=(10.0, 20.0, 20.0, 30.0))]
|
||||
references.append(weakref.ref(chars[0]))
|
||||
return pipeline._DocumentSources(
|
||||
[models._PageSource((100.0, 100.0), [], chars, [])],
|
||||
[PDFPageTextGeometry(chars, {}, {})],
|
||||
[(100.0, 100.0)],
|
||||
[[]],
|
||||
[[]],
|
||||
)
|
||||
|
||||
def prepare(source: models._PageSource, **kwargs: object) -> models._PreparedPage:
|
||||
"""仅保留模拟公式重建需要的字符副本,其它原始引用应被释放。"""
|
||||
|
||||
prepared = models._PreparedPage(source.page_size, [], [], [], [])
|
||||
if retain_formula:
|
||||
prepared.canonical_formula_source_lines.append(
|
||||
models._LineItem(
|
||||
text="x",
|
||||
bbox=(10.0, 20.0, 20.0, 30.0),
|
||||
angle=0,
|
||||
source_index=0,
|
||||
chars=list(source.chars),
|
||||
)
|
||||
)
|
||||
return prepared
|
||||
|
||||
monkeypatch.setattr(pipeline, "_prepare_page_source", prepare)
|
||||
raw = sources()
|
||||
prepared = pipeline._prepare_document_sources(raw)
|
||||
assert raw.page_sources == []
|
||||
assert raw.page_text_geometries == []
|
||||
assert (references[0]() is not None) == retain_formula
|
||||
prepared[0].canonical_formula_source_lines.clear()
|
||||
assert references[0]() is None
|
||||
|
||||
@@ -73,7 +73,7 @@ def test_explicit_section_number_rejects_century_and_decimal_sentence() -> None:
|
||||
_text_line("0.26 value, continues", (0.0, 96.0, 100.0, 106.0), 5, font_signature=body_font),
|
||||
_text_line("ordinary body four", (0.0, 108.0, 100.0, 118.0), 6, font_signature=body_font),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -172,7 +172,7 @@ def test_noninitial_document_title_requires_paragraph_title_candidate() -> None:
|
||||
font_signature=body_font,
|
||||
font_coverage=1.0,
|
||||
)
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -290,7 +290,7 @@ def test_noninitial_document_titles_support_multiple_articles_without_metadata()
|
||||
page_size=(100.0, 150.0),
|
||||
),
|
||||
]
|
||||
profile = titles._DocumentBodyProfile(
|
||||
profile = models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=400.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -389,7 +389,7 @@ def test_body_height_section_titles_mark_only_repeated_short_anchors() -> None:
|
||||
lines,
|
||||
(100.0, 280.0),
|
||||
container_bboxes=[(0.0, 128.0, 100.0, 180.0)],
|
||||
document_body_profile=titles._DocumentBodyProfile(
|
||||
document_body_profile=models._DocumentBodyProfile(
|
||||
body_height=10.0,
|
||||
body_weight=250.0,
|
||||
regular_fonts=frozenset({body_font}),
|
||||
@@ -437,7 +437,7 @@ def test_grid_title_suppression_requires_two_distinct_parallel_bands() -> None:
|
||||
)
|
||||
source_index += 1
|
||||
lanes.append(
|
||||
titles._TextLane(
|
||||
models._TextLane(
|
||||
left=left,
|
||||
right=left + 100.0,
|
||||
lines=[
|
||||
@@ -792,7 +792,7 @@ def test_subset_font_visual_row_continues_into_short_body_tail() -> None:
|
||||
font_coverage=1.0,
|
||||
)
|
||||
rows = [(line, line.bbox) for line in (first, second, tail)]
|
||||
profile = titles._LaneBodyProfile(
|
||||
profile = models._LaneBodyProfile(
|
||||
body_height=10.0,
|
||||
body_font=("ABCDEF+SimSun", 0),
|
||||
body_weight=400.0,
|
||||
|
||||
@@ -130,9 +130,14 @@ def test_standalone_vlm_main_configures_streams_and_forwards_extra_args(monkeypa
|
||||
@pytest.mark.parametrize(
|
||||
("script", "entrypoint", "expected_options"),
|
||||
[
|
||||
("mineru-gradio", gradio.main, ("--api-url", "--api-server-tier")),
|
||||
("mineru-openai-server", vlm_server.main, ("--engine",)),
|
||||
("mineru-models-download", models.download_main, ("--tier", "--stack", "--source", "--verbose")),
|
||||
("mineru-api", api_server.main, ("--host", "--port", "--tier", "--no-flash", "--preload-models")),
|
||||
(
|
||||
"mineru-api",
|
||||
api_server.main,
|
||||
("--host", "--port", "--tier", "--no-flash", "--no-advanced", "--preload-models"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_standalone_command_help(
|
||||
@@ -158,7 +163,20 @@ def test_standalone_command_help(
|
||||
@pytest.mark.parametrize("exit_code", [0, 7])
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[[], ["--host", "0.0.0.0", "--port", "18000", "--tier", "basic", "--no-flash", "--preload-models"]],
|
||||
[
|
||||
[],
|
||||
[
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"18000",
|
||||
"--tier",
|
||||
"basic",
|
||||
"--no-flash",
|
||||
"--no-advanced",
|
||||
"--preload-models",
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_standalone_api_matches_kit_arguments_and_exit_code(
|
||||
monkeypatch: pytest.MonkeyPatch, args: list[str], exit_code: int
|
||||
@@ -319,7 +337,7 @@ def test_top_level_commands_register_implementation_callbacks_directly() -> None
|
||||
[
|
||||
("parse", ("--output", "--format", "--tier")),
|
||||
("gradio", ("--api-url", "--server-name", "--api-server-tier")),
|
||||
("api-server", ("--host", "--port", "--tier", "--no-flash", "--preload-models")),
|
||||
("api-server", ("--host", "--port", "--tier", "--no-flash", "--no-advanced", "--preload-models")),
|
||||
("vlm-server", ("--engine",)),
|
||||
("router", ("--host", "--upstream-url", "--local-gpus")),
|
||||
("version", ("--json",)),
|
||||
@@ -560,6 +578,21 @@ def test_api_server_rejects_backend_and_effort_options() -> None:
|
||||
assert "--effort" in effort_result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "option"),
|
||||
[("api-server", "--ocr-mode"), ("gradio", "--ocr-mode"), ("gradio", "--api-server-ocr-mode")],
|
||||
)
|
||||
def test_server_commands_reject_startup_ocr_configuration(command: str, option: str) -> None:
|
||||
"""服务启动命令不再接受或展示 OCR 配置,单次解析命令继续提供该参数。"""
|
||||
result = runner.invoke(app, [command, option, "ocr"])
|
||||
assert result.exit_code != 0
|
||||
assert "No such option" in result.output
|
||||
help_result = runner.invoke(app, [command, "--help"])
|
||||
assert help_result.exit_code == 0
|
||||
assert option not in help_result.output
|
||||
assert "--ocr-mode" in runner.invoke(app, ["parse", "--help"]).output
|
||||
|
||||
|
||||
def test_kit_commands_do_not_expose_formula_table_switches() -> None:
|
||||
"""校验 mineru-kit 公开命令不再暴露公式/表格识别开关。"""
|
||||
parse_help = runner.invoke(app, ["parse", "--help"])
|
||||
@@ -572,7 +605,7 @@ def test_kit_commands_do_not_expose_formula_table_switches() -> None:
|
||||
assert _REMOVED_DISABLE_FORMULA_OPTION not in output
|
||||
|
||||
|
||||
def test_api_server_forwards_single_tier_and_no_flash(monkeypatch: Any) -> None:
|
||||
def test_api_server_forwards_single_tier_and_disabled_tiers(monkeypatch: Any) -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def _fake_main(*, args: list[str], prog_name: str, standalone_mode: bool) -> None:
|
||||
@@ -583,14 +616,19 @@ def test_api_server_forwards_single_tier_and_no_flash(monkeypatch: Any) -> None:
|
||||
|
||||
monkeypatch.setattr(api_server.parser_api_server.main, "main", _fake_main)
|
||||
|
||||
result = runner.invoke(app, ["api-server", "--tier", "standard", "--no-flash", "--preload-models"])
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["api-server", "--tier", "standard", "--no-flash", "--no-advanced", "--preload-models"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["prog_name"] == "mineru-kit api-server"
|
||||
assert seen["standalone_mode"] is False
|
||||
assert [seen["args"][index + 1] for index, item in enumerate(seen["args"]) if item == "--tier"] == ["standard"]
|
||||
assert "--no-flash" in seen["args"]
|
||||
assert "--no-advanced" in seen["args"]
|
||||
assert "--preload-models" in seen["args"]
|
||||
assert "--ocr-mode" not in seen["args"]
|
||||
|
||||
|
||||
def test_api_server_without_tier_lets_parser_api_apply_standard_default(monkeypatch: Any) -> None:
|
||||
@@ -610,6 +648,8 @@ def test_api_server_without_tier_lets_parser_api_apply_standard_default(monkeypa
|
||||
assert seen["prog_name"] == "mineru-kit api-server"
|
||||
assert seen["standalone_mode"] is False
|
||||
assert "--tier" not in seen["args"]
|
||||
assert "--no-flash" not in seen["args"]
|
||||
assert "--no-advanced" not in seen["args"]
|
||||
assert "--preload-models" not in seen["args"]
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -17,13 +18,14 @@ from PIL import Image, ImageStat
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from mineru.filetypes import PARSEABLE_EXTENSIONS
|
||||
from mineru.filetypes import FLASH_ONLY_PARSE_EXTENSIONS, IMAGE_EXTENSIONS, PARSEABLE_EXTENSIONS
|
||||
from mineru.kit.commands import gradio as gradio_command
|
||||
from mineru.kit.gradio import app as gradio_app
|
||||
from mineru.kit.gradio import client as gradio_client
|
||||
from mineru.kit.gradio.app import build_gradio_app
|
||||
from mineru.kit.gradio.artifacts import create_run_artifacts, persist_parse_result, render_download
|
||||
from mineru.kit.gradio.client import (
|
||||
GradioArtifactClient,
|
||||
ManagedLocalApiServer,
|
||||
STATUS_DOWNLOADING_RESULT,
|
||||
V1ArtifactClient,
|
||||
@@ -35,8 +37,9 @@ from mineru.kit.main import app
|
||||
from mineru.parser import api_client as parser_api_client
|
||||
from mineru.parser import api_server as parser_api_server
|
||||
from mineru.parser.base import ParseResult
|
||||
from mineru.types import BlockType, ImageBlock, ImageBodyBlock, MiddleJson, PageInfo, TextBlock, TextSpan
|
||||
from mineru.types import BlockType, ImageBlock, ImageBodyBlock, MiddleJson, ModelJson, PageInfo, TextBlock, TextSpan
|
||||
from mineru.version import __version__
|
||||
from typer.main import get_command
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -168,6 +171,50 @@ def test_gradio_command_is_registered_and_help_is_available() -> None:
|
||||
assert "gradio" in result.output
|
||||
assert "--api-url" in gradio_result.output
|
||||
assert "--api-server-tier" in gradio_result.output
|
||||
assert "Disable Advanced on" not in gradio_result.output
|
||||
assert "Disable Flash on" not in gradio_result.output
|
||||
|
||||
|
||||
def test_gradio_managed_tier_disable_option_names_are_removed() -> None:
|
||||
"""验证禁用档位的参数完整删除,不仅从帮助中隐藏。"""
|
||||
root_command = get_command(app)
|
||||
command = root_command.get_command(None, "gradio")
|
||||
assert command is not None
|
||||
options = {parameter.name: tuple(parameter.opts) for parameter in command.params}
|
||||
|
||||
assert "api_server_no_flash" not in options
|
||||
assert "api_server_no_advanced" not in options
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag",
|
||||
[
|
||||
"--api-server-no-flash",
|
||||
"--no-flash",
|
||||
"--api-server-no-advanced",
|
||||
"--no-advanced",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("standalone", [False, True])
|
||||
def test_gradio_rejects_removed_tier_disable_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
flag: str,
|
||||
standalone: bool,
|
||||
) -> None:
|
||||
"""验证两个命令入口在启动服务前拒绝所有已移除的禁用档位参数。"""
|
||||
launch = Mock()
|
||||
monkeypatch.setattr(gradio_app, "launch_gradio", launch)
|
||||
|
||||
if standalone:
|
||||
monkeypatch.setattr(sys, "argv", ["mineru-gradio", flag])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
gradio_command.main()
|
||||
assert error.value.code == 2
|
||||
else:
|
||||
result = runner.invoke(app, ["gradio", flag])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert "No such option" in result.output
|
||||
launch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("port_args", "expected_port"), [([], None), (["--server-port", "7861"], 7861)])
|
||||
@@ -282,6 +329,28 @@ def test_v1_client_discovers_health_and_tiers_and_sends_api_key() -> None:
|
||||
assert requests == [("GET", "/api/v1/health"), ("GET", "/api/v1/tiers")]
|
||||
|
||||
|
||||
def test_v1_client_discovers_api_server_without_advanced(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""验证真实 API server 禁用 Advanced 后,Gradio 能力快照不会重新补回该档位。"""
|
||||
monkeypatch.setattr(parser_api_server, "_preflight_tier_dependencies", Mock())
|
||||
api = parser_api_server.create_app(
|
||||
upload_dir=str(tmp_path / "api"),
|
||||
tier="standard",
|
||||
no_advanced=True,
|
||||
)
|
||||
request_log: list[tuple[str, str, str | None]] = []
|
||||
|
||||
with TestClient(api, base_url="http://testserver") as test_client:
|
||||
proxy = _httpx_proxy_for_test_client(test_client, request_log)
|
||||
monkeypatch.setattr(gradio_client, "httpx", proxy)
|
||||
capabilities = asyncio.run(V1ArtifactClient(api_url="http://testserver").discover())
|
||||
|
||||
assert capabilities.tiers == ("flash", "basic", "standard")
|
||||
assert [path for _method, path, _authorization in request_log] == ["/v1/health", "/v1/tiers"]
|
||||
|
||||
|
||||
def test_v1_client_rejects_server_without_zip() -> None:
|
||||
"""验证缺少 ZIP 能力时在 UI 启动前给出稳定错误。"""
|
||||
|
||||
@@ -392,6 +461,111 @@ def test_v1_client_full_asgi_upload_job_poll_and_zip(
|
||||
assert parse_calls[0]["page_range"] == "1"
|
||||
|
||||
|
||||
def test_gradio_routes_real_v1_jobs_and_isolates_remote_auth(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""贯通两套真实 V1 路由,验证本地 Flash 不上传远程且环境密钥只用于远程任务。"""
|
||||
source = tmp_path / "report.pdf"
|
||||
source.write_bytes(_pdf_bytes())
|
||||
monkeypatch.setenv("MINERU_API_KEY", "remote-secret")
|
||||
monkeypatch.setattr(parser_api_server, "_preflight_tier_dependencies", Mock())
|
||||
|
||||
async def fake_parse_async(path: str, **kwargs: Any) -> ParseResult:
|
||||
"""只替换模型推理,保留两套服务各自的上传、任务和 ZIP 打包流程。"""
|
||||
return ParseResult(middle_json=_middle_json(with_image=False), _model_output={"raw": "model"})
|
||||
|
||||
monkeypatch.setattr(parser_api_server, "parse_async", fake_parse_async)
|
||||
remote_api = parser_api_server.create_app(
|
||||
upload_dir=str(tmp_path / "remote"), tier="standard", no_flash=True, no_advanced=True, api_key="remote-secret"
|
||||
)
|
||||
local_api = parser_api_server.create_app(upload_dir=str(tmp_path / "local"), tier="flash")
|
||||
requests: list[tuple[str, str, str, str | None]] = []
|
||||
with TestClient(remote_api, base_url="http://remote.test") as remote_http:
|
||||
with TestClient(local_api, base_url="http://local.test") as local_http:
|
||||
|
||||
async def handle(request: httpx.Request) -> httpx.Response:
|
||||
"""按目标主机转入对应 ASGI 服务,记录完整请求目的地和鉴权头。"""
|
||||
host = request.url.host
|
||||
requests.append((host, request.method, request.url.path, request.headers.get("authorization")))
|
||||
target = {"remote.test": remote_http, "local.test": local_http}[host]
|
||||
response = target.request(
|
||||
request.method,
|
||||
str(request.url),
|
||||
headers=dict(request.headers),
|
||||
content=request.content,
|
||||
)
|
||||
return httpx.Response(response.status_code, headers=response.headers, content=response.content)
|
||||
|
||||
def async_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""为能力发现和 API parser 注入同一双服务传输层。"""
|
||||
kwargs["transport"] = httpx.MockTransport(handle)
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
proxy = SimpleNamespace(**{**vars(httpx), "AsyncClient": async_client})
|
||||
monkeypatch.setattr(gradio_client, "httpx", proxy)
|
||||
monkeypatch.setattr(parser_api_client, "httpx", proxy)
|
||||
remote = V1ArtifactClient(api_url="http://remote.test")
|
||||
local = V1ArtifactClient(api_url="http://local.test", api_key="")
|
||||
remote_capabilities = asyncio.run(remote.discover())
|
||||
local_capabilities = asyncio.run(local.discover())
|
||||
routed = GradioArtifactClient(remote, local_flash=local)
|
||||
assert routed.capabilities.tiers == ("flash", "basic", "standard")
|
||||
assert remote.capabilities is remote_capabilities
|
||||
assert remote_capabilities.tiers == ("basic", "standard")
|
||||
assert local.capabilities is local_capabilities
|
||||
requests.clear()
|
||||
|
||||
flash_result = asyncio.run(routed.parse_file(source, tier="flash", page_range=""))
|
||||
assert flash_result._model_output == {"raw": "model"}
|
||||
assert requests and all(host == "local.test" and auth is None for host, _method, _path, auth in requests)
|
||||
assert any(method == "POST" and path == "/v1/parse/jobs" for _host, method, path, _auth in requests)
|
||||
requests.clear()
|
||||
|
||||
remote_result = asyncio.run(routed.parse_file(source, tier="standard", page_range="1"))
|
||||
assert remote_result._model_output == {"raw": "model"}
|
||||
assert requests and all(
|
||||
host == "remote.test" and auth == "Bearer remote-secret" for host, _method, _path, auth in requests
|
||||
)
|
||||
assert any(method == "POST" and path == "/v1/parse/jobs" for _host, method, path, _auth in requests)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, V1ArtifactError("remote failed", code="parse_failed"), asyncio.CancelledError()])
|
||||
def test_gradio_router_preserves_remote_flash_results_errors_and_cancellation(failure: BaseException | None) -> None:
|
||||
"""验证远程已有 Flash 时优先远程,并原样传递结果、失败和取消而不重试本地。"""
|
||||
result = ParseResult(middle_json=_middle_json(with_image=False))
|
||||
primary = Mock(spec=V1ArtifactClient)
|
||||
primary.capabilities = V1ServerCapabilities("http://remote.test", ("standard", "flash"), ("zip",), ("file_id",))
|
||||
primary.parse_file = AsyncMock(return_value=result, side_effect=failure)
|
||||
local = Mock(spec=V1ArtifactClient)
|
||||
routed = GradioArtifactClient(primary, local_flash=local)
|
||||
callback = Mock()
|
||||
request = routed.parse_file(Path("report.pdf"), tier="flash", page_range="1-2", ocr_mode="ocr", status_callback=callback)
|
||||
if failure is None:
|
||||
assert asyncio.run(request) is result
|
||||
else:
|
||||
with pytest.raises(type(failure)) as error:
|
||||
asyncio.run(request)
|
||||
assert error.value is failure
|
||||
primary.parse_file.assert_awaited_once_with(
|
||||
Path("report.pdf"), tier="flash", page_range="1-2", ocr_mode="ocr", status_callback=callback
|
||||
)
|
||||
local.parse_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("tier", "code"), [("advanced", "tier_unavailable"), ("unknown", "invalid_request")])
|
||||
def test_gradio_router_rejects_unavailable_tiers(tier: str, code: str) -> None:
|
||||
"""验证路由层只补充 Flash,拒绝未声明的 Advanced 及未知档位。"""
|
||||
primary = Mock(spec=V1ArtifactClient)
|
||||
primary.capabilities = V1ServerCapabilities("http://remote.test", ("standard",), ("zip",), ("file_id",))
|
||||
local = Mock(spec=V1ArtifactClient)
|
||||
local.capabilities = V1ServerCapabilities("http://local.test", ("flash", "advanced"), ("zip",), ("file_id",))
|
||||
routed = GradioArtifactClient(primary, local_flash=local)
|
||||
assert routed.capabilities.tiers == ("flash", "standard")
|
||||
with pytest.raises(V1ArtifactError) as error:
|
||||
asyncio.run(routed.parse_file(Path("report.pdf"), tier=tier, page_range=""))
|
||||
assert error.value.code == code
|
||||
primary.parse_file.assert_not_called()
|
||||
local.parse_file.assert_not_called()
|
||||
|
||||
|
||||
def test_api_client_upload_auth_is_limited_to_same_origin() -> None:
|
||||
"""验证 Bearer Key 会用于同源上传,但不会泄露给外部预签名地址。"""
|
||||
same_origin = parser_api_client._same_origin_upload_headers(
|
||||
@@ -410,6 +584,60 @@ def test_api_client_upload_auth_is_limited_to_same_origin() -> None:
|
||||
assert "Authorization" not in external
|
||||
|
||||
|
||||
def test_gradio_ocr_reaches_analysis_through_real_v1_jobs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""贯通 UI、上传、V1 任务与统一解析器,仅替换分析推理并检查连续请求之间的模式隔离。"""
|
||||
from mineru.parser import mineru_parser
|
||||
|
||||
source = tmp_path / "report.PDF"
|
||||
source.write_bytes(_pdf_bytes())
|
||||
received_modes: list[str] = []
|
||||
|
||||
async def fake_analyze(file_bytes: bytes, **kwargs: Any) -> tuple[MiddleJson, ModelJson]:
|
||||
"""记录真正传入分析层的模式,并生成与模式一致的严格结果和模型输出。"""
|
||||
assert file_bytes.startswith(b"%PDF")
|
||||
received_modes.append(kwargs["parse_mode"])
|
||||
middle = _middle_json(with_image=False)
|
||||
middle.parse_mode = "ocr" if kwargs["parse_mode"] == "ocr" else "txt"
|
||||
model = ModelJson(
|
||||
pages=[[]],
|
||||
page_index_map=[],
|
||||
file_suffix="pdf",
|
||||
effort="flash",
|
||||
parse_mode=middle.parse_mode,
|
||||
mineru_version=__version__,
|
||||
)
|
||||
return middle, model
|
||||
|
||||
monkeypatch.setattr(mineru_parser, "aio_doc_analyze", fake_analyze)
|
||||
api = parser_api_server.create_app(upload_dir=str(tmp_path / "api"), tier="flash")
|
||||
with TestClient(api, base_url="http://testserver") as test_client:
|
||||
request_log: list[tuple[str, str, str | None]] = []
|
||||
proxy = _httpx_proxy_for_test_client(test_client, request_log)
|
||||
monkeypatch.setattr(gradio_client, "httpx", proxy)
|
||||
monkeypatch.setattr(parser_api_client, "httpx", proxy)
|
||||
client = V1ArtifactClient(api_url="http://testserver")
|
||||
|
||||
async def convert_requests() -> None:
|
||||
"""同一 Gradio 实例依次强制、自动、强制解析,再通过缺省 SDK 请求验证默认模式。"""
|
||||
capabilities = await client.discover()
|
||||
demo = build_gradio_app(client, capabilities, output_root=tmp_path / "output", enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
for enabled in (True, False, True):
|
||||
updates = [update async for update in handler(str(source), 0, "", enabled)]
|
||||
state = updates[-1][8]
|
||||
assert state is not None, updates[-1][0]
|
||||
payload = json.loads(Path(state["middle_json_path"]).read_text())
|
||||
assert payload["parse_mode"] == ("ocr" if enabled else "txt")
|
||||
default_parser = parser_api_client.MinerUApiParser(api_url="http://testserver", tier="flash")
|
||||
result = await default_parser.parse_async(source)
|
||||
assert result.middle_json.parse_mode == "txt"
|
||||
|
||||
asyncio.run(convert_requests())
|
||||
assert not hasattr(api.state, "ocr_mode")
|
||||
assert received_modes == ["ocr", "auto", "ocr", "auto"]
|
||||
assert sum(method == "POST" and path == "/v1/parse/jobs" for method, path, _ in request_log) == 4
|
||||
|
||||
|
||||
def test_v1_client_preserves_v1_error_code(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""验证 V1 error envelope 会保留错误码并清晰传给 UI。"""
|
||||
source = tmp_path / "demo.pdf"
|
||||
@@ -421,7 +649,7 @@ def test_v1_client_preserves_v1_error_code(monkeypatch: pytest.MonkeyPatch, tmp_
|
||||
def __init__(self, **_kwargs: Any) -> None:
|
||||
"""忽略构造参数。"""
|
||||
|
||||
async def parse_async(self, _path: Path, *, page_range: str) -> ParseResult:
|
||||
async def parse_async(self, _path: Path, *, page_range: str, status_callback: Any = None) -> ParseResult:
|
||||
"""抛出与正式 API client 一致的错误形态。"""
|
||||
error = RuntimeError(f"bad range: {page_range}")
|
||||
error.code = "page_range_invalid" # type: ignore[attr-defined]
|
||||
@@ -435,7 +663,8 @@ def test_v1_client_preserves_v1_error_code(monkeypatch: pytest.MonkeyPatch, tmp_
|
||||
assert error.value.code == "page_range_invalid"
|
||||
|
||||
|
||||
def test_v1_client_parse_uses_api_parser_zip_contract(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
@pytest.mark.parametrize("ocr_mode", ["auto", "txt", "ocr"])
|
||||
def test_v1_client_parse_uses_api_parser_zip_contract(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ocr_mode: str) -> None:
|
||||
"""验证 Gradio client 固定请求带图片和模型输出的 V1 ZIP 结果。"""
|
||||
source = tmp_path / "demo.pdf"
|
||||
source.write_bytes(_pdf_bytes())
|
||||
@@ -448,10 +677,16 @@ def test_v1_client_parse_uses_api_parser_zip_contract(monkeypatch: pytest.Monkey
|
||||
"""保存构造参数,供断言 V1 ZIP 契约。"""
|
||||
calls.update(kwargs)
|
||||
|
||||
async def parse_async(self, path: Path, *, page_range: str) -> ParseResult:
|
||||
async def parse_async(self, path: Path, *, page_range: str, status_callback: Any = None) -> ParseResult:
|
||||
"""返回最小解析结果。"""
|
||||
calls["path"] = path
|
||||
calls["page_range"] = page_range
|
||||
assert statuses == ["Preparing request...", "Submitting task..."]
|
||||
assert status_callback is not None
|
||||
status_callback("queued")
|
||||
status_callback("running")
|
||||
status_callback("completed")
|
||||
assert statuses[-1] == STATUS_DOWNLOADING_RESULT
|
||||
return ParseResult(middle_json=_middle_json(with_image=False))
|
||||
|
||||
monkeypatch.setattr("mineru.kit.gradio.client.MinerUApiParser", FakeParser)
|
||||
@@ -463,24 +698,26 @@ def test_v1_client_parse_uses_api_parser_zip_contract(monkeypatch: pytest.Monkey
|
||||
("file_id",),
|
||||
)
|
||||
statuses: list[str] = []
|
||||
result = asyncio.run(client.parse_file(source, tier="standard", page_range="1-2", status_callback=statuses.append))
|
||||
result = asyncio.run(
|
||||
client.parse_file(source, tier="standard", page_range="1-2", ocr_mode=ocr_mode, status_callback=statuses.append)
|
||||
)
|
||||
assert isinstance(result, ParseResult)
|
||||
assert calls["include_images"] is True
|
||||
assert calls["include_model_output"] is True
|
||||
assert calls["tier"] == "standard"
|
||||
assert calls["ocr_mode"] == ocr_mode
|
||||
assert calls["page_range"] == "1-2"
|
||||
assert statuses[0] == "Preparing request..."
|
||||
assert statuses[-1] == STATUS_DOWNLOADING_RESULT
|
||||
assert statuses[2:4] == ["Queued on server", "Processing on server..."]
|
||||
|
||||
|
||||
def test_managed_local_api_command_uses_kit_entrypoint() -> None:
|
||||
"""验证托管 server 使用正式 mineru-kit api-server 命令。"""
|
||||
server = ManagedLocalApiServer(
|
||||
tier="standard",
|
||||
no_flash=True,
|
||||
concurrency=3,
|
||||
language="en",
|
||||
ocr_mode="ocr",
|
||||
disable_image_analysis=True,
|
||||
preload_models=True,
|
||||
api_key="secret",
|
||||
@@ -488,9 +725,20 @@ def test_managed_local_api_command_uses_kit_entrypoint() -> None:
|
||||
command = server._command(8123, Path("/tmp/mineru-upload"))
|
||||
assert command[:4] == [server._command(8123, Path("/tmp/mineru-upload"))[0], "-m", "mineru.kit.main", "api-server"]
|
||||
assert "--tier" in command and command[command.index("--tier") + 1] == "standard"
|
||||
assert "--no-flash" in command
|
||||
assert "--no-flash" not in command
|
||||
assert "--no-advanced" not in command
|
||||
assert "--preload-models" in command
|
||||
assert "--ocr-mode" not in command
|
||||
assert command[command.index("--api-key") + 1] == "secret"
|
||||
with pytest.raises(TypeError, match="ocr_mode"):
|
||||
ManagedLocalApiServer(ocr_mode="ocr") # type: ignore[call-arg]
|
||||
with pytest.raises(TypeError, match="api_server_ocr_mode"):
|
||||
gradio_app.launch_gradio(api_server_ocr_mode="ocr") # type: ignore[call-arg]
|
||||
for option in ("no_flash", "no_advanced"):
|
||||
with pytest.raises(TypeError, match=option):
|
||||
ManagedLocalApiServer(**{option: True}) # type: ignore[arg-type]
|
||||
with pytest.raises(TypeError, match=f"api_server_{option}"):
|
||||
gradio_app.launch_gradio(**{f"api_server_{option}": True}) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_managed_local_api_server_cleans_process_control_and_temp_dir(
|
||||
@@ -569,6 +817,7 @@ def test_managed_local_api_server_cleans_process_control_and_temp_dir(
|
||||
temp_root = Path(server._temp_dir.name)
|
||||
assert temp_root.is_dir()
|
||||
assert "--allow-local-source" not in captured["command"]
|
||||
assert "--no-advanced" not in captured["command"]
|
||||
assert captured["accepting"] is True
|
||||
|
||||
server.stop()
|
||||
@@ -724,11 +973,14 @@ def test_render_download_rejects_state_outside_allowed_root(tmp_path: Path) -> N
|
||||
assert not (tmp_path / "escaped.html").exists()
|
||||
|
||||
|
||||
def test_gradio_file_types_page_range_and_header_follow_new_contract() -> None:
|
||||
def test_gradio_file_types_page_range_and_header_follow_new_contract(tmp_path: Path) -> None:
|
||||
"""验证完整扩展名、PDF-only 页码规则和复用后的 Header。"""
|
||||
assert set(gradio_app._supported_file_types()) == {f".{extension}" for extension in PARSEABLE_EXTENSIONS}
|
||||
assert gradio_app._effective_page_range("report.PDF", " 1-3,r1 ") == "1-3,r1"
|
||||
assert gradio_app._effective_page_range("report.docx", "1-3") == ""
|
||||
source = tmp_path / "report.PDF"
|
||||
source.write_bytes(_pdf_bytes(5))
|
||||
assert gradio_app._effective_page_range(source, " 1-3,r1 ", tier="standard") == "1-3,r1"
|
||||
assert gradio_app._effective_page_range(source, " 1-3,r1 ", tier="flash") == ""
|
||||
assert gradio_app._effective_page_range("report.docx", "1-3", tier="standard") == ""
|
||||
header = gradio_app._render_header(gradio_major_version=6)
|
||||
assert "mineru-gradio6-header" in header
|
||||
assert "mineru-header-popover mineru-model-popover" in header
|
||||
@@ -736,6 +988,48 @@ def test_gradio_file_types_page_range_and_header_follow_new_contract() -> None:
|
||||
assert "{{HEADER_" not in header
|
||||
|
||||
|
||||
def test_gradio_ocr_control_visibility_reset_and_event_binding(tmp_path: Path) -> None:
|
||||
"""验证 PDF 专属开关的缺省值、文件重置、清除绑定和独立于 tier 的可见性。"""
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash", "standard"), ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(Mock(), capabilities, output_root=tmp_path, enable_example=False)
|
||||
checkbox = next(block for block in demo.blocks.values() if block.__class__.__name__ == "Checkbox")
|
||||
assert checkbox.label == "强制 OCR" and checkbox.value is False and checkbox.visible is False
|
||||
update = next(fn for fn in demo.fns.values() if fn.name == "update_ocr_control")
|
||||
assert len(update.inputs) == 1 and update.inputs[0].__class__.__name__ == "File"
|
||||
for path, visible in (("first.pdf", True), ("second.PDF", True), ("photo.png", False), ("book.docx", False), (None, False)):
|
||||
assert update.fn(path) == {"__type__": "update", "value": False, "visible": visible}
|
||||
convert = next(fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
assert convert.inputs[-1] is checkbox
|
||||
slider = next(block for block in demo.blocks.values() if "mineru-tier-slider" in (block.elem_classes or []))
|
||||
clear = next(block for block in demo.blocks.values() if block.__class__.__name__ == "ClearButton")
|
||||
events = demo.config["dependencies"]
|
||||
assert all(checkbox._id not in event["outputs"] for event in events if (slider._id, "input") in event["targets"])
|
||||
assert any(checkbox._id in event["outputs"] for event in events if (clear._id, "click") in event["targets"])
|
||||
change = next(event for event in events if event["id"] == update._id)
|
||||
assert change["queue"] is False and change["trigger_mode"] == "always_last"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".png", ".docx", ".csv"])
|
||||
def test_gradio_non_pdf_ignores_hidden_force_ocr(tmp_path: Path, suffix: str) -> None:
|
||||
"""直接调用事件并传入残留 True,非 PDF 请求仍使用 auto。"""
|
||||
source = tmp_path / f"source{suffix}"
|
||||
source.write_bytes(b"placeholder")
|
||||
client = SimpleNamespace(parse_file=AsyncMock(side_effect=V1ArtifactError("stop after request")))
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash",), ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(client, capabilities, output_root=tmp_path / "output", enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
|
||||
async def convert() -> None:
|
||||
"""消费完整转换事件,以等待参数进入 client。"""
|
||||
updates = [update async for update in handler(str(source), 0, "1-3", True)]
|
||||
assert "stop after request" in updates[-1][0]
|
||||
|
||||
asyncio.run(convert())
|
||||
client.parse_file.assert_awaited_once()
|
||||
assert client.parse_file.call_args.kwargs["ocr_mode"] == "auto"
|
||||
assert client.parse_file.call_args.kwargs["page_range"] == ""
|
||||
|
||||
|
||||
def test_build_gradio_app_exposes_three_tabs_and_download_menu(tmp_path: Path) -> None:
|
||||
"""验证新 UI 不引入旧 backend 控件,并注册三个标签和下载事件。"""
|
||||
capabilities = V1ServerCapabilities(
|
||||
@@ -752,19 +1046,32 @@ def test_build_gradio_app_exposes_three_tabs_and_download_menu(tmp_path: Path) -
|
||||
)
|
||||
tab_labels = [component.label for component in app.blocks.values() if component.__class__.__name__ == "Tab"]
|
||||
assert tab_labels == ["Markdown 渲染", "Markdown 源码", "Structured Content 源码"]
|
||||
download_labels = [component.label for component in app.blocks.values() if component.__class__.__name__ == "DownloadButton"]
|
||||
assert download_labels == ["ZIP", "HTML", "DOCX", "LaTeX bundle", "EPUB", "PDF"]
|
||||
download_buttons = [component for component in app.blocks.values() if component.__class__.__name__ == "DownloadButton"]
|
||||
download_buttons = [
|
||||
component
|
||||
for component in app.blocks.values()
|
||||
if component.__class__.__name__ == "Button"
|
||||
and any(name.startswith("mineru-kit-download-") for name in (component.elem_classes or []))
|
||||
]
|
||||
assert [component.value for component in download_buttons] == ["ZIP", "HTML", "DOCX", "LaTeX bundle", "EPUB", "PDF"]
|
||||
assert not any(component.__class__.__name__ == "DownloadButton" for component in app.blocks.values())
|
||||
assert all(component.interactive is False for component in download_buttons)
|
||||
file_inputs = [component for component in app.blocks.values() if component.__class__.__name__ == "File"]
|
||||
file_inputs = [
|
||||
component for component in app.blocks.values() if component.__class__.__name__ == "File" and component.visible
|
||||
]
|
||||
assert len(file_inputs) == 1
|
||||
assert file_inputs[0].file_count == "single"
|
||||
assert set(file_inputs[0].file_types) == {f".{extension}" for extension in PARSEABLE_EXTENSIONS}
|
||||
preview_handler = next(fn.fn for fn in app.fns.values() if fn.name == "update_file_preview")
|
||||
assert preview_handler("report.pdf")[0]["interactive"] is True
|
||||
non_pdf_page_update = preview_handler("report.docx")[0]
|
||||
assert non_pdf_page_update["interactive"] is False
|
||||
assert non_pdf_page_update["value"] == ""
|
||||
assert preview_handler("report.pdf")[0] == {
|
||||
"__type__": "update",
|
||||
"value": "report.pdf",
|
||||
"visible": True,
|
||||
"elem_classes": ["mineru-kit-pdf-preview"],
|
||||
}
|
||||
assert preview_handler("report.docx")[0]["visible"] == "hidden"
|
||||
range_group = next(block for block in app.blocks.values() if "mineru-kit-page-range" in (block.elem_classes or []))
|
||||
assert range_group.visible is True
|
||||
assert '[data-range-visible="true"]' in app._mineru_kit_css
|
||||
assert sum(1 for dependency in app.config["dependencies"] if dependency.get("queue") is True) >= 7
|
||||
|
||||
|
||||
@@ -773,6 +1080,7 @@ def test_build_gradio_app_exposes_three_tabs_and_download_menu(tmp_path: Path) -
|
||||
[
|
||||
(("advanced", "flash", "standard", "basic"), 2, "standard", 3),
|
||||
(("advanced", "standard", "basic"), 1, "standard", 2),
|
||||
(("flash", "basic", "standard"), 2, "standard", 2),
|
||||
(("advanced", "flash"), 1, "advanced", 1),
|
||||
(("basic", "flash"), 1, "basic", 1),
|
||||
(("flash",), 0, "flash", 1),
|
||||
@@ -794,10 +1102,18 @@ def test_gradio_tier_slider_uses_available_tiers_and_preserves_selection(
|
||||
assert slider.interactive is (len(tiers) > 1)
|
||||
assert label.value == f"解析 tier:{default_tier}"
|
||||
assert not any(block.__class__.__name__ == "Dropdown" for block in demo.blocks.values())
|
||||
label_event = next(event for event in demo.config["dependencies"] if event["targets"] == [(slider._id, "input")])
|
||||
label_events = [event for event in demo.config["dependencies"] if (slider._id, "input") in event["targets"]]
|
||||
assert len(label_events) == 1
|
||||
label_event = label_events[0]
|
||||
assert label_event["backend_fn"] is False
|
||||
assert label_event["queue"] is False
|
||||
assert label_event["outputs"] == [label._id]
|
||||
assert label_event["trigger_mode"] == "always_last"
|
||||
assert label_event["outputs"][7:9] == [slider._id, label._id]
|
||||
preference = demo.blocks[label_event["outputs"][9]]
|
||||
assert preference.visible is False
|
||||
assert json.loads(preference.value) == {"tier": default_tier, "locked": False}
|
||||
assert preference._id in label_event["inputs"]
|
||||
assert json.dumps(sorted(FLASH_ONLY_PARSE_EXTENSIONS)) in label_event["js"]
|
||||
for fn in demo.fns.values():
|
||||
if fn.name in {"update_file_preview", "reset_ui"}:
|
||||
assert slider not in fn.outputs and label not in fn.outputs
|
||||
@@ -806,6 +1122,51 @@ def test_gradio_tier_slider_uses_available_tiers_and_preserves_selection(
|
||||
assert slider.api_info()["type"] == "integer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extension", sorted(FLASH_ONLY_PARSE_EXTENSIONS | IMAGE_EXTENSIONS))
|
||||
@pytest.mark.parametrize("uppercase", [False, True])
|
||||
def test_gradio_conversion_enforces_file_type_tier(tmp_path: Path, extension: str, uppercase: bool) -> None:
|
||||
"""高档位残留值对所有轻量格式固定为 Flash,图片则保留用户选择,后缀不区分大小写。"""
|
||||
source = tmp_path / f"source.{extension.upper() if uppercase else extension}"
|
||||
source.write_bytes(b"placeholder")
|
||||
client = SimpleNamespace(parse_file=AsyncMock(side_effect=V1ArtifactError("stop after request")))
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("standard", "flash"), ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(client, capabilities, output_root=tmp_path / "output", enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
|
||||
async def convert() -> None:
|
||||
"""模拟直接调用事件,并携带不应影响非 PDF 的选页与 OCR 残留值。"""
|
||||
updates = [update async for update in handler(str(source), 1, "bad range", True)]
|
||||
assert "stop after request" in updates[-1][0]
|
||||
|
||||
asyncio.run(convert())
|
||||
client.parse_file.assert_awaited_once()
|
||||
kwargs = client.parse_file.call_args.kwargs
|
||||
assert kwargs["tier"] == ("flash" if extension in FLASH_ONLY_PARSE_EXTENSIONS else "standard")
|
||||
assert kwargs["page_range"] == "" and kwargs["ocr_mode"] == "auto"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tiers", [("standard",), ("advanced", "basic"), ("basic", "standard", "advanced")])
|
||||
def test_gradio_flash_only_input_requires_available_flash(tmp_path: Path, tiers: tuple[str, ...]) -> None:
|
||||
"""服务缺少 Flash 时在事件入口报错,不发起解析请求,也不保留旧下载。"""
|
||||
source = tmp_path / "source.csv"
|
||||
source.write_text("name,value\na,1\n", encoding="utf-8")
|
||||
client = SimpleNamespace(parse_file=AsyncMock())
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", tiers, ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(client, capabilities, output_root=tmp_path / "output", enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
|
||||
async def convert() -> list[tuple[Any, ...]]:
|
||||
"""收集合法位置的错误响应,验证不是滑杆越界触发的拒绝。"""
|
||||
return [update async for update in handler(str(source), 0, "")]
|
||||
|
||||
updates = asyncio.run(convert())
|
||||
assert "tier_unavailable" in updates[-1][0]
|
||||
assert "该格式仅支持 Flash,当前服务不可用" in updates[-1][0]
|
||||
assert updates[-1][8] is None
|
||||
assert all(item["interactive"] is False for item in updates[-1][-6:])
|
||||
client.parse_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tiers", "position", "expected_tier"),
|
||||
[
|
||||
@@ -818,8 +1179,9 @@ def test_gradio_tier_slider_uses_available_tiers_and_preserves_selection(
|
||||
(("flash",), 0, "flash"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("force_ocr", [False, True])
|
||||
def test_gradio_conversion_forwards_page_range_and_enables_fresh_downloads(
|
||||
tmp_path: Path, tiers: tuple[str, ...], position: int, expected_tier: str
|
||||
tmp_path: Path, tiers: tuple[str, ...], position: int, expected_tier: str, force_ocr: bool
|
||||
) -> None:
|
||||
"""验证滑块位置映射与 PDF page_range 透传,并只在新结果完成后启用下载。"""
|
||||
source = tmp_path / "demo.pdf"
|
||||
@@ -838,17 +1200,19 @@ def test_gradio_conversion_forwards_page_range_and_enables_fresh_downloads(
|
||||
*,
|
||||
tier: str,
|
||||
page_range: str,
|
||||
ocr_mode: str = "auto",
|
||||
status_callback: Any = None,
|
||||
) -> ParseResult:
|
||||
"""返回最小解析结果并记录页码。"""
|
||||
self.calls.append((path, tier, page_range))
|
||||
assert ocr_mode == ("ocr" if force_ocr else "auto")
|
||||
if status_callback is not None:
|
||||
status_callback("Processing on server...")
|
||||
return ParseResult(middle_json=_middle_json(with_image=False))
|
||||
|
||||
async def collect_updates(handler: Any) -> list[tuple[Any, ...]]:
|
||||
"""收集 Gradio 异步生成器的全部增量输出。"""
|
||||
return [update async for update in handler(str(source), position, " 1 ")]
|
||||
return [update async for update in handler(str(source), position, " 1 ", force_ocr)]
|
||||
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", tiers, ("zip",), ("file_id",))
|
||||
client = FakeClient()
|
||||
@@ -861,11 +1225,12 @@ def test_gradio_conversion_forwards_page_range_and_enables_fresh_downloads(
|
||||
convert_handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
updates = asyncio.run(collect_updates(convert_handler))
|
||||
|
||||
assert client.calls == [(source.resolve(), expected_tier, "1")]
|
||||
assert len(updates[-1]) == 15
|
||||
assert client.calls == [(source.resolve(), expected_tier, "" if expected_tier == "flash" else "1")]
|
||||
assert len(updates[-1]) == 16
|
||||
assert updates[-1][9] == Path(updates[-1][8]["root"]).name
|
||||
assert updates[-1][8] is not None
|
||||
assert all(update["interactive"] is True and update["value"] is None for update in updates[-1][-6:])
|
||||
assert all(update["interactive"] is False and update["value"] is None for update in updates[0][-6:])
|
||||
assert all(update["interactive"] is True for update in updates[-1][-6:])
|
||||
assert all(update["interactive"] is False for update in updates[0][-6:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -895,7 +1260,7 @@ def test_gradio_conversion_rejects_invalid_tier_position(tmp_path: Path, tiers:
|
||||
updates = asyncio.run(collect_updates())
|
||||
assert "Failed: Invalid tier slider position" in updates[-1][0]
|
||||
assert updates[-1][8] is None
|
||||
assert all(item["interactive"] is False and item["value"] is None for item in updates[-1][-6:])
|
||||
assert all(item["interactive"] is False for item in updates[-1][-6:])
|
||||
client.parse_file.assert_not_called()
|
||||
|
||||
|
||||
@@ -928,4 +1293,120 @@ def test_gradio_conversion_failure_clears_previous_downloads(tmp_path: Path) ->
|
||||
|
||||
assert "Failed: boom" in update[0]
|
||||
assert update[8] is None
|
||||
assert all(item["interactive"] is False and item["value"] is None for item in update[-6:])
|
||||
assert all(item["interactive"] is False for item in update[-6:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explicit_session_cancel", [False, True])
|
||||
def test_gradio_local_queue_cancellation_releases_slot_and_keeps_sessions_isolated(
|
||||
tmp_path: Path, explicit_session_cancel: bool
|
||||
) -> None:
|
||||
"""验证等待和运行中的生成器被关闭后释放任务槽,其他会话仍能完成自己的结果。"""
|
||||
sources = [tmp_path / f"session-{index}.csv" for index in range(3)]
|
||||
for source in sources:
|
||||
source.write_text("name,value\na,1\n", encoding="utf-8")
|
||||
|
||||
async def scenario() -> None:
|
||||
"""模拟两个会话取消后由第三个会话接续执行,不依赖真实服务等待。"""
|
||||
finish = asyncio.Event()
|
||||
calls: list[Path] = []
|
||||
canceled: list[Path] = []
|
||||
|
||||
class WaitingClient:
|
||||
"""按测试事件控制解析完成时机。"""
|
||||
|
||||
async def parse_file(self, path: Path, *, status_callback: Any, **_kwargs: Any) -> ParseResult:
|
||||
"""记录实际进入任务槽的会话,并传播本地取消。"""
|
||||
calls.append(path)
|
||||
status_callback("Processing on server...")
|
||||
try:
|
||||
await finish.wait()
|
||||
except asyncio.CancelledError:
|
||||
canceled.append(path)
|
||||
raise
|
||||
status_callback(STATUS_DOWNLOADING_RESULT)
|
||||
return ParseResult(middle_json=_middle_json(with_image=False))
|
||||
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash",), ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(WaitingClient(), capabilities, output_root=tmp_path / "output", enable_example=False)
|
||||
handler_fn = next(fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
assert handler_fn.concurrency_limit is None
|
||||
# 清除和文件切换都使用 Gradio 的会话级取消事件。
|
||||
cancellation_events = [dependency for dependency in demo.config["dependencies"] if dependency["cancels"]]
|
||||
assert len(cancellation_events) >= 2
|
||||
assert all(handler_fn._id in dependency["cancels"] for dependency in cancellation_events)
|
||||
|
||||
async def advance_until(stream: Any, marker: str) -> tuple[Any, ...]:
|
||||
"""推进生成器直到目标阶段,并给测试死锁设置明确超时。"""
|
||||
while True:
|
||||
update = await asyncio.wait_for(anext(stream), timeout=2)
|
||||
if marker in update[0]:
|
||||
return update
|
||||
|
||||
requests = [SimpleNamespace(session_hash=f"session-{index}") for index in range(3)]
|
||||
first, second, third = [
|
||||
handler_fn.fn(str(source), 0, "", request=request) for source, request in zip(sources, requests)
|
||||
]
|
||||
cancel_session = next(fn.fn for fn in demo.fns.values() if fn.name == "cancel_session_conversion")
|
||||
await advance_until(first, "Processing on server")
|
||||
queued = await advance_until(second, "Queued locally.")
|
||||
assert all(value == {"__type__": "update"} for value in queued[1:])
|
||||
assert calls == [sources[0]]
|
||||
if explicit_session_cancel:
|
||||
await cancel_session(requests[1])
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(second)
|
||||
else:
|
||||
await second.aclose()
|
||||
await advance_until(third, "Queued locally.")
|
||||
if explicit_session_cancel:
|
||||
await cancel_session(requests[0])
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(first)
|
||||
else:
|
||||
await first.aclose()
|
||||
await advance_until(third, "Processing on server")
|
||||
assert canceled == [sources[0]]
|
||||
assert calls == [sources[0], sources[2]]
|
||||
finish.set()
|
||||
updates = [update async for update in third]
|
||||
final = updates[-1]
|
||||
assert "Completed (" in final[0]
|
||||
assert final[8]["stem"] == sources[2].stem
|
||||
assert all(item["interactive"] is True for item in final[-6:])
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_gradio_output_failure_stops_timer_and_allows_next_conversion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证解析完成后的输出整理错误仍显示失败,并释放槽位供下一次转换使用。"""
|
||||
source = tmp_path / "source.csv"
|
||||
source.write_text("name,value\na,1\n", encoding="utf-8")
|
||||
|
||||
class CompletedClient:
|
||||
"""模拟已完成远端解析和下载的任务。"""
|
||||
|
||||
async def parse_file(self, _path: Path, *, status_callback: Any, **_kwargs: Any) -> ParseResult:
|
||||
"""按真实阶段通知后返回解析结果。"""
|
||||
status_callback("Processing on server...")
|
||||
status_callback(STATUS_DOWNLOADING_RESULT)
|
||||
return ParseResult(middle_json=_middle_json(with_image=False))
|
||||
|
||||
def fail_persistence(*_args: Any, **_kwargs: Any) -> Any:
|
||||
"""模拟产物磁盘写入失败。"""
|
||||
raise OSError("output disk unavailable")
|
||||
|
||||
monkeypatch.setattr(gradio_app, "persist_parse_result", fail_persistence)
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash",), ("zip",), ("file_id",))
|
||||
demo = build_gradio_app(CompletedClient(), capabilities, output_root=tmp_path, enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
|
||||
async def scenario() -> None:
|
||||
"""连续运行两次,验证异常分支没有泄漏执行槽。"""
|
||||
for _ in range(2):
|
||||
updates = [update async for update in handler(str(source), 0, "")]
|
||||
assert "Failed: output disk unavailable" in updates[-1][0]
|
||||
assert "is-error" in updates[-1][0]
|
||||
assert updates[-1][8] is None
|
||||
assert all(item["interactive"] is False for item in updates[-1][-6:])
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const downloads = [];
|
||||
global.window = {};
|
||||
global.document = {
|
||||
body: { appendChild() { /* 模拟临时下载链接挂载。 */ } },
|
||||
createElement() {
|
||||
// 记录真实脚本发起的下载,重复成功事件不得重复触发链接。
|
||||
return { click() { downloads.push([this.href, this.download]); }, remove() {} };
|
||||
},
|
||||
};
|
||||
const reduce = vm.runInThisContext(fs.readFileSync(path.join(__dirname, "../../mineru/resources/gradio_download.js"), "utf8"));
|
||||
const formats = [["zip", "ZIP"], ["html", "HTML"], ["docx", "DOCX"], ["latex", "LaTeX bundle"], ["epub", "EPUB"], ["pdf", "PDF"]];
|
||||
// 调用实际产品脚本,保持 DOM 替身只负责记录下载行为。
|
||||
const step = (action, format = "", ...args) => reduce(action, formats, format, formats.find(([name]) => name === format)?.[1], ...args);
|
||||
const receipt = (request, error = "") => JSON.stringify({ request, error });
|
||||
const file = { url: "/gradio_api/file=/cache/test.html", orig_name: "含 空格.html" };
|
||||
step("activate", "", "run-a");
|
||||
for (const [format, label] of formats) {
|
||||
for (let click = 0; click < 2; click++) {
|
||||
const [token, busy] = step("begin", format, "run-a");
|
||||
assert.equal(busy.interactive, false);
|
||||
assert.match(busy.value, /准备中/);
|
||||
assert.deepEqual(step("begin", format, "run-a"), [{ __type__: "update" }, { __type__: "update" }, { __type__: "update" }]);
|
||||
const count = downloads.length;
|
||||
const [ready, notice] = step("complete", format, file, receipt(token), "run-a");
|
||||
assert.equal(downloads.length, count + 1);
|
||||
assert.deepEqual(downloads.at(-1), [file.url, file.orig_name]);
|
||||
assert.equal(ready.value, label);
|
||||
assert.equal(ready.interactive, true);
|
||||
assert.equal(notice, "");
|
||||
step("complete", format, file, receipt(token), "run-a");
|
||||
assert.equal(downloads.length, count + 1);
|
||||
}
|
||||
}
|
||||
const [failed] = step("begin", "pdf", "run-a");
|
||||
const [restored, error] = step("complete", "pdf", null, receipt(failed, "<bad> & failed"), "run-a");
|
||||
assert.equal(restored.interactive, true);
|
||||
assert.ok(error.includes("<bad> & failed"));
|
||||
assert.ok(!error.includes("<bad>"));
|
||||
const [retry] = step("begin", "pdf", "run-a");
|
||||
const count = downloads.length;
|
||||
const reset = step("reset");
|
||||
assert.equal(reset[0], "");
|
||||
assert.deepEqual(reset.slice(1, 7), Array(6).fill(null));
|
||||
assert.ok(reset.slice(-7, -1).every(update => update.interactive === false));
|
||||
step("activate", "", "run-b");
|
||||
const [current] = step("begin", "pdf", "run-b");
|
||||
assert.deepEqual(step("busy", "pdf", retry, "run-b"), [{ __type__: "update" }, { __type__: "update" }]);
|
||||
assert.equal(step("busy", "pdf", current, "run-b")[0].interactive, false);
|
||||
assert.deepEqual(step("complete", "pdf", file, receipt(retry), "run-b"), [{ __type__: "update" }, { __type__: "update" }]);
|
||||
assert.equal(downloads.length, count);
|
||||
step("complete", "pdf", file, receipt(current), "run-b");
|
||||
assert.equal(downloads.length, count + 1);
|
||||
console.log("download lifecycle passed");
|
||||
@@ -0,0 +1,83 @@
|
||||
"""下载请求与前端首次点击、缓存、错误和过期响应的回归。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from mineru.kit.gradio import app as gradio_app
|
||||
from mineru.kit.gradio.artifacts import create_run_artifacts
|
||||
from mineru.kit.gradio.client import V1ServerCapabilities
|
||||
|
||||
|
||||
def test_download_receipt_keeps_request_on_success_and_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""真实结果标识校验覆盖正常生成、缓存复用及渲染异常,并在错误回执中保留请求标识。"""
|
||||
source = tmp_path / "source.pdf"
|
||||
source.write_bytes(b"test")
|
||||
artifacts = create_run_artifacts(source, tmp_path / "output")
|
||||
token = json.dumps({"run_id": artifacts.root.name, "sequence": 1})
|
||||
target = artifacts.downloads_dir / "source.html"
|
||||
render = Mock(return_value=str(target))
|
||||
monkeypatch.setattr(gradio_app, "render_download", render)
|
||||
handler = gradio_app._download_handler("html", tmp_path / "output")
|
||||
for sequence in (1, 2):
|
||||
token = json.dumps({"run_id": artifacts.root.name, "sequence": sequence})
|
||||
path, receipt = handler(artifacts.as_state(), token)
|
||||
assert path == str(target)
|
||||
assert json.loads(receipt) == {"request": token, "error": ""}
|
||||
render.side_effect = RuntimeError("render failed")
|
||||
path, receipt = handler(artifacts.as_state(), token)
|
||||
assert path is None
|
||||
assert json.loads(receipt) == {"request": token, "error": "render failed"}
|
||||
render.reset_mock()
|
||||
path, receipt = handler(artifacts.as_state(), json.dumps({"run_id": "old-run", "sequence": 3}))
|
||||
assert path is None and "已变更" in json.loads(receipt)["error"]
|
||||
render.assert_not_called()
|
||||
|
||||
|
||||
def test_download_event_chain_and_pdf_mount(tmp_path: Path) -> None:
|
||||
"""每种格式使用独立文件与成功事件,所有空 PDF 预览状态保持挂载。"""
|
||||
cap = V1ServerCapabilities("http://127.0.0.1:1", ("flash",), ("zip",), ("file_id",))
|
||||
app = gradio_app.build_gradio_app(Mock(), cap, output_root=tmp_path, enable_example=False)
|
||||
pdf = next(component for component in app.blocks.values() if component.__class__.__name__ == "PDF")
|
||||
assert pdf.visible == "hidden"
|
||||
preview = next(fn.fn for fn in app.fns.values() if fn.name == "update_file_preview")
|
||||
for source in (None, "photo.png", "book.docx", "book.epub"):
|
||||
assert preview(source)[0]["value"] is None
|
||||
assert preview(source)[0]["visible"] == "hidden"
|
||||
assert "mineru-kit-pdf-empty" in preview(source)[0]["elem_classes"]
|
||||
reset = next(fn.fn for fn in app.fns.values() if fn.name == "reset_ui")
|
||||
assert reset()[4]["visible"] == "hidden"
|
||||
assert reset()[9] == ""
|
||||
conversion = next(fn for fn in app.fns.values() if fn.name == "convert_handler")
|
||||
dependency = next(dep for dep in app.config["dependencies"] if dep["id"] == conversion._id)
|
||||
# 转换必须等待真正的重置完成事件,纯 JS 事件的 then 在支持版本中不会可靠触发。
|
||||
reset_event = app.fns[dependency["trigger_after"]]
|
||||
assert reset_event.name == "reset_download_ui"
|
||||
assert len(reset_event.fn()) == len(reset_event.outputs)
|
||||
handlers = [fn for fn in app.fns.values() if fn.name == "handler"]
|
||||
assert len(handlers) == 6
|
||||
files = []
|
||||
for handler in handlers:
|
||||
file, receipt = handler.outputs
|
||||
assert file.__class__.__name__ == "File" and file.visible is False
|
||||
files.append(file._id)
|
||||
success = next(dep for dep in app.config["dependencies"] if dep.get("trigger_after") == handler._id)
|
||||
assert success["trigger_only_on_success"] is True
|
||||
assert success["backend_fn"] is False and success["queue"] is False
|
||||
assert success["inputs"][:2] == [file._id, receipt._id]
|
||||
assert len(set(files)) == 6
|
||||
|
||||
|
||||
def test_frontend_download_lifecycle() -> None:
|
||||
"""运行真实前端脚本,验证恰好一次下载、失败重试和跨文档失效。"""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("Node.js is required for frontend state tests")
|
||||
result = subprocess.run([node, str(Path(__file__).with_suffix(".cjs"))], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,254 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const reduce = vm.runInThisContext(fs.readFileSync(path.join(__dirname, "../../mineru/resources/gradio_page_range.js"), "utf8"));
|
||||
const tiers = ["flash", "basic", "standard", "advanced"];
|
||||
// 使用 Python 测试传入的统一格式集合,测试和产品代码均不维护另一份后缀清单。
|
||||
const flashOnlyExtensions = JSON.parse(process.argv[2]);
|
||||
const tieredExtensions = JSON.parse(process.argv[3]);
|
||||
|
||||
// 模拟原生 Gradio 组件值与纯前端输出更新,不复制实际联动算法。
|
||||
function createUi(limit = 20, availableTiers = tiers) {
|
||||
const defaultTier = availableTiers.includes("standard") ? "standard" : availableTiers.at(-1);
|
||||
const state = {
|
||||
file: null, position: availableTiers.indexOf(defaultTier), metadata: {}, previous: {}, a: 1, b: 1,
|
||||
tierSelection: { tier: defaultTier, locked: false },
|
||||
};
|
||||
return {
|
||||
state,
|
||||
step(action, patch = {}) {
|
||||
// 应用本次输入事件,并保留 Gradio update 的跳过语义。
|
||||
Object.assign(state, patch);
|
||||
if (action === "clear") state.file = null;
|
||||
const result = reduce(
|
||||
availableTiers, flashOnlyExtensions, limit, state.file, state.position,
|
||||
JSON.stringify(state.metadata), JSON.stringify(state.previous), state.a, state.b,
|
||||
JSON.stringify(state.tierSelection)
|
||||
);
|
||||
if ("value" in result[0]) state.a = result[0].value;
|
||||
if ("value" in result[1]) state.b = result[1].value;
|
||||
if (result[4].__type__ !== "update") state.previous = JSON.parse(result[4]);
|
||||
if ("value" in result[7]) state.position = result[7].value;
|
||||
state.tierSelection = JSON.parse(result[9]);
|
||||
return result;
|
||||
},
|
||||
upload(count, filename = "/report.pdf") {
|
||||
// 上传和元数据完成分两次触发,以检查未完成阶段的提交限制。
|
||||
this.step("file", { file: { path: filename } });
|
||||
return this.step("metadata", { metadata: { path: filename, page_count: count, error: "" } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 所有轻量格式(含大写后缀)锁定 Flash;连续上传和残留输入不能覆盖原先选择。
|
||||
for (const extension of flashOnlyExtensions.flatMap((extension) => [extension, extension.toUpperCase()])) {
|
||||
const ui = createUi();
|
||||
ui.step("tier", { position: 3 });
|
||||
const out = ui.step("file", { file: { path: `/source.${extension}` } });
|
||||
assert.equal(out[7].value, 0);
|
||||
assert.equal(out[7].interactive, false);
|
||||
assert.equal(out[8], "解析 tier:flash");
|
||||
assert.equal(out[5].interactive, true);
|
||||
assert.equal(out[3], "");
|
||||
assert.match(out[2], /data-range-visible="false"/);
|
||||
assert.deepEqual(ui.state.tierSelection, { tier: "advanced", locked: true });
|
||||
// 即使模拟一个已经排队的高档位 input,也不能改变锁定值或恢复偏好。
|
||||
assert.equal(ui.step("tier", { position: 1 })[7].value, 0);
|
||||
ui.step("file", { file: `/another.${extension}` });
|
||||
assert.equal(ui.state.tierSelection.tier, "advanced");
|
||||
const cleared = ui.step("clear");
|
||||
assert.equal(cleared[7].value, 3);
|
||||
assert.equal(cleared[7].interactive, true);
|
||||
assert.equal(cleared[8], "解析 tier:advanced");
|
||||
assert.equal(cleared[5].interactive, false);
|
||||
}
|
||||
|
||||
// 切回任意 PDF/图片后在同一次事件恢复档位,PDF 等待页数、图片可立即转换。
|
||||
for (const extension of tieredExtensions.flatMap((extension) => [extension, extension.toUpperCase()])) {
|
||||
const ui = createUi();
|
||||
ui.step("file", { file: "/source.csv" });
|
||||
const out = ui.step("file", { file: { path: `/source.${extension}` } });
|
||||
assert.equal(out[7].value, 2);
|
||||
assert.equal(out[7].interactive, true);
|
||||
assert.equal(out[8], "解析 tier:standard");
|
||||
assert.equal(out[5].interactive, extension.toLowerCase() !== "pdf");
|
||||
assert.deepEqual(ui.state.tierSelection, { tier: "standard", locked: false });
|
||||
}
|
||||
|
||||
// 缺少 Flash 不伪造滑杆位置,始终阻止提交;恢复可分档文件后沿用原先能力和选择。
|
||||
for (const availableTiers of [["basic", "standard", "advanced"], ["advanced"], ["flash"], ["flash", "advanced"]]) {
|
||||
const ui = createUi(20, availableTiers);
|
||||
const originalPosition = ui.state.position;
|
||||
const available = availableTiers.includes("flash");
|
||||
const out = ui.step("file", { file: "/source.csv" });
|
||||
assert.equal(out[7].interactive, false);
|
||||
assert.equal(out[7].value, available ? availableTiers.indexOf("flash") : originalPosition);
|
||||
assert.equal(out[5].interactive, available);
|
||||
assert.equal(out[6].visible, !available);
|
||||
if (!available) {
|
||||
assert.equal(out[6].value, "该格式仅支持 Flash,当前服务不可用");
|
||||
assert.match(out[8], /flash.*当前服务不可用/);
|
||||
}
|
||||
const restored = ui.step("file", { file: "/source.png" });
|
||||
assert.equal(restored[7].value, originalPosition);
|
||||
assert.equal(restored[7].interactive, availableTiers.length > 1);
|
||||
assert.equal(restored[5].interactive, true);
|
||||
assert.equal(restored[6].visible, false);
|
||||
}
|
||||
|
||||
// 文件连续切换与旧 PDF 元数据晚到时,锁定状态和偏好只跟随当前文件。
|
||||
{
|
||||
const ui = createUi();
|
||||
ui.upload(100);
|
||||
ui.step("tier", { position: 3 });
|
||||
ui.step("file", { file: "/source.csv" });
|
||||
ui.step("metadata", { metadata: { path: "/report.pdf", page_count: 100, error: "" } });
|
||||
assert.equal(ui.state.position, 0);
|
||||
assert.equal(ui.state.tierSelection.tier, "advanced");
|
||||
let out = ui.step("file", { file: "/new.pdf" });
|
||||
assert.equal(out[7].value, 3);
|
||||
assert.equal(out[5].interactive, false);
|
||||
assert.match(out[6].value, /正在读取/);
|
||||
out = ui.step("metadata", { metadata: { path: "/report.pdf", page_count: 100, error: "" } });
|
||||
assert.equal(out[5].interactive, false);
|
||||
out = ui.step("metadata", { metadata: { path: "/new.pdf", page_count: 12, error: "" } });
|
||||
assert.equal(out[5].interactive, true);
|
||||
assert.equal(out[3], "1-12");
|
||||
ui.step("tier", { position: 1 });
|
||||
ui.step("file", { file: "/second.csv" });
|
||||
assert.equal(ui.step("clear")[7].value, 1);
|
||||
// 会话互不共享偏好。
|
||||
assert.equal(createUi().state.tierSelection.tier, "standard");
|
||||
}
|
||||
|
||||
// 同时检查组件身份、语义角色、提交范围及无障碍标签,不能只验证排序后的数字。
|
||||
function assertSelection(result, a, b, startHandle) {
|
||||
const state = JSON.parse(result[4]);
|
||||
const start = Math.min(a, b);
|
||||
const end = Math.max(a, b);
|
||||
assert.equal(result[0].value, a);
|
||||
assert.equal(result[1].value, b);
|
||||
assert.equal(state.handle_a, a);
|
||||
assert.equal(state.handle_b, b);
|
||||
assert.equal(state.start_handle, startHandle);
|
||||
assert.equal(result[0].label, startHandle === "a" ? "起始页" : "结束页");
|
||||
assert.equal(result[1].label, startHandle === "b" ? "起始页" : "结束页");
|
||||
assert.equal(result[3], start === end ? String(start) : `${start}-${end}`);
|
||||
assert(result[2].includes(`[${start}-${end}] · ${end - start + 1} 页`));
|
||||
assert(result[2].includes(`data-start-handle="${startHandle}"`));
|
||||
}
|
||||
|
||||
let ui = createUi();
|
||||
assert.match(ui.step("file")[2], /data-range-visible="false"/);
|
||||
let out = ui.step("file", { file: { path: "/report.pdf" } });
|
||||
assert.equal(out[5].interactive, false);
|
||||
assert.match(out[6].value, /正在读取/);
|
||||
out = ui.upload(100);
|
||||
assert.equal(out[0].minimum, 1);
|
||||
assert.equal(out[1].maximum, 100);
|
||||
assert.equal(out[3], "1-20");
|
||||
for (const [action, value, expected] of [
|
||||
["b", 40, "21-40"], ["a", 15, "15-34"], ["a", 20, "20-34"], ["b", 35, "20-35"]
|
||||
]) {
|
||||
assert.equal(ui.step(action, { [action]: value })[3], expected);
|
||||
}
|
||||
|
||||
// 原左滑块保持 A 身份连续穿越:20→40→60→30,超限时推动 B 而不是交换物理值。
|
||||
assertSelection(ui.step("a", { a: 40 }), 40, 35, "b");
|
||||
assertSelection(ui.step("a", { a: 60 }), 60, 41, "b");
|
||||
const crossedState = { ...ui.state.previous };
|
||||
assert.equal(ui.step("tier", { position: 0 })[3], "");
|
||||
assert.match(ui.step("tier", { position: 0 })[2], /data-range-visible="false"/);
|
||||
assert.deepEqual(ui.state.previous, crossedState);
|
||||
assertSelection(ui.step("tier", { position: 3 }), 60, 41, "b");
|
||||
assertSelection(ui.step("a", { a: 30 }), 30, 41, "a");
|
||||
|
||||
// 两侧接近相等时保留旧角色,只有严格越过才交换;反复穿越也不改变组件身份。
|
||||
assertSelection(ui.step("a", { a: 41 }), 41, 41, "a");
|
||||
assertSelection(ui.step("a", { a: 42 }), 42, 41, "b");
|
||||
assertSelection(ui.step("a", { a: 41 }), 41, 41, "b");
|
||||
assertSelection(ui.step("a", { a: 40 }), 40, 41, "a");
|
||||
assertSelection(ui.step("b", { b: 40 }), 40, 40, "a");
|
||||
assertSelection(ui.step("b", { b: 39 }), 40, 39, "b");
|
||||
assertSelection(ui.step("b", { b: 40 }), 40, 40, "b");
|
||||
|
||||
// 原右滑块保持 B 身份连续穿越:55→30→10,左边的 B 超限时把 A 向左推。
|
||||
ui = createUi();
|
||||
ui.upload(100);
|
||||
ui.step("b", { b: 55 });
|
||||
assertSelection(ui.step("a", { a: 40 }), 40, 55, "a");
|
||||
assertSelection(ui.step("b", { b: 30 }), 40, 30, "b");
|
||||
assertSelection(ui.step("b", { b: 10 }), 29, 10, "b");
|
||||
assertSelection(ui.step("a", { a: -500 }), 1, 10, "a");
|
||||
assertSelection(ui.step("b", { b: 500 }), 81, 100, "a");
|
||||
|
||||
// 换文件后旧页数响应不能覆盖新状态,已缓存页数不受晚到响应污染。
|
||||
ui.step("file", { file: { path: "/new.pdf" } });
|
||||
out = ui.step("metadata", { metadata: { path: "/report.pdf", page_count: 100 } });
|
||||
assert.equal(out[5].interactive, false);
|
||||
assert.equal(JSON.parse(out[4]).page_count, 0);
|
||||
out = ui.upload(12, "/new.pdf");
|
||||
assert.equal(out[3], "1-12");
|
||||
assertSelection(out, 1, 12, "a");
|
||||
ui.step("metadata", { metadata: { path: "/report.pdf", page_count: 100 } });
|
||||
assert.equal(ui.step("tier", { position: 2 })[3], "1-12");
|
||||
out = ui.step("file", { file: { path: "/sample.csv" } });
|
||||
assert.match(out[2], /data-range-visible="false"/);
|
||||
assert.equal(out[3], "");
|
||||
assert.equal(out[5].interactive, true);
|
||||
out = ui.step("clear");
|
||||
assert.equal(out[5].interactive, false);
|
||||
assert.equal(JSON.parse(out[4]).path, "");
|
||||
assert.equal(JSON.parse(out[4]).start_handle, "a");
|
||||
|
||||
// 不限页数时跨越不能移动对端;上限为 1 时两个实体一起移动并保留重合角色。
|
||||
ui = createUi(null);
|
||||
ui.upload(100);
|
||||
ui.step("b", { b: 35 });
|
||||
assertSelection(ui.step("a", { a: 100 }), 100, 35, "b");
|
||||
assertSelection(ui.step("b", { b: 1 }), 100, 1, "b");
|
||||
ui = createUi(1);
|
||||
assertSelection(ui.upload(100), 1, 1, "a");
|
||||
assertSelection(ui.step("a", { a: 50 }), 50, 50, "a");
|
||||
assertSelection(ui.step("b", { b: 10 }), 10, 10, "a");
|
||||
|
||||
// 文件错误只阻止非 Flash;错误消息须转义,不能成为 HTML 注入入口。
|
||||
ui = createUi();
|
||||
ui.step("file", { file: { path: "/bad.pdf" } });
|
||||
out = ui.step("metadata", { metadata: { path: "/bad.pdf", page_count: 0, error: "<bad>" } });
|
||||
assert.equal(out[5].interactive, false);
|
||||
assert.equal(out[6].value, "<bad>");
|
||||
out = ui.step("tier", { position: 0 });
|
||||
assert.equal(out[5].interactive, true);
|
||||
assert.equal(out[6].visible, false);
|
||||
|
||||
// 确定性遍历总页数和上限:物理值允许倒序,排序后的范围、角色与上限必须一致。
|
||||
for (const count of [1, 12, 20, 100]) {
|
||||
for (const limit of [null, 1, 20, 250]) {
|
||||
ui = createUi(limit);
|
||||
out = ui.upload(count);
|
||||
assert.equal(ui.state.b, Math.min(count, limit ?? count));
|
||||
assert.equal(out[1].interactive, count > 1);
|
||||
let seed = 7;
|
||||
for (let step = 0; step < 1000; step++) {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
const action = step % 2 ? "a" : "b";
|
||||
const value = seed % (count + 40) - 20;
|
||||
const other = action === "a" ? "b" : "a";
|
||||
const previousOther = ui.state[other];
|
||||
const previousRole = ui.state.previous.start_handle;
|
||||
const wanted = Math.min(count, Math.max(1, value));
|
||||
out = ui.step(action, { [action]: value });
|
||||
const { a, b } = ui.state;
|
||||
assert(a >= 1 && a <= count && b >= 1 && b <= count);
|
||||
assert.equal(ui.state[action], wanted);
|
||||
assert(limit === null || Math.abs(a - b) + 1 <= limit);
|
||||
if (limit === null || Math.abs(wanted - previousOther) + 1 <= limit) {
|
||||
assert.equal(ui.state[other], previousOther);
|
||||
}
|
||||
assertSelection(out, a, b, a < b ? "a" : b < a ? "b" : previousRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("Page range frontend cases passed (including 16000 boundary transitions).");
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Gradio 双滑块的页数预检、请求约束和前端状态回归。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pypdfium2 as pdfium
|
||||
import pytest
|
||||
from pypdf import PdfWriter
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mineru.errors import InvalidRequestError
|
||||
from mineru.filetypes import FLASH_ONLY_PARSE_EXTENSIONS, TIERED_PARSE_EXTENSIONS
|
||||
from mineru.kit.gradio import app as gradio_app
|
||||
from mineru.kit.gradio import page_range as ranges
|
||||
from mineru.kit.gradio.client import V1ArtifactError, V1ServerCapabilities
|
||||
from mineru.kit.main import app
|
||||
from mineru.model.flash.pdf.pdfium import _pdfium_lock
|
||||
|
||||
|
||||
def _pdf(tmp_path: Path, page_count: int = 100, *, encrypted: bool = False) -> Path:
|
||||
"""生成测试用 PDF,可选密码保护及空文档。"""
|
||||
writer = PdfWriter()
|
||||
for _ in range(page_count):
|
||||
writer.add_blank_page(width=200, height=300)
|
||||
if encrypted:
|
||||
writer.encrypt("required-password")
|
||||
path = tmp_path / "report.PDF"
|
||||
writer.write(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("args", "expected"), [([], None), (["--max-pages", "20"], 20)])
|
||||
def test_command_forwards_max_pages(monkeypatch: pytest.MonkeyPatch, args: list[str], expected: int | None) -> None:
|
||||
"""验证省略或显式配置上限时的启动参数透传。"""
|
||||
launch = Mock()
|
||||
monkeypatch.setattr(gradio_app, "launch_gradio", launch)
|
||||
result = CliRunner().invoke(app, ["gradio", *args])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert launch.call_args.kwargs["max_pages"] == expected
|
||||
assert "--max-pages" in CliRunner().invoke(app, ["gradio", "--help"]).output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "1.5", "invalid"])
|
||||
def test_command_rejects_invalid_max_pages(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
||||
"""验证非正整数配置在启动任何服务前报错。"""
|
||||
launch = Mock()
|
||||
monkeypatch.setattr(gradio_app, "launch_gradio", launch)
|
||||
result = CliRunner().invoke(app, ["gradio", "--max-pages", value])
|
||||
assert result.exit_code != 0
|
||||
launch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, -1, 1.5, True, "20"])
|
||||
def test_programmatic_max_pages_validation(value: object) -> None:
|
||||
"""验证直接构建或启动界面也不能绕过整数配置检查。"""
|
||||
with pytest.raises(ValueError, match="positive integer"):
|
||||
ranges.validate_max_pages(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_launch_forwards_limit_and_versioned_assets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""验证外部 API 已有 Flash 时不启动本地服务,并传入页数上限和资源参数。"""
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash", "standard"), ("zip",), ("file_id",))
|
||||
client = SimpleNamespace(discover=AsyncMock(return_value=capabilities), capabilities=capabilities)
|
||||
demo = SimpleNamespace(launch=Mock(), _mineru_kit_launch_kwargs={"css": "test-css", "js": "test-js"})
|
||||
build = Mock(return_value=demo)
|
||||
managed_server = Mock()
|
||||
monkeypatch.setattr(gradio_app, "ManagedLocalApiServer", managed_server)
|
||||
monkeypatch.setattr(gradio_app, "V1ArtifactClient", Mock(return_value=client))
|
||||
monkeypatch.setattr(gradio_app, "build_gradio_app", build)
|
||||
gradio_app.launch_gradio(
|
||||
api_url="http://127.0.0.1:1",
|
||||
api_key=None,
|
||||
server_name="127.0.0.1",
|
||||
server_port=None,
|
||||
output_dir=str(tmp_path),
|
||||
enable_example=False,
|
||||
enable_api=True,
|
||||
latex_delimiters_type="all",
|
||||
api_server_tier="standard",
|
||||
api_server_concurrency=1,
|
||||
api_server_language="ch",
|
||||
api_server_disable_image_analysis=False,
|
||||
api_server_preload_models=False,
|
||||
max_pages=20,
|
||||
)
|
||||
assert build.call_args.kwargs["max_pages"] == 20
|
||||
assert demo.launch.call_args.kwargs["css"] == "test-css"
|
||||
assert demo.launch.call_args.kwargs["js"] == "test-js"
|
||||
managed_server.assert_not_called()
|
||||
|
||||
|
||||
def test_launch_forwards_managed_options_and_stops_server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""验证无远程地址时托管 API server 沿用配置、不禁用档位并在退出时停止。"""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeManagedLocalApiServer:
|
||||
"""记录托管 API server 的构造参数和生命周期。"""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
"""保存 Gradio 向托管进程传入的配置。"""
|
||||
captured["options"] = kwargs
|
||||
|
||||
def start(self) -> str:
|
||||
"""返回测试用 API 地址。"""
|
||||
captured["started"] = True
|
||||
return "http://127.0.0.1:18000"
|
||||
|
||||
def stop(self) -> None:
|
||||
"""记录启动器执行了托管进程清理。"""
|
||||
captured["stopped"] = True
|
||||
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:18000", ("flash", "standard"), ("zip",), ("file_id",))
|
||||
client = SimpleNamespace(discover=AsyncMock(return_value=capabilities), capabilities=capabilities)
|
||||
demo = SimpleNamespace(launch=Mock(), _mineru_kit_launch_kwargs={})
|
||||
monkeypatch.setattr(gradio_app, "ManagedLocalApiServer", FakeManagedLocalApiServer)
|
||||
monkeypatch.setattr(gradio_app, "V1ArtifactClient", Mock(return_value=client))
|
||||
monkeypatch.setattr(gradio_app, "build_gradio_app", Mock(return_value=demo))
|
||||
|
||||
gradio_app.launch_gradio(
|
||||
api_url=None,
|
||||
api_key=None,
|
||||
server_name="127.0.0.1",
|
||||
server_port=None,
|
||||
output_dir=str(tmp_path),
|
||||
enable_example=False,
|
||||
enable_api=True,
|
||||
latex_delimiters_type="all",
|
||||
api_server_tier="standard",
|
||||
api_server_concurrency=2,
|
||||
api_server_language="ch",
|
||||
api_server_disable_image_analysis=False,
|
||||
api_server_preload_models=False,
|
||||
)
|
||||
|
||||
assert captured["options"] == {
|
||||
"tier": "standard",
|
||||
"concurrency": 2,
|
||||
"language": "ch",
|
||||
"disable_image_analysis": False,
|
||||
"preload_models": False,
|
||||
"api_key": None,
|
||||
}
|
||||
assert captured["started"] is True
|
||||
assert captured["stopped"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote_tiers", [("basic", "standard"), ("basic", "standard", "advanced"), ("flash", "standard")])
|
||||
@pytest.mark.parametrize("failure_stage", [None, "remote", "start", "local", "missing_flash", "build", "launch"])
|
||||
def test_launch_remote_flash_fallback_and_cleanup(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, remote_tiers: tuple[str, ...], failure_stage: str | None
|
||||
) -> None:
|
||||
"""验证能力发现决定是否补充 Flash,且发现、启动、构建和运行失败均清理托管进程。"""
|
||||
needs_local = "flash" not in remote_tiers
|
||||
if not needs_local and failure_stage in {"start", "local", "missing_flash"}:
|
||||
pytest.skip("远程已有 Flash 时不进入本地服务阶段")
|
||||
events: list[str] = []
|
||||
failure = V1ArtifactError("startup failure", code="server_start_failed")
|
||||
remote_cap = V1ServerCapabilities("http://remote.test", remote_tiers, ("zip",), ("file_id",))
|
||||
local_tiers = ("basic",) if failure_stage == "missing_flash" else ("flash",)
|
||||
local_cap = V1ServerCapabilities("http://local.test", local_tiers, ("zip",), ("file_id",))
|
||||
|
||||
async def discover_remote() -> V1ServerCapabilities:
|
||||
"""记录主服务完成能力发现的时间点。"""
|
||||
events.append("remote")
|
||||
if failure_stage == "remote":
|
||||
raise failure
|
||||
return remote_cap
|
||||
|
||||
async def discover_local() -> V1ServerCapabilities:
|
||||
"""记录本地服务完成能力发现的时间点。"""
|
||||
events.append("local")
|
||||
if failure_stage == "local":
|
||||
raise failure
|
||||
return local_cap
|
||||
|
||||
def start() -> str:
|
||||
"""记录本地进程启动,并可模拟启动失败。"""
|
||||
events.append("start")
|
||||
if failure_stage == "start":
|
||||
raise failure
|
||||
return local_cap.base_url
|
||||
|
||||
remote = SimpleNamespace(capabilities=remote_cap, discover=AsyncMock(side_effect=discover_remote))
|
||||
local = SimpleNamespace(capabilities=local_cap, discover=AsyncMock(side_effect=discover_local))
|
||||
factory = Mock(side_effect=[remote, local])
|
||||
server = Mock(start=Mock(side_effect=start))
|
||||
server_factory = Mock(return_value=server)
|
||||
demo = SimpleNamespace(launch=Mock(), _mineru_kit_launch_kwargs={})
|
||||
build = Mock(return_value=demo)
|
||||
if failure_stage == "build":
|
||||
build.side_effect = failure
|
||||
if failure_stage == "launch":
|
||||
demo.launch.side_effect = failure
|
||||
monkeypatch.setenv("MINERU_API_KEY", "remote-secret")
|
||||
monkeypatch.setattr(gradio_app, "V1ArtifactClient", factory)
|
||||
monkeypatch.setattr(gradio_app, "ManagedLocalApiServer", server_factory)
|
||||
monkeypatch.setattr(gradio_app, "build_gradio_app", build)
|
||||
options = {
|
||||
"api_url": remote_cap.base_url,
|
||||
"api_key": None,
|
||||
"server_name": "127.0.0.1",
|
||||
"server_port": None,
|
||||
"output_dir": str(tmp_path),
|
||||
"enable_example": False,
|
||||
"enable_api": True,
|
||||
"latex_delimiters_type": "all",
|
||||
"api_server_tier": "standard",
|
||||
"api_server_concurrency": 2,
|
||||
"api_server_language": "en",
|
||||
"api_server_disable_image_analysis": True,
|
||||
"api_server_preload_models": True,
|
||||
}
|
||||
if failure_stage is not None:
|
||||
with pytest.raises(V1ArtifactError):
|
||||
gradio_app.launch_gradio(**options)
|
||||
else:
|
||||
gradio_app.launch_gradio(**options)
|
||||
expected = tuple(tier for tier in ("flash", "basic", "standard", "advanced") if tier in (*remote_tiers, "flash"))
|
||||
assert build.call_args.args[1].tiers == expected
|
||||
assert remote.capabilities is remote_cap
|
||||
assert factory.call_args_list[0].kwargs == {"api_url": remote_cap.base_url, "api_key": "remote-secret"}
|
||||
if needs_local and failure_stage != "remote":
|
||||
server_factory.assert_called_once_with(
|
||||
tier="flash", concurrency=2, language="en", disable_image_analysis=True, preload_models=True, api_key=""
|
||||
)
|
||||
server.stop.assert_called_once()
|
||||
assert events[:2] == ["remote", "start"]
|
||||
if failure_stage != "start":
|
||||
assert events == ["remote", "start", "local"]
|
||||
assert factory.call_args_list[1].kwargs == {"api_url": local_cap.base_url, "api_key": ""}
|
||||
else:
|
||||
server_factory.assert_not_called()
|
||||
assert events == ["remote"]
|
||||
if failure_stage in {"remote", "start", "local", "missing_flash", "build"}:
|
||||
demo.launch.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 12, 20, 100])
|
||||
def test_pdf_metadata_and_default_range(tmp_path: Path, count: int) -> None:
|
||||
"""验证真实页数、大小写后缀和不足上限时的默认选区。"""
|
||||
source = _pdf(tmp_path, count)
|
||||
assert ranges.pdf_page_metadata(str(source)) == {"path": str(source), "page_count": count, "error": ""}
|
||||
expected = "1" if count == 1 else f"1-{min(count, 20)}"
|
||||
assert ranges.effective_page_range(source, "", tier="standard", max_pages=20) == expected
|
||||
assert ranges.effective_page_range(source, "", tier="standard") == ""
|
||||
assert ranges.effective_page_range(source, "all", tier="standard") == "all"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"), [("20-35", "20-35"), (" 1, 1-20 ", "1-20"), ("r20-r1", "r20-r1"), ("95-200", "95-200")]
|
||||
)
|
||||
def test_existing_range_syntax_and_unique_page_count(tmp_path: Path, raw: str, expected: str) -> None:
|
||||
"""保留既有语法,按求值后的去重页数限制,不按字符串跨度计数。"""
|
||||
source = _pdf(tmp_path)
|
||||
assert ranges.effective_page_range(source, raw, tier="standard", max_pages=20) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["all", "1-21", "r30-r1", "1-10,90-100", "999", "20-1", "~", "0"])
|
||||
def test_invalid_or_oversized_range_is_rejected_before_submission(tmp_path: Path, raw: str) -> None:
|
||||
"""验证直接调用 Gradio 转换事件也无法提交超限或非法范围。"""
|
||||
source = _pdf(tmp_path)
|
||||
client = SimpleNamespace(parse_file=AsyncMock())
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash", "standard"), ("zip",), ("file_id",))
|
||||
demo = gradio_app.build_gradio_app(client, capabilities, output_root=tmp_path, max_pages=20, enable_example=False)
|
||||
handler = next(fn.fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
|
||||
async def collect() -> list[tuple[object, ...]]:
|
||||
"""收集错误事件并确认没有启动实际解析。"""
|
||||
return [update async for update in handler(str(source), 1, raw)]
|
||||
|
||||
updates = asyncio.run(collect())
|
||||
assert "page_range_invalid" in str(updates[-1][0])
|
||||
assert updates[-1][8] is None
|
||||
client.parse_file.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "tier"), [("missing.pdf", "flash"), ("report.docx", "standard"), ("image.png", "advanced"), (None, "basic")]
|
||||
)
|
||||
def test_whole_document_modes_never_read_pdf_metadata(monkeypatch: pytest.MonkeyPatch, path: str | None, tier: str) -> None:
|
||||
"""Flash、非 PDF 和空输入直接清空范围,不因残留的坏页码而失败。"""
|
||||
read_count = Mock(side_effect=AssertionError("unexpected PDF access"))
|
||||
monkeypatch.setattr(ranges, "read_pdf_page_count", read_count)
|
||||
assert ranges.effective_page_range(path, "bad range", tier=tier, max_pages=1) == "" # type: ignore[arg-type]
|
||||
read_count.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["broken", "encrypted", "empty", "missing"])
|
||||
def test_unreadable_pdf_is_not_treated_as_all_pages(tmp_path: Path, kind: str) -> None:
|
||||
"""损坏、加密、空页和丢失文件均反馈错误,不能降级为全部解析。"""
|
||||
source = tmp_path / "report.PDF"
|
||||
if kind == "broken":
|
||||
source.write_bytes(b"not a PDF")
|
||||
elif kind != "missing":
|
||||
source = _pdf(tmp_path, 0 if kind == "empty" else 1, encrypted=kind == "encrypted")
|
||||
metadata = ranges.pdf_page_metadata(str(source))
|
||||
assert metadata["path"] == str(source)
|
||||
assert metadata["page_count"] == 0
|
||||
assert "无法读取 PDF 页数" in metadata["error"]
|
||||
with pytest.raises(InvalidRequestError, match="无法读取 PDF 页数"):
|
||||
ranges.effective_page_range(source, "", tier="standard", max_pages=20)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail", [False, True])
|
||||
def test_pdfium_access_is_locked_and_document_is_closed(monkeypatch: pytest.MonkeyPatch, fail: bool) -> None:
|
||||
"""验证页数读取和异常路径都持有共享锁并且恰好关闭文档一次。"""
|
||||
closed: list[bool] = []
|
||||
|
||||
class Document:
|
||||
"""检查文档生命周期内的锁状态。"""
|
||||
|
||||
def __init__(self, _path: str) -> None:
|
||||
"""确认打开时已持有共享锁。"""
|
||||
assert _pdfium_lock._is_owned()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""模拟正常页数或读取页数失败。"""
|
||||
assert _pdfium_lock._is_owned()
|
||||
if fail:
|
||||
raise RuntimeError("count failed")
|
||||
return 10
|
||||
|
||||
def close(self) -> None:
|
||||
"""确认清理时仍然持有共享锁。"""
|
||||
closed.append(_pdfium_lock._is_owned())
|
||||
|
||||
monkeypatch.setattr(pdfium, "PdfDocument", Document)
|
||||
if fail:
|
||||
with pytest.raises(InvalidRequestError):
|
||||
ranges.read_pdf_page_count("report.pdf")
|
||||
else:
|
||||
assert ranges.read_pdf_page_count("report.pdf") == 10
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_metadata_does_not_read_non_pdf(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""非 PDF 和清除文件不触发 PDFium 读取。"""
|
||||
count = Mock()
|
||||
monkeypatch.setattr(ranges, "read_pdf_page_count", count)
|
||||
assert ranges.pdf_page_metadata(None) == {"path": "", "page_count": 0, "error": ""}
|
||||
assert ranges.pdf_page_metadata("report.docx")["page_count"] == 0
|
||||
count.assert_not_called()
|
||||
|
||||
|
||||
def test_native_range_components_and_frontend_only_events(tmp_path: Path) -> None:
|
||||
"""双滑块联动与档位切换不请求 Python,公开转换输入在页码之后增加 OCR 开关。"""
|
||||
capabilities = V1ServerCapabilities("http://127.0.0.1:1", ("flash", "standard"), ("zip",), ("file_id",))
|
||||
demo = gradio_app.build_gradio_app(Mock(), capabilities, output_root=tmp_path, max_pages=20, enable_example=False)
|
||||
sliders = [block for block in demo.blocks.values() if block.__class__.__name__ == "Slider"]
|
||||
assert len(sliders) == 3
|
||||
assert sliders[1].elem_classes == ["mineru-page-handle-a"]
|
||||
assert sliders[2].elem_classes == ["mineru-page-handle-b"]
|
||||
for slider in sliders[1:]:
|
||||
assert slider.step == 1 and slider.precision == 0
|
||||
events = [event for event in demo.config["dependencies"] if (slider._id, "input") in event["targets"]]
|
||||
assert len(events) == 1 and events[0]["backend_fn"] is False and events[0]["queue"] is False
|
||||
assert events[0]["trigger_mode"] == "always_last"
|
||||
assert len(events[0]["targets"]) == 5
|
||||
assert events[0]["outputs"][:2] == [sliders[1]._id, sliders[2]._id]
|
||||
handler = next(fn for fn in demo.fns.values() if fn.name == "convert_handler")
|
||||
assert len(handler.inputs) == 4
|
||||
assert handler.inputs[2].__class__.__name__ == "Textbox" and handler.inputs[2].visible is False
|
||||
assert handler.inputs[3].__class__.__name__ == "Checkbox" and handler.inputs[3].value is False
|
||||
metadata_handler = next(fn for fn in demo.fns.values() if fn.name == "read_page_metadata")
|
||||
assert metadata_handler.outputs[0].__class__.__name__ == "Textbox"
|
||||
metadata = json.loads(metadata_handler.fn(str(_pdf(tmp_path, 12))))
|
||||
assert metadata["page_count"] == 12 and metadata["error"] == ""
|
||||
load_events = [event for event in demo.config["dependencies"] if any(target[1] == "load" for target in event["targets"])]
|
||||
assert any(event["js"] == demo._mineru_kit_js and not event["backend_fn"] for event in load_events)
|
||||
import gradio as gr
|
||||
|
||||
if gradio_app._gradio_major_version(gr) >= 6:
|
||||
assert set(demo._mineru_kit_launch_kwargs) == {"css", "js"}
|
||||
else:
|
||||
assert demo._mineru_kit_launch_kwargs == {}
|
||||
assert demo.css and demo.js
|
||||
|
||||
|
||||
def test_frontend_page_range_state_machine() -> None:
|
||||
"""通过 Node 执行实际前端脚本,覆盖联动、竞态、键盘等共享状态转换。"""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("Node.js is required for frontend state tests")
|
||||
result = subprocess.run(
|
||||
[
|
||||
node,
|
||||
str(Path(__file__).with_suffix(".cjs")),
|
||||
json.dumps(sorted(FLASH_ONLY_PARSE_EXTENSIONS)),
|
||||
json.dumps(sorted(TIERED_PARSE_EXTENSIONS)),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,106 @@
|
||||
"""步骤卡片的计时、状态生命周期与安全文本回归。"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
|
||||
import pytest
|
||||
|
||||
from mineru.kit.gradio.status import (
|
||||
STATUS_COMPLETED,
|
||||
STATUS_DOWNLOADING_RESULT,
|
||||
STATUS_PROCESSING_ON_SERVER,
|
||||
STATUS_PROCESSING_OUTPUT,
|
||||
STATUS_QUEUED_LOCALLY,
|
||||
STATUS_QUEUED_ON_SERVER,
|
||||
StatusPanelState,
|
||||
status_html,
|
||||
stream_status_updates,
|
||||
)
|
||||
|
||||
|
||||
def test_processing_clock_excludes_queue_and_download_and_keeps_completed_duration() -> None:
|
||||
"""模拟长时间排队和下载,确保完成耗时仅保留两次真实运行通知之间的时间。"""
|
||||
now = [0.0]
|
||||
state = StatusPanelState(clock=lambda: now[0])
|
||||
state.append(STATUS_QUEUED_ON_SERVER)
|
||||
now[0] = 100.0
|
||||
state.append(STATUS_PROCESSING_ON_SERVER)
|
||||
assert state.refresh_interval == 0.1
|
||||
now[0] = 101.2
|
||||
assert "Processing on server (1.2s)" in state.render()
|
||||
assert not state.append(STATUS_PROCESSING_ON_SERVER)
|
||||
now[0] = 104.6
|
||||
state.append(STATUS_DOWNLOADING_RESULT, at=104.5)
|
||||
assert state.refresh_interval is None
|
||||
assert state.processing_elapsed == pytest.approx(4.5)
|
||||
assert "status-step is-active" in state.render()
|
||||
assert state.render().count("status-step is-done") == 5
|
||||
now[0] = 200.0
|
||||
state.append(STATUS_PROCESSING_OUTPUT)
|
||||
state.append(STATUS_COMPLETED)
|
||||
assert "Completed (4.5s)" in state.render()
|
||||
assert state.render().count("status-step is-done") == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", [STATUS_QUEUED_LOCALLY, STATUS_QUEUED_ON_SERVER])
|
||||
def test_queue_animation_cycles_without_restarting_on_repeated_status(message: str) -> None:
|
||||
"""验证本地和远端队列都按每秒一个圆点循环,重复轮询不重置动画。"""
|
||||
now = [5.0]
|
||||
state = StatusPanelState(clock=lambda: now[0])
|
||||
state.append(message)
|
||||
assert state.refresh_interval == 1.0
|
||||
for seconds, dots in [(0, 1), (3, 4), (9, 10), (10, 1)]:
|
||||
now[0] = 5.0 + seconds
|
||||
assert not state.append(message)
|
||||
assert f'<div class="status-latest">{message}{"." * dots}</div>' in state.render()
|
||||
|
||||
|
||||
def test_fast_completion_has_no_invented_elapsed_time_and_reset_has_eight_pending_steps() -> None:
|
||||
"""验证没有观察到运行的任务不显示零秒耗时,重置后保留完整双语步骤。"""
|
||||
state = StatusPanelState()
|
||||
state.append(STATUS_QUEUED_ON_SERVER)
|
||||
state.append(STATUS_DOWNLOADING_RESULT)
|
||||
state.append(STATUS_COMPLETED)
|
||||
assert '<div class="status-latest">Completed</div>' in state.render()
|
||||
idle = status_html()
|
||||
assert idle.count("status-step is-pending") == 8
|
||||
assert 'data-mineru-i18n-en="Waiting"' in idle
|
||||
assert 'data-mineru-i18n-zh="排队"' in idle
|
||||
assert "status-steps-panel" in idle
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", ["server task failed", "server task canceled", '<script>alert("x")</script>'])
|
||||
def test_failure_stops_timer_and_renders_escaped_error(error: str) -> None:
|
||||
"""验证失败或取消停止计时,并使用红色失败节点展示转义后的消息。"""
|
||||
state = StatusPanelState(clock=lambda: 10.0)
|
||||
state.append(STATUS_PROCESSING_ON_SERVER, at=1.0)
|
||||
state.append(f"Failed: {error}", at=3.0)
|
||||
rendered = state.render()
|
||||
assert state.processing_elapsed == 2.0
|
||||
assert state.refresh_interval is None
|
||||
assert "status-step is-active is-error" in rendered
|
||||
assert 'data-mineru-i18n-en="Failed"' in rendered
|
||||
assert "<script>" not in rendered
|
||||
|
||||
|
||||
def test_status_stream_ticks_while_job_waits_and_cleans_up_waiter() -> None:
|
||||
"""验证无网络通知时仍刷新本地时钟,关闭流后不残留队列等待任务。"""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""运行可手动推进时钟的解析等待场景。"""
|
||||
now = [1.0]
|
||||
state = StatusPanelState(clock=lambda: now[0])
|
||||
events: asyncio.Queue[tuple[str, float]] = asyncio.Queue()
|
||||
events.put_nowait((STATUS_PROCESSING_ON_SERVER, 1.0))
|
||||
task = asyncio.create_task(asyncio.Event().wait())
|
||||
baseline = set(asyncio.all_tasks())
|
||||
async with aclosing(stream_status_updates(task, events, state)) as stream:
|
||||
assert "(0.0s)" in await anext(stream)
|
||||
now[0] = 2.3
|
||||
assert "(1.3s)" in await asyncio.wait_for(anext(stream), timeout=1)
|
||||
assert set(asyncio.all_tasks()) == baseline
|
||||
assert set(asyncio.all_tasks()) == baseline
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -22,6 +22,7 @@ from pydantic import ValidationError
|
||||
import mineru.parser.api_client as api_client
|
||||
import mineru.parser.api_server as api_server
|
||||
import mineru.parser.tier as parser_tier
|
||||
from mineru.config import VlmConfig
|
||||
from mineru.parser import MIDDLE_JSON_SCHEMA_VERSION
|
||||
from mineru.parser.api_client import MinerUApiParser, _pages_from_middle_json, _parse_result_from_job, should_trust_env_for_url
|
||||
from mineru.parser.api_server import (
|
||||
@@ -53,6 +54,7 @@ from mineru.types import (
|
||||
ImageBodyBlock,
|
||||
MiddleJson,
|
||||
PageInfo,
|
||||
ServerTier,
|
||||
select_default_quality_tier,
|
||||
select_parsing_rule_tier,
|
||||
validate_tier,
|
||||
@@ -1442,9 +1444,11 @@ def test_api_client_omits_tier_when_unspecified(tmp_path: Path) -> None:
|
||||
assert "tier" not in payload
|
||||
|
||||
|
||||
def test_api_client_constructor_does_not_expose_ocr_or_image_options() -> None:
|
||||
def test_api_client_constructor_exposes_request_ocr_without_legacy_options() -> None:
|
||||
"""验证 OCR 使用显式请求参数,且不恢复旧 method 或图片分析选项。"""
|
||||
parameters = inspect.signature(MinerUApiParser).parameters
|
||||
|
||||
assert parameters["ocr_mode"].default is None
|
||||
assert "method" not in parameters
|
||||
assert "image_analysis" not in parameters
|
||||
|
||||
@@ -1796,7 +1800,7 @@ def test_create_app_does_not_read_runtime_settings_from_env(tmp_path: Path, monk
|
||||
assert app.state.max_inline_bytes == 1024 * 1024
|
||||
assert app.state.allow_http_source is False
|
||||
assert app.state.language == "ch"
|
||||
assert app.state.ocr_mode == "auto"
|
||||
assert not hasattr(app.state, "ocr_mode")
|
||||
assert app.state.effort == "high"
|
||||
assert app.state.image_analysis is True
|
||||
assert not hasattr(app.state, _REMOVED_TABLE_ENABLE_PARAM)
|
||||
@@ -1900,7 +1904,6 @@ def test_api_server_rendered_outputs_do_not_return_image_sidecars(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -1939,7 +1942,6 @@ def test_api_server_run_job_normalizes_lightweight_file_tier_to_flash(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -2072,7 +2074,6 @@ def test_api_server_zip_includes_model_output_when_parse_result_has_it(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -2121,7 +2122,6 @@ def test_api_server_zip_rejects_unsafe_image_sidecar_path(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -2163,7 +2163,6 @@ def test_api_server_zip_skips_model_output_when_parse_result_has_none(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -2209,7 +2208,6 @@ def test_api_server_logs_traceback_when_job_file_fails(
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode="auto",
|
||||
image_analysis=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
@@ -2331,6 +2329,37 @@ def test_api_server_standard_no_flash_state_and_metadata(tmp_path: Path, monkeyp
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tier", "no_flash", "expected_tiers", "expected_default_tier"),
|
||||
[
|
||||
("standard", False, ("flash", "basic", "standard"), "standard"),
|
||||
("standard", True, ("basic", "standard"), "standard"),
|
||||
("basic", False, ("flash", "basic"), "basic"),
|
||||
("flash", False, ("flash",), None),
|
||||
],
|
||||
)
|
||||
def test_api_server_no_advanced_capability_matrix(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tier: ServerTier,
|
||||
no_flash: bool,
|
||||
expected_tiers: tuple[str, ...],
|
||||
expected_default_tier: str | None,
|
||||
) -> None:
|
||||
"""验证禁用 Advanced 可与 server tier 和禁用 Flash 组合,且不改变其它档位。"""
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
app = create_app(
|
||||
upload_dir=str(tmp_path / f"{tier}-{no_flash}"),
|
||||
tier=tier,
|
||||
no_flash=no_flash,
|
||||
no_advanced=True,
|
||||
)
|
||||
|
||||
assert tuple(tier_info["id"] for tier_info in app.state.tiers) == expected_tiers
|
||||
assert tuple(app.state.tier_runtime_options) == expected_tiers
|
||||
assert app.state.default_tier == expected_default_tier
|
||||
|
||||
|
||||
def test_api_server_standard_http_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""校验 /v1/tiers 与 /v1/models 返回 Standard 上限展开后的全部能力。"""
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
@@ -2357,12 +2386,38 @@ def test_api_server_standard_http_metadata(tmp_path: Path, monkeypatch: pytest.M
|
||||
]
|
||||
|
||||
|
||||
def test_api_server_no_advanced_http_metadata_keeps_shared_models(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""验证能力接口隐藏 Advanced,同时保留 Standard 仍需使用的共享模型。"""
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
app = create_app(upload_dir=str(tmp_path), tier="standard", no_advanced=True)
|
||||
|
||||
with TestClient(app) as client:
|
||||
tiers_response = client.get("/v1/tiers")
|
||||
models_response = client.get("/v1/models")
|
||||
|
||||
assert tiers_response.status_code == 200
|
||||
assert [tier["id"] for tier in tiers_response.json()["data"]] == ["flash", "basic", "standard"]
|
||||
assert models_response.status_code == 200
|
||||
assert [model["id"] for model in models_response.json()["data"]] == [
|
||||
"MinerU-Flash",
|
||||
"Hybrid-Basic",
|
||||
"MinerU-HTML",
|
||||
"MinerU2.5-Pro-2605-1.2B",
|
||||
]
|
||||
|
||||
|
||||
def test_api_server_model_preload_failure_keeps_health_diagnostics_and_rejects_capabilities(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
|
||||
def _fail_preload(startup_tier: DeploymentTier, *, language: str) -> api_server._ModelPreloadResult:
|
||||
def _fail_preload(
|
||||
startup_tier: DeploymentTier, *, language: str, vlm_config: VlmConfig | None = None
|
||||
) -> api_server._ModelPreloadResult:
|
||||
"""模拟包含 VLM 初始化在内的服务预加载失败。"""
|
||||
raise ValueError("CUDA is not available.")
|
||||
|
||||
monkeypatch.setattr(api_server, "_preload_server_models", _fail_preload)
|
||||
@@ -2386,7 +2441,10 @@ def test_api_server_model_preload_is_opt_in_and_ignored_for_flash(tmp_path: Path
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _preload(startup_tier: DeploymentTier, *, language: str) -> api_server._ModelPreloadResult:
|
||||
def _preload(
|
||||
startup_tier: DeploymentTier, *, language: str, vlm_config: VlmConfig | None = None
|
||||
) -> api_server._ModelPreloadResult:
|
||||
"""记录预加载调用,兼容显式 VLM 配置传入。"""
|
||||
calls.append((startup_tier, language))
|
||||
return api_server._ModelPreloadResult(tier=startup_tier, engine="test")
|
||||
|
||||
@@ -2427,7 +2485,6 @@ def test_api_server_standard_jobs_use_requested_tier(tmp_path: Path, monkeypatch
|
||||
rec,
|
||||
request,
|
||||
file_store,
|
||||
ocr_mode=app.state.ocr_mode,
|
||||
image_analysis=app.state.image_analysis,
|
||||
allow_local_source=app.state.allow_local_source,
|
||||
max_inline_bytes=app.state.max_inline_bytes,
|
||||
@@ -2488,6 +2545,31 @@ def test_api_server_basic_rejects_advanced_request(tmp_path: Path, monkeypatch:
|
||||
assert "Tier 'advanced' not available in this server" in response.text
|
||||
|
||||
|
||||
def test_api_server_no_advanced_rejects_advanced_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证 Standard server 禁用 Advanced 后不会只隐藏能力而仍允许执行。"""
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
source = tmp_path / "demo.pdf"
|
||||
source.write_bytes(b"%PDF-1.7\n")
|
||||
app = create_app(
|
||||
upload_dir=str(tmp_path / "api"),
|
||||
tier="standard",
|
||||
no_advanced=True,
|
||||
allow_local_source=True,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/parse/jobs",
|
||||
json={
|
||||
"files": [{"source": {"type": "local", "path": str(source)}}],
|
||||
"tier": "advanced",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Tier 'advanced' not available in this server" in response.text
|
||||
|
||||
|
||||
def test_api_server_no_flash_rejects_flash_request(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_stub_api_server_dependency_preflight(monkeypatch)
|
||||
source = tmp_path / "demo.pdf"
|
||||
@@ -2629,12 +2711,11 @@ def test_api_server_stores_parser_runtime_options(tmp_path: Path, monkeypatch: p
|
||||
upload_dir=str(tmp_path),
|
||||
tier="basic",
|
||||
language="en",
|
||||
ocr_mode="ocr",
|
||||
image_analysis=False,
|
||||
)
|
||||
|
||||
assert app.state.language == "ch"
|
||||
assert app.state.ocr_mode == "ocr"
|
||||
assert not hasattr(app.state, "ocr_mode")
|
||||
assert app.state.effort == "medium"
|
||||
assert app.state.image_analysis is False
|
||||
assert not hasattr(app.state, _REMOVED_TABLE_ENABLE_PARAM)
|
||||
@@ -2652,7 +2733,7 @@ def test_api_server_cli_exposes_parser_runtime_options() -> None:
|
||||
assert "--tier" in option_names
|
||||
assert "--backend" not in option_names
|
||||
assert "--language" in option_names
|
||||
assert "--ocr-mode" in option_names
|
||||
assert "--ocr-mode" not in option_names
|
||||
assert "--allow-local-source" in option_names
|
||||
assert "--max-inline-bytes" in option_names
|
||||
assert "--allow-http-source" in option_names
|
||||
@@ -2735,6 +2816,30 @@ def test_api_server_cli_accepts_single_tier_and_no_flash(monkeypatch: pytest.Mon
|
||||
}
|
||||
|
||||
|
||||
def test_api_server_cli_accepts_no_advanced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证底层 Click 入口将 --no-advanced 应用于能力发布与 runtime。"""
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def _fake_run(server: Any) -> None:
|
||||
"""记录禁用 Advanced 后的 API server 能力。"""
|
||||
seen["tiers"] = [tier["id"] for tier in server.config.app.state.tiers]
|
||||
seen["default_tier"] = server.config.app.state.default_tier
|
||||
seen["effort_by_tier"] = {
|
||||
tier: runtime.effort for tier, runtime in server.config.app.state.tier_runtime_options.items()
|
||||
}
|
||||
|
||||
monkeypatch.setattr("uvicorn.Server.run", _fake_run)
|
||||
|
||||
result = runner.invoke(main, ["--tier", "standard", "--no-advanced", "--host", "0.0.0.0", "--port", "15984"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen == {
|
||||
"tiers": ["flash", "basic", "standard"],
|
||||
"default_tier": "standard",
|
||||
"effort_by_tier": {"flash": "flash", "basic": "medium", "standard": "high"},
|
||||
}
|
||||
|
||||
|
||||
def test_api_server_cli_enables_model_preload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, bool] = {}
|
||||
|
||||
@@ -2795,6 +2900,7 @@ def test_api_server_cli_help_exposes_tier_only_runtime_selection() -> None:
|
||||
assert "basic" in normalized_output
|
||||
assert "standard" in normalized_output
|
||||
assert "advanced" in normalized_output
|
||||
assert "--no-advanced" in normalized_output
|
||||
assert "--backend" not in normalized_output
|
||||
assert "--effort" not in normalized_output
|
||||
assert "hybrid-engine" not in normalized_output
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""V1 请求级 OCR 参数、输入校验与服务启动配置退役回归。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mineru.parser import api_client, api_server
|
||||
from mineru.parser.api_client import MinerUApiParser
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
@pytest.mark.parametrize("ocr_mode", [None, "auto", "txt", "ocr"])
|
||||
def test_api_parser_serializes_request_ocr_mode(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
use_async: bool,
|
||||
ocr_mode: Literal["auto", "txt", "ocr"] | None,
|
||||
) -> None:
|
||||
"""通过正式 HTTP 提交流程检查两个入口的显式模式与缺省字段。"""
|
||||
source = tmp_path / "demo.pdf"
|
||||
source.write_bytes(b"%PDF-1.7\n")
|
||||
parser = MinerUApiParser(api_url="http://localhost:8000", tier="flash", ocr_mode=ocr_mode)
|
||||
parser._source_features = {"local"}
|
||||
payloads: list[dict[str, Any]] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
"""记录完整任务请求,在产物下载前返回已完成任务。"""
|
||||
assert request.method == "POST" and request.url.path == "/v1/parse/jobs"
|
||||
payloads.append(json.loads(request.content))
|
||||
return httpx.Response(202, json={"job_id": "job_test", "status": "completed"})
|
||||
|
||||
transport = httpx.MockTransport(respond)
|
||||
sync_client, async_client = httpx.Client, httpx.AsyncClient
|
||||
|
||||
def create_sync_client(**kwargs: Any) -> httpx.Client:
|
||||
"""向同步传输注入请求记录器,避免访问真实网络。"""
|
||||
return sync_client(transport=transport, **kwargs)
|
||||
|
||||
def create_async_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""向异步传输注入同一个请求记录器。"""
|
||||
return async_client(transport=transport, **kwargs)
|
||||
|
||||
monkeypatch.setattr(api_client.httpx, "Client", create_sync_client)
|
||||
monkeypatch.setattr(api_client.httpx, "AsyncClient", create_async_client)
|
||||
monkeypatch.setattr(parser, "_build_result", Mock())
|
||||
monkeypatch.setattr(parser, "_async_build_result", AsyncMock())
|
||||
if use_async:
|
||||
asyncio.run(parser.parse_async(source, page_range="1"))
|
||||
else:
|
||||
parser.parse(source, page_range="1")
|
||||
|
||||
expected: dict[str, Any] = {
|
||||
"files": [{"source": {"type": "local", "path": str(source)}, "page_range": "1"}],
|
||||
"tier": "flash",
|
||||
"output_formats": ["middle_json"],
|
||||
}
|
||||
if ocr_mode is not None:
|
||||
expected["ocr_mode"] = ocr_mode
|
||||
assert payloads == [expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "forced", True, 1, {}])
|
||||
def test_api_parser_rejects_invalid_ocr_mode(value: object) -> None:
|
||||
"""在上传之前拒绝不属于公开模式集合的配置。"""
|
||||
with pytest.raises(ValueError, match="Unsupported OCR mode"):
|
||||
MinerUApiParser(ocr_mode=value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", "forced", True, 1, {}])
|
||||
def test_api_server_rejects_invalid_request_ocr_mode(tmp_path: Path, value: object) -> None:
|
||||
"""非法模式统一返回带参数定位的 HTTP 400,且不会创建任务。"""
|
||||
app = api_server.create_app(upload_dir=str(tmp_path), tier="flash")
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/parse/jobs",
|
||||
json={
|
||||
"files": [{"source": {"type": "inline", "name": "demo.pdf", "data": "JVBERi0xLjcK"}}],
|
||||
"ocr_mode": value,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["code"] == "invalid_request"
|
||||
assert response.json()["error"]["param"] == "ocr_mode"
|
||||
assert app.state.job_store._jobs == {}
|
||||
|
||||
|
||||
def test_api_server_ocr_defaults_to_request_auto_without_startup_configuration(tmp_path: Path) -> None:
|
||||
"""请求缺省值固定为 auto,应用生命周期不保存全局 OCR 模式。"""
|
||||
request = api_server.CreateJobRequest.model_validate({"files": [{"source": {"type": "local", "path": "/tmp/demo.pdf"}}]})
|
||||
assert request.ocr_mode == "auto"
|
||||
app = api_server.create_app(upload_dir=str(tmp_path), tier="flash")
|
||||
assert not hasattr(app.state, "ocr_mode")
|
||||
with TestClient(app):
|
||||
assert not hasattr(app.state, "ocr_mode")
|
||||
with pytest.raises(TypeError, match="ocr_mode"):
|
||||
api_server.create_app(ocr_mode="ocr") # type: ignore[call-arg]
|
||||
result = CliRunner().invoke(api_server.main, ["--ocr-mode", "ocr"])
|
||||
assert result.exit_code != 0
|
||||
assert "No such option: --ocr-mode" in result.output
|
||||
@@ -0,0 +1,108 @@
|
||||
"""同步和异步 V1 parser 的状态观察回调合同。"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mineru.parser import ApiJobStatus, MinerUApiParser, ParseResult
|
||||
from mineru.parser import api_client
|
||||
from mineru.types import MiddleJson
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
@pytest.mark.parametrize("callback_raises", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"statuses",
|
||||
[
|
||||
("queued", "running", "running", "completed"),
|
||||
("completed",),
|
||||
("queued", "partial"),
|
||||
("queued", "running", "failed"),
|
||||
("queued", "canceled"),
|
||||
],
|
||||
)
|
||||
def test_parse_notifies_submission_and_polls_before_result_building(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
use_async: bool,
|
||||
callback_raises: bool,
|
||||
statuses: tuple[ApiJobStatus, ...],
|
||||
) -> None:
|
||||
"""验证六种真实状态、固定轮询、直接完成和回调异常隔离,通知不延迟到结果下载之后。"""
|
||||
source = tmp_path / "source.pdf"
|
||||
source.write_bytes(b"%PDF-1.7\n")
|
||||
parser = MinerUApiParser(api_url="http://localhost:8000", tier="flash")
|
||||
parser._source_features = {"local"}
|
||||
notifications: list[ApiJobStatus] = []
|
||||
delays: list[float] = []
|
||||
replies = iter(statuses)
|
||||
methods: list[str] = []
|
||||
result = ParseResult(
|
||||
middle_json=MiddleJson(
|
||||
pages=[], is_full_document=True, file_suffix="pdf", effort="flash", parse_mode="txt", mineru_version="4.0.0"
|
||||
)
|
||||
)
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
"""只模拟创建和轮询任务,保留正式 HTTP 调用及状态传播路径。"""
|
||||
methods.append(request.method)
|
||||
return httpx.Response(200, json={"job_id": "job_test", "status": next(replies)})
|
||||
|
||||
transport = httpx.MockTransport(respond)
|
||||
sync_client, async_client = httpx.Client, httpx.AsyncClient
|
||||
|
||||
def create_sync_client(**kwargs: Any) -> httpx.Client:
|
||||
"""注入同步 HTTP transport,禁止测试访问真实服务。"""
|
||||
return sync_client(transport=transport, **kwargs)
|
||||
|
||||
def create_async_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""注入异步 HTTP transport,禁止测试访问真实服务。"""
|
||||
return async_client(transport=transport, **kwargs)
|
||||
|
||||
async def sleep_async(delay: float) -> None:
|
||||
"""记录轮询间隔而不真实等待。"""
|
||||
delays.append(delay)
|
||||
|
||||
def on_status(status: ApiJobStatus) -> None:
|
||||
"""记录本次调用的状态,按参数模拟不可靠的观察者。"""
|
||||
notifications.append(status)
|
||||
if callback_raises:
|
||||
raise RuntimeError("observer failed")
|
||||
|
||||
def build_result(job: dict[str, Any], file_name: str) -> ParseResult:
|
||||
"""确认所有状态在开始下载/构建结果之前已经通知。"""
|
||||
assert notifications == list(statuses)
|
||||
assert file_name == source.name
|
||||
api_client._raise_for_terminal_job_error(job)
|
||||
return result
|
||||
|
||||
async def build_result_async(job: dict[str, Any], file_name: str) -> ParseResult:
|
||||
"""为异步解析复用相同的终态和通知时序断言。"""
|
||||
return build_result(job, file_name)
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", create_sync_client)
|
||||
monkeypatch.setattr(httpx, "AsyncClient", create_async_client)
|
||||
monkeypatch.setattr(api_client.time, "sleep", delays.append)
|
||||
monkeypatch.setattr(api_client.asyncio, "sleep", sleep_async)
|
||||
monkeypatch.setattr(parser, "_build_result", build_result)
|
||||
monkeypatch.setattr(parser, "_async_build_result", build_result_async)
|
||||
|
||||
def parse() -> ParseResult:
|
||||
"""统一执行两个公共入口,确保回调参数一路透传。"""
|
||||
if use_async:
|
||||
return asyncio.run(parser.parse_async(source, status_callback=on_status))
|
||||
return parser.parse(source, status_callback=on_status)
|
||||
|
||||
if statuses[-1] in ("failed", "canceled"):
|
||||
with pytest.raises(Exception, match=f"ended with status {statuses[-1]}"):
|
||||
parse()
|
||||
else:
|
||||
assert parse() is result
|
||||
assert notifications == list(statuses)
|
||||
assert methods == ["POST", *(["GET"] * (len(statuses) - 1))]
|
||||
assert delays == [1] * (len(statuses) - 1)
|
||||
assert ("Parse job status callback failed" in caplog.text) is callback_raises
|
||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import mineru.parser.api_server as api_server
|
||||
from mineru.config import VlmConfig
|
||||
|
||||
|
||||
def test_preload_local_models_initializes_conditional_model_families(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -74,6 +76,34 @@ def test_preload_standard_models_initializes_platform_engine_and_local_models(mo
|
||||
assert calls == [("vlm", "lmdeploy-engine", None, None), ("local", "en")]
|
||||
|
||||
|
||||
def test_local_preload_and_parse_reuse_llama_predictor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""本地预加载与解析共用同步引擎缓存,llama 参数循环不能覆盖模型缓存键。"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mineru.model.vlm import runtime, selector
|
||||
from mineru.model.vlm.client import get_vlm_predictor
|
||||
|
||||
engine_factory = MagicMock()
|
||||
predictor_factory = MagicMock()
|
||||
engine_module = types.ModuleType("mineru_llama_cpp")
|
||||
engine_module.Engine = engine_factory
|
||||
monkeypatch.setitem(sys.modules, "mineru_llama_cpp", engine_module)
|
||||
monkeypatch.setattr(runtime.ModelSingleton, "_models", {})
|
||||
monkeypatch.setattr(runtime, "MinerUClient", predictor_factory)
|
||||
monkeypatch.setattr(type(runtime.MINERU_2_5_PRO_2605_1_2B), "ensure", lambda self: tmp_path)
|
||||
engine_selector = MagicMock(return_value="llama-cpp-engine")
|
||||
monkeypatch.setattr(selector, "get_vlm_engine", engine_selector)
|
||||
monkeypatch.setattr(api_server, "_preload_local_models", lambda language: None)
|
||||
|
||||
api_server._preload_server_models("standard", language="ch", vlm_config=VlmConfig())
|
||||
predictor, engine = get_vlm_predictor(VlmConfig())
|
||||
assert predictor is predictor_factory.return_value
|
||||
assert engine == "llama-cpp-engine"
|
||||
predictor_factory.assert_called_once()
|
||||
engine_factory.assert_called_once()
|
||||
assert all(call.kwargs == {"is_async": False} for call in engine_selector.call_args_list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected_code"),
|
||||
[
|
||||
|
||||
@@ -164,9 +164,6 @@ def test_doc_analyze_converts_vlm_results_before_downstream_processing(
|
||||
|
||||
vlm_predictor = MagicMock()
|
||||
getattr(vlm_predictor, vlm_method_name).return_value = [source_page]
|
||||
vlm_singleton = MagicMock()
|
||||
vlm_singleton.get_model.return_value = vlm_predictor
|
||||
serial_wrapper = MagicMock(return_value=vlm_predictor)
|
||||
page_image = Image.new("RGB", (64, 96), "white")
|
||||
|
||||
def fake_process_text_and_formulas(
|
||||
@@ -196,15 +193,9 @@ def test_doc_analyze_converts_vlm_results_before_downstream_processing(
|
||||
monkeypatch.setattr(pipeline, "HybridLocalModelContextSingleton", MagicMock(return_value=hybrid_singleton))
|
||||
monkeypatch.setattr(
|
||||
pipeline,
|
||||
"_load_vlm_runtime",
|
||||
MagicMock(
|
||||
return_value={
|
||||
"ModelSingleton": MagicMock(return_value=vlm_singleton),
|
||||
"_maybe_enable_serial_execution": serial_wrapper,
|
||||
}
|
||||
),
|
||||
"get_vlm_predictor",
|
||||
MagicMock(return_value=(vlm_predictor, "transformers")),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_engine", MagicMock(return_value="transformers"))
|
||||
monkeypatch.setattr(
|
||||
window,
|
||||
"load_images_from_pdf_bytes_range",
|
||||
|
||||
@@ -1266,6 +1266,7 @@ def test_aio_doc_analyze_runs_sync_entrypoint_in_thread_and_forwards_arguments(
|
||||
"page_index_map": [3, 5],
|
||||
"file_suffix": "pptx",
|
||||
"source_context": None,
|
||||
"vlm_config": None,
|
||||
}
|
||||
assert observed["thread_id"] != caller_thread_id
|
||||
|
||||
@@ -1476,8 +1477,7 @@ def test_pdf_flash_ocr_uses_local_ocr_without_vlm(
|
||||
hybrid_singleton = MagicMock()
|
||||
hybrid_singleton.get_model.return_value = hybrid_model
|
||||
process_pdf_windows = MagicMock(return_value=[])
|
||||
load_vlm_runtime = MagicMock()
|
||||
get_vlm_engine = MagicMock()
|
||||
get_vlm_predictor = MagicMock()
|
||||
|
||||
monkeypatch.setattr(pipeline, "PDFDocument", MagicMock(return_value=fake_document))
|
||||
monkeypatch.setattr(
|
||||
@@ -1487,8 +1487,7 @@ def test_pdf_flash_ocr_uses_local_ocr_without_vlm(
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "process_pdf_windows", process_pdf_windows)
|
||||
monkeypatch.setattr(pipeline, "clean_memory", MagicMock())
|
||||
monkeypatch.setattr(pipeline, "_load_vlm_runtime", load_vlm_runtime)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_engine", get_vlm_engine)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_predictor", get_vlm_predictor)
|
||||
|
||||
result = pipeline.analyze_pdf(
|
||||
b"ocr-pdf",
|
||||
@@ -1505,8 +1504,7 @@ def test_pdf_flash_ocr_uses_local_ocr_without_vlm(
|
||||
fake_document.classify.assert_called_once_with()
|
||||
else:
|
||||
fake_document.classify.assert_not_called()
|
||||
load_vlm_runtime.assert_not_called()
|
||||
get_vlm_engine.assert_not_called()
|
||||
get_vlm_predictor.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parse_mode", ["txt", "auto"])
|
||||
@@ -1518,14 +1516,12 @@ def test_pdf_flash_txt_skips_all_neural_model_loading(
|
||||
fake_document = MagicMock()
|
||||
fake_document.classify.return_value = "txt"
|
||||
hybrid_model_factory = MagicMock()
|
||||
load_vlm_runtime = MagicMock()
|
||||
get_vlm_engine = MagicMock()
|
||||
get_vlm_predictor = MagicMock()
|
||||
clean_memory = MagicMock()
|
||||
process_pdf_windows = MagicMock(return_value=[])
|
||||
monkeypatch.setattr(pipeline, "PDFDocument", MagicMock(return_value=fake_document))
|
||||
monkeypatch.setattr(pipeline, "HybridLocalModelContextSingleton", hybrid_model_factory)
|
||||
monkeypatch.setattr(pipeline, "_load_vlm_runtime", load_vlm_runtime)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_engine", get_vlm_engine)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_predictor", get_vlm_predictor)
|
||||
monkeypatch.setattr(pipeline, "clean_memory", clean_memory)
|
||||
monkeypatch.setattr(pipeline, "process_pdf_windows", process_pdf_windows)
|
||||
|
||||
@@ -1545,8 +1541,7 @@ def test_pdf_flash_txt_skips_all_neural_model_loading(
|
||||
else:
|
||||
fake_document.classify.assert_not_called()
|
||||
hybrid_model_factory.assert_not_called()
|
||||
load_vlm_runtime.assert_not_called()
|
||||
get_vlm_engine.assert_not_called()
|
||||
get_vlm_predictor.assert_not_called()
|
||||
clean_memory.assert_not_called()
|
||||
|
||||
|
||||
@@ -1574,19 +1569,11 @@ def test_pdf_infer_timer_excludes_hybrid_vlm_initialization_and_cleanup(
|
||||
|
||||
hybrid_singleton.get_model.side_effect = fake_hybrid_get_model
|
||||
vlm_predictor = MagicMock()
|
||||
vlm_singleton = MagicMock()
|
||||
|
||||
def fake_vlm_get_model(**_kwargs: object) -> MagicMock:
|
||||
def fake_vlm_get_model(_config: object) -> tuple[MagicMock, str]:
|
||||
"""记录 VLM predictor 初始化顺序。"""
|
||||
events.append("vlm_init")
|
||||
return vlm_predictor
|
||||
|
||||
vlm_singleton.get_model.side_effect = fake_vlm_get_model
|
||||
|
||||
def fake_enable_serial_execution(predictor: MagicMock, _backend: str) -> MagicMock:
|
||||
"""记录 VLM predictor 包装顺序并原样返回。"""
|
||||
events.append("vlm_wrap")
|
||||
return predictor
|
||||
return vlm_predictor, "transformers"
|
||||
|
||||
perf_counter_values = iter([20.0, 22.0])
|
||||
|
||||
@@ -1606,15 +1593,7 @@ def test_pdf_infer_timer_excludes_hybrid_vlm_initialization_and_cleanup(
|
||||
|
||||
monkeypatch.setattr(pipeline, "PDFDocument", MagicMock(return_value=fake_document))
|
||||
monkeypatch.setattr(pipeline, "HybridLocalModelContextSingleton", MagicMock(return_value=hybrid_singleton))
|
||||
monkeypatch.setattr(
|
||||
pipeline,
|
||||
"_load_vlm_runtime",
|
||||
lambda: {
|
||||
"ModelSingleton": MagicMock(return_value=vlm_singleton),
|
||||
"_maybe_enable_serial_execution": fake_enable_serial_execution,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "get_vlm_engine", MagicMock(return_value="transformers"))
|
||||
monkeypatch.setattr(pipeline, "get_vlm_predictor", fake_vlm_get_model)
|
||||
monkeypatch.setattr(pipeline.time, "perf_counter", fake_perf_counter)
|
||||
monkeypatch.setattr(pipeline, "_normalize_pdf_model_list", fake_normalize_model_list)
|
||||
monkeypatch.setattr(pipeline, "clean_memory", fake_clean_memory)
|
||||
@@ -1635,7 +1614,6 @@ def test_pdf_infer_timer_excludes_hybrid_vlm_initialization_and_cleanup(
|
||||
assert events == [
|
||||
"hybrid_init",
|
||||
"vlm_init",
|
||||
"vlm_wrap",
|
||||
"timer_20.0",
|
||||
"normalize_model_list",
|
||||
"timer_22.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -1117,3 +1119,96 @@ def test_get_page_path_infos_skips_one_bad_path(monkeypatch: pytest.MonkeyPatch)
|
||||
assert call_count >= 8
|
||||
assert len(path_infos) == 6
|
||||
assert all(item.source_index != 0 for item in path_infos)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builder",
|
||||
[
|
||||
_build_drawing_pdf,
|
||||
_build_rotated_cropped_drawing_pdf,
|
||||
_build_colored_path_pdf,
|
||||
_build_rotated_cropped_image_pdf,
|
||||
_build_rotated_cropped_signature_pdf,
|
||||
_build_rotated_cropped_link_pdf,
|
||||
],
|
||||
)
|
||||
def test_native_page_snapshot_matches_independent_accessors(builder: Callable[[], bytes]) -> None:
|
||||
"""批量提取与独立接口在旋转、裁剪、Form、签名和链接语料上完全一致。"""
|
||||
|
||||
with pdf_document.PDFDocument(builder()) as document:
|
||||
snapshot = document._extract_native_page(0)
|
||||
assert snapshot.page_size == document.page_size(0)
|
||||
assert snapshot.rotation == document.page_rotation(0)
|
||||
assert snapshot.drawing_lines == document.get_page_drawing_lines(0)
|
||||
assert snapshot.path_infos == document.get_page_path_infos(0)
|
||||
assert snapshot.image_infos == document.get_page_image_infos(0)
|
||||
assert snapshot.form_bboxes == document.get_page_form_bboxes(0)
|
||||
assert snapshot.signature_bboxes == document.get_page_signature_bboxes(0)
|
||||
assert snapshot.link_annotations == document.get_page_link_annotations(0)
|
||||
geometry = document.get_page_chars_with_geometry(0)
|
||||
assert snapshot.text_geometry.tight_bboxes == geometry.tight_bboxes
|
||||
assert snapshot.text_geometry.loose_bboxes == geometry.loose_bboxes
|
||||
assert snapshot.text_geometry.origins == geometry.origins
|
||||
assert [(char["char"], char["char_idx"], tuple(char["bbox"])) for char in snapshot.text_geometry.chars] == [
|
||||
(char["char"], char["char_idx"], tuple(char["bbox"])) for char in geometry.chars
|
||||
]
|
||||
|
||||
|
||||
def test_native_page_snapshot_opens_once_and_closes_after_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""批量读取只打开一次页面,字符提取抛错时同样关闭原生页面。"""
|
||||
|
||||
opened: list[pdf_document.pdfium.PdfPage] = []
|
||||
original = pdf_document.PDFDocument._open_page
|
||||
|
||||
@contextmanager
|
||||
def record_page(document: pdf_document.PDFDocument, page_idx: int) -> Iterator[pdf_document.pdfium.PdfPage]:
|
||||
"""记录真实页面对象,使用原生命周期管理检查成功和失败后的关闭状态。"""
|
||||
|
||||
with original(document, page_idx) as page:
|
||||
opened.append(page)
|
||||
yield page
|
||||
|
||||
monkeypatch.setattr(pdf_document.PDFDocument, "_open_page", record_page)
|
||||
with pdf_document.PDFDocument(_build_drawing_pdf()) as document:
|
||||
document._extract_native_page(0)
|
||||
assert len(opened) == 1
|
||||
assert opened[0].raw is None
|
||||
|
||||
def fail_text(
|
||||
page: pdf_document.pdfium.PdfPage, *, include_extended_geometry: bool
|
||||
) -> pdf_document.PDFPageTextGeometry:
|
||||
"""模拟字符提取失败,验证页面生命周期仍由外层上下文管理。"""
|
||||
|
||||
raise RuntimeError("broken text")
|
||||
|
||||
monkeypatch.setattr(pdf_document, "_extract_page_text_geometry", fail_text)
|
||||
with pytest.raises(RuntimeError, match="broken text"):
|
||||
document._extract_native_page(0)
|
||||
assert len(opened) == 2
|
||||
assert opened[1].raw is None
|
||||
|
||||
|
||||
def test_native_page_snapshot_decodes_each_path_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""同一 Path 解码由绘图线和路径信息共享,避免独立接口的重复工作。"""
|
||||
|
||||
counts: list[object] = []
|
||||
original = pdf_document._read_raw_path_subpaths
|
||||
|
||||
def record_decode(raw_object: Any) -> list[pdf_document._PathSubpath]:
|
||||
"""统计真实 Path 解码次数,不替换解码结果。"""
|
||||
|
||||
counts.append(raw_object)
|
||||
return original(raw_object)
|
||||
|
||||
monkeypatch.setattr(pdf_document, "_read_raw_path_subpaths", record_decode)
|
||||
with pdf_document.PDFDocument(_build_drawing_pdf()) as document:
|
||||
document.get_page_drawing_lines(0)
|
||||
document.get_page_path_infos(0)
|
||||
separate_count = len(counts)
|
||||
counts.clear()
|
||||
document._extract_native_page(0)
|
||||
shared_count = len(counts)
|
||||
with document._open_page(0) as page:
|
||||
expected_count = len(list(pdf_document._iter_raw_path_objects_with_depth(page)))
|
||||
assert shared_count == expected_count
|
||||
assert shared_count < separate_count
|
||||
|
||||
@@ -259,7 +259,7 @@ def test_async_job_resolves_relative_ranges_and_reports_file_errors(tmp_path: Pa
|
||||
}
|
||||
)
|
||||
record = api_server.JobStore().create(request, store)
|
||||
asyncio.run(api_server._run_job(record, request, store, ocr_mode="auto", image_analysis=True, allow_local_source=True))
|
||||
asyncio.run(api_server._run_job(record, request, store, image_analysis=True, allow_local_source=True))
|
||||
assert record.status == "partial"
|
||||
assert record.files[0].page_range == "1,4-5"
|
||||
assert record.files[0].status == "completed"
|
||||
|
||||
@@ -18,6 +18,7 @@ from mineru.backend.analysis.pdf.text.models import _AnalyzeLine, _AnalyzeSpan
|
||||
from mineru.backend.analysis.pdf.text.native import txt_spans_extract
|
||||
from mineru.backend.postprocess.pages import model_json_to_pages
|
||||
from mineru.model.flash import PdfModel
|
||||
from mineru.model.flash.pdf.inline import matching as inline_matching
|
||||
from mineru.model.flash.pdf import models, native_text, text_styles as flash_text_styles
|
||||
from mineru.render import render_docx, render_html, render_markdown, render_structured_content
|
||||
from mineru.types import (
|
||||
@@ -363,7 +364,7 @@ def test_resplit_text_evidence_preserves_identity_and_unsafe_mapping(
|
||||
original_links = [link_line]
|
||||
warnings: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
flash_text_styles,
|
||||
inline_matching,
|
||||
"logger",
|
||||
SimpleNamespace(warning=warnings.append),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
from typer.testing import CliRunner as TyperRunner
|
||||
|
||||
from mineru.config import Config, VlmConfig, _load_effective_config
|
||||
from mineru.kit.commands import api_server as kit_api
|
||||
from mineru.kit.main import app as kit_app
|
||||
from mineru.parser import MinerUParser, api_server
|
||||
|
||||
|
||||
def test_vlm_config_defaults_and_environment_precedence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""验证 YAML 和标准环境变量的字段覆盖、配置来源以及无配置默认值。"""
|
||||
assert Config().model.vlm == VlmConfig(server_url="", api_key="", model="", http_timeout=600, max_concurrency=100)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(
|
||||
"model:\n vlm:\n server_url: http://localhost:9000/proxy/v1\n"
|
||||
" api_key: file-key\n model: file-model\n http_timeout: 30\n max_concurrency: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("MINERU_CONFIG", str(config_file))
|
||||
monkeypatch.setenv("MINERU_MODEL_VLM_API_KEY", "env-key")
|
||||
monkeypatch.setenv("MINERU_MODEL_VLM_HTTP_TIMEOUT", "45")
|
||||
loaded = _load_effective_config()
|
||||
assert loaded.config.model.vlm == VlmConfig(
|
||||
server_url="http://localhost:9000/proxy/", api_key="env-key", model="file-model", http_timeout=45, max_concurrency=2
|
||||
)
|
||||
assert loaded.sources[("model", "vlm", "server_url")] == "file"
|
||||
assert loaded.sources[("model", "vlm", "api_key")] == "env"
|
||||
assert "env-key" not in repr(loaded.config.model.vlm)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("", ""),
|
||||
(" ", ""),
|
||||
("http://localhost:9000", "http://localhost:9000/"),
|
||||
("https://example.test/v1/", "https://example.test/"),
|
||||
("https://example.test/proxy/v1", "https://example.test/proxy/"),
|
||||
("https://example.test/proxy/", "https://example.test/proxy/"),
|
||||
("http://[::1]:9000/v1", "http://[::1]:9000/"),
|
||||
],
|
||||
)
|
||||
def test_vlm_url_normalization(value: str, expected: str) -> None:
|
||||
"""验证服务根地址、v1、代理前缀和 IPv6 的稳定规范化。"""
|
||||
settings = VlmConfig(server_url=value)
|
||||
assert settings.server_url == expected
|
||||
assert VlmConfig.model_validate(settings.model_dump()) == settings
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"ftp://host",
|
||||
"host:9000",
|
||||
"http://",
|
||||
"http://host:bad",
|
||||
"http://user:pass@host",
|
||||
"http://host?q=1",
|
||||
"http://host/#x",
|
||||
"http://bad host",
|
||||
],
|
||||
)
|
||||
def test_vlm_rejects_invalid_url(value: str) -> None:
|
||||
"""非法服务地址在配置边界失败,避免错误地址进入 HTTP 客户端。"""
|
||||
with pytest.raises(ValidationError, match="HTTP"):
|
||||
VlmConfig(server_url=value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["http_timeout", "max_concurrency"])
|
||||
@pytest.mark.parametrize("value", [0, -1, True, 1.5])
|
||||
def test_vlm_rejects_invalid_limits(field: str, value: object) -> None:
|
||||
"""连接超时和推理并发只接受正整数。"""
|
||||
with pytest.raises(ValidationError):
|
||||
VlmConfig.model_validate({field: value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("env_name", "field"), [("MINERU_VL_API_KEY", "api_key"), ("MINERU_VL_MODEL_NAME", "model")])
|
||||
def test_vlm_legacy_conflicts_are_explicit_and_redacted(monkeypatch: pytest.MonkeyPatch, env_name: str, field: str) -> None:
|
||||
"""拒绝旧变量覆盖新配置,异常不包含任一凭据值,本地模式不受影响。"""
|
||||
monkeypatch.delenv("MINERU_VL_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MINERU_VL_MODEL_NAME", raising=False)
|
||||
monkeypatch.setenv(env_name, "legacy-secret")
|
||||
with pytest.raises(ValueError, match=env_name) as caught:
|
||||
VlmConfig.model_validate({"server_url": "http://localhost:9000", field: "explicit-secret"}).validate_environment()
|
||||
assert "secret" not in str(caught.value)
|
||||
VlmConfig.model_validate({"server_url": "http://localhost:9000", field: "legacy-secret"}).validate_environment()
|
||||
VlmConfig().validate_environment()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("standalone", [False, True])
|
||||
def test_vlm_options_forward_including_empty_values(monkeypatch: pytest.MonkeyPatch, standalone: bool) -> None:
|
||||
"""新版命令及独立别名保留所有显式 VLM 值,包括用于清空配置的空字符串。"""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_main(*, args: list[str], prog_name: str, standalone_mode: bool) -> None:
|
||||
"""记录命令桥接参数,不启动服务。"""
|
||||
calls.append(args)
|
||||
|
||||
monkeypatch.setattr(api_server.main, "main", fake_main)
|
||||
options = [
|
||||
"--vlm-server-url",
|
||||
"",
|
||||
"--vlm-api-key",
|
||||
"",
|
||||
"--vlm-model",
|
||||
"alias",
|
||||
"--vlm-http-timeout",
|
||||
"12",
|
||||
"--vlm-max-concurrency",
|
||||
"3",
|
||||
]
|
||||
if standalone:
|
||||
monkeypatch.setattr("sys.argv", ["mineru-api", *options])
|
||||
with pytest.raises(SystemExit) as caught:
|
||||
kit_api.main()
|
||||
assert caught.value.code == 0
|
||||
else:
|
||||
assert TyperRunner().invoke(kit_app, ["api-server", *options]).exit_code == 0
|
||||
for option, value in zip(options[::2], options[1::2]):
|
||||
assert calls[0][calls[0].index(option) + 1] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["http://cli.test/proxy/v1", ""])
|
||||
def test_api_cli_merges_vlm_without_mutating_global(monkeypatch: pytest.MonkeyPatch, url: str) -> None:
|
||||
"""底层 CLI 按字段覆盖全局设置,并保持 API Key、全局配置和环境变量隔离。"""
|
||||
settings = VlmConfig(server_url="http://global.test", api_key="global-key", model="global-model", max_concurrency=7)
|
||||
monkeypatch.setattr(api_server.mineru_config.model, "vlm", settings)
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def capture_app(**kwargs: Any) -> object:
|
||||
"""记录应用构造参数,用于核验 CLI 覆盖而不创建文件。"""
|
||||
seen.update(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(api_server, "create_app", capture_app)
|
||||
monkeypatch.setattr(api_server.uvicorn.Server, "run", lambda self: None)
|
||||
previous_env = dict(os.environ)
|
||||
result = CliRunner().invoke(
|
||||
api_server.main, ["--vlm-server-url", url, "--vlm-api-key", "", "--vlm-http-timeout", "9", "--api-key", "inbound-key"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert seen["api_key"] == "inbound-key"
|
||||
assert seen["vlm_config"] == VlmConfig(server_url=url, api_key="", model="global-model", http_timeout=9, max_concurrency=7)
|
||||
assert settings.server_url == "http://global.test/"
|
||||
assert settings.api_key == "global-key"
|
||||
assert dict(os.environ) == previous_env
|
||||
|
||||
|
||||
def test_vlm_app_and_parser_snapshot_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""应用及解析器捕获独立副本,显式空配置完整覆盖全局远程设置。"""
|
||||
monkeypatch.setattr(api_server, "ensure_tier_runtime_dependencies", lambda tier: None)
|
||||
settings = VlmConfig(server_url="http://remote.test", model="alias")
|
||||
monkeypatch.setattr(api_server.mineru_config.model, "vlm", settings)
|
||||
app = api_server.create_app(upload_dir=str(tmp_path))
|
||||
parser = MinerUParser()
|
||||
local_parser = MinerUParser(vlm_config=VlmConfig())
|
||||
settings.model = "changed"
|
||||
assert parser.vlm_config.model == app.state.vlm_config.model == "alias"
|
||||
assert parser.vlm_config is not app.state.vlm_config
|
||||
assert local_parser.vlm_config.server_url == ""
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/v1/health").status_code == 200
|
||||
assert app.state.vlm_config.model == "alias"
|
||||
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
from reportlab.pdfgen import canvas
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mineru.config import VlmConfig, config
|
||||
from mineru.model.vlm.client import get_vlm_predictor
|
||||
from mineru.parser import api_server, parse, parse_async
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OpenAIServer:
|
||||
"""记录模拟推理服务的协议请求和可控响应。"""
|
||||
|
||||
url: str = ""
|
||||
models: list[str] = field(default_factory=lambda: ["test-model"])
|
||||
api_key: str = "test-key"
|
||||
chat_status: int = 200
|
||||
requests: list[tuple[str, str, dict[str, Any] | None]] = field(default_factory=list)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openai_server(monkeypatch: pytest.MonkeyPatch) -> Iterator[_OpenAIServer]:
|
||||
"""启动仅监听 loopback 的模拟 OpenAI 服务,并隔离模型缓存与旧环境变量。"""
|
||||
from mineru.model.vlm.runtime import ModelSingleton
|
||||
|
||||
monkeypatch.delenv("MINERU_VL_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MINERU_VL_MODEL_NAME", raising=False)
|
||||
monkeypatch.setattr(config.model, "vlm", VlmConfig())
|
||||
monkeypatch.setattr(config.llm_aided.features, "title_leveling", False)
|
||||
monkeypatch.setattr(config.llm_aided.features, "cross_page_table_cell_merge", False)
|
||||
monkeypatch.setattr(ModelSingleton, "_models", {})
|
||||
state = _OpenAIServer()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
"""实现模型发现、认证和最小 Chat Completions 协议。"""
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
"""关闭测试 HTTP 日志,避免图片请求污染测试输出。"""
|
||||
|
||||
def _respond(self, status: int, payload: dict[str, Any]) -> None:
|
||||
"""返回带明确长度的 JSON,使客户端可以正常结束响应读取。"""
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
"""校验 VLM Key,与解析 API 自身的 Key 独立。"""
|
||||
if state.api_key and self.headers.get("Authorization") != f"Bearer {state.api_key}":
|
||||
self._respond(401, {"error": {"message": "unauthorized"}})
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""仅在保留代理前缀的 models 路径返回模型列表。"""
|
||||
state.requests.append((self.path, self.headers.get("Authorization", ""), None))
|
||||
if not self._authorized():
|
||||
return
|
||||
if self.path != "/proxy/v1/models":
|
||||
self._respond(404, {"error": {"message": "unknown path"}})
|
||||
return
|
||||
self._respond(200, {"data": [{"id": model} for model in state.models]})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
"""根据版面或文本提示返回稳定结果,支持失败任务测试。"""
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
state.requests.append((self.path, self.headers.get("Authorization", ""), body))
|
||||
if not self._authorized():
|
||||
return
|
||||
if self.path != "/proxy/v1/chat/completions":
|
||||
self._respond(404, {"error": {"message": "unknown path"}})
|
||||
return
|
||||
if state.chat_status != 200:
|
||||
self._respond(state.chat_status, {"error": {"message": "inference rejected"}})
|
||||
return
|
||||
content = "Remote VLM text"
|
||||
if "Layout Detection:" in json.dumps(body["messages"]):
|
||||
content = "<|box_start|>50 50 950 250<|box_end|><|ref_start|>text<|ref_end|>"
|
||||
self._respond(200, {"choices": [{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}]})
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
state.url = f"http://127.0.0.1:{server.server_port}/proxy/v1"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield state
|
||||
finally:
|
||||
for predictor in ModelSingleton._models.values():
|
||||
predictor.client._client.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _settings(server: _OpenAIServer, **overrides: Any) -> VlmConfig:
|
||||
"""为模拟服务生成连接配置,允许单个用例覆盖连接字段。"""
|
||||
return VlmConfig.model_validate({"server_url": server.url, "api_key": server.api_key, **overrides})
|
||||
|
||||
|
||||
def test_remote_client_protocol_and_cache(openai_server: _OpenAIServer, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""真实 HTTP 验证模型发现、显式模型、认证、代理路径、参数及缓存隔离。"""
|
||||
from mineru.model.vlm import runtime, selector
|
||||
|
||||
local_engine = MagicMock(side_effect=AssertionError("local VLM engine must not load"))
|
||||
local_weights = MagicMock(side_effect=AssertionError("local VLM weights must not download"))
|
||||
monkeypatch.setattr(selector, "get_vlm_engine", local_engine)
|
||||
monkeypatch.setattr(type(runtime.MINERU_2_5_PRO_2605_1_2B), "ensure", local_weights)
|
||||
settings = _settings(openai_server, http_timeout=9, max_concurrency=3)
|
||||
predictor, backend = get_vlm_predictor(settings)
|
||||
assert backend == "http-client"
|
||||
assert predictor.client.model_name == "test-model"
|
||||
assert predictor.client.max_concurrency == 3
|
||||
assert predictor.client._client.timeout.read == 9
|
||||
assert predictor.client.predict(Image.new("RGB", (32, 32)), "Text Recognition:") == "Remote VLM text"
|
||||
assert get_vlm_predictor(settings)[0] is predictor
|
||||
for patch in ({"model": "test-model"}, {"http_timeout": 10}, {"max_concurrency": 4}):
|
||||
assert get_vlm_predictor(VlmConfig.model_validate({**settings.model_dump(), **patch}))[0] is not predictor
|
||||
openai_server.api_key = "second-key"
|
||||
assert get_vlm_predictor(_settings(openai_server))[0] is not predictor
|
||||
assert {request[0] for request in openai_server.requests} == {"/proxy/v1/models", "/proxy/v1/chat/completions"}
|
||||
body = next(request[2] for request in openai_server.requests if request[2] is not None)
|
||||
assert body["model"] == "test-model"
|
||||
assert all(request[1] == "Bearer test-key" for request in openai_server.requests[:-1])
|
||||
local_engine.assert_not_called()
|
||||
local_weights.assert_not_called()
|
||||
|
||||
|
||||
def test_remote_models_and_auth_failures(openai_server: _OpenAIServer) -> None:
|
||||
"""认证失败、多模型自动发现及不存在模型均显式失败,之后可以用有效模型重试。"""
|
||||
with pytest.raises(Exception, match="401"):
|
||||
get_vlm_predictor(_settings(openai_server, api_key="wrong-key"))
|
||||
openai_server.models = []
|
||||
with pytest.raises(Exception, match="exactly one model"):
|
||||
get_vlm_predictor(_settings(openai_server))
|
||||
openai_server.models = ["first", "second"]
|
||||
with pytest.raises(Exception, match="exactly one model"):
|
||||
get_vlm_predictor(_settings(openai_server))
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
get_vlm_predictor(_settings(openai_server, model="missing"))
|
||||
predictor, _ = get_vlm_predictor(_settings(openai_server, model="second"))
|
||||
assert predictor.client.model_name == "second"
|
||||
|
||||
|
||||
def test_remote_optional_auth_and_concurrent_credentials(openai_server: _OpenAIServer) -> None:
|
||||
"""允许无鉴权服务,并验证同一服务的不同客户端并发请求不会串用凭据。"""
|
||||
openai_server.api_key = ""
|
||||
predictor, _ = get_vlm_predictor(_settings(openai_server))
|
||||
assert predictor.client.predict(None, "test") == "Remote VLM text"
|
||||
assert all(request[1] == "" for request in openai_server.requests)
|
||||
|
||||
def infer(key: str) -> str:
|
||||
"""使用独立 Key 请求同一上游,返回真实客户端收到的文本。"""
|
||||
client, _ = get_vlm_predictor(_settings(openai_server, api_key=key))
|
||||
return client.client.predict(None, key)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
assert list(executor.map(infer, ["first-key", "second-key"])) == ["Remote VLM text", "Remote VLM text"]
|
||||
for _path, header, body in openai_server.requests:
|
||||
if body is not None and header:
|
||||
assert header.removeprefix("Bearer ") in json.dumps(body["messages"])
|
||||
|
||||
|
||||
def test_remote_timeout_propagates(openai_server: _OpenAIServer, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""请求超时保留原始异常,不选择本地引擎重试。"""
|
||||
predictor, _ = get_vlm_predictor(_settings(openai_server, http_timeout=1))
|
||||
monkeypatch.setattr(predictor.client._client, "post", MagicMock(side_effect=httpx.ReadTimeout("timed out")))
|
||||
with pytest.raises(httpx.ReadTimeout, match="timed out"):
|
||||
predictor.client.predict(None, "test")
|
||||
|
||||
|
||||
def test_remote_preflight_and_preload(openai_server: _OpenAIServer, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""远程预检仅需本地 Hybrid 依赖,预加载和推理复用同一个客户端。"""
|
||||
checks: list[str] = []
|
||||
local_loads: list[str] = []
|
||||
monkeypatch.setattr(api_server, "ensure_tier_runtime_dependencies", checks.append)
|
||||
monkeypatch.setattr(api_server, "_preload_local_models", local_loads.append)
|
||||
settings = _settings(openai_server)
|
||||
api_server._preflight_tier_dependencies("standard", settings)
|
||||
result = api_server._preload_server_models("standard", language="en", vlm_config=settings)
|
||||
assert result.engine == "http-client"
|
||||
assert checks == ["basic"]
|
||||
assert local_loads == ["en"]
|
||||
request_count = len(openai_server.requests)
|
||||
get_vlm_predictor(settings)
|
||||
assert len(openai_server.requests) == request_count
|
||||
api_server._preload_server_models("basic", language="ch", vlm_config=settings)
|
||||
assert len(openai_server.requests) == request_count
|
||||
|
||||
|
||||
def test_remote_preload_failure_is_reported(
|
||||
openai_server: _OpenAIServer, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""预加载认证失败通过现有健康和能力错误暴露,未预加载时保持懒连接。"""
|
||||
monkeypatch.setattr(api_server, "ensure_tier_runtime_dependencies", lambda tier: None)
|
||||
settings = _settings(openai_server, api_key="wrong-key")
|
||||
lazy = api_server.create_app(upload_dir=str(tmp_path / "lazy"), vlm_config=settings)
|
||||
with TestClient(lazy) as client:
|
||||
assert client.get("/v1/health").status_code == 200
|
||||
assert openai_server.requests == []
|
||||
preloaded = api_server.create_app(upload_dir=str(tmp_path / "preload"), vlm_config=settings, preload_models=True)
|
||||
with TestClient(preloaded) as client:
|
||||
for endpoint in ("health", "tiers", "models"):
|
||||
response = client.get(f"/v1/{endpoint}")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error"]["code"] == "model_preload_failed"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hybrid_stub(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""隔离本地神经模型,保留真实 PDF 准备、窗口、HTTP VLM 和 Middle JSON 后处理。"""
|
||||
from mineru.backend.analysis.pdf import pipeline, window
|
||||
|
||||
context = MagicMock()
|
||||
context.device = "cpu"
|
||||
context.layout_model.batch_predict.return_value = [[{"bbox": [20, 20, 250, 80], "label": "text"}]]
|
||||
monkeypatch.setattr(
|
||||
pipeline,
|
||||
"HybridLocalModelContextSingleton",
|
||||
MagicMock(return_value=MagicMock(get_model=MagicMock(return_value=context))),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "clean_memory", lambda device: None)
|
||||
|
||||
def retain_vlm_text(
|
||||
images: object, pages: object, blocks: list[list[dict[str, Any]]], *args: object
|
||||
) -> list[list[dict[str, Any]]]:
|
||||
"""保留远程正文并模拟 Hybrid 提供的行框,满足 PDF 文本块校验合同。"""
|
||||
for page in blocks:
|
||||
for block in page:
|
||||
if block.get("content"):
|
||||
block["lines"] = [{"bbox": block["bbox"]}]
|
||||
return blocks
|
||||
|
||||
monkeypatch.setattr(window, "_process_text_and_formulas", retain_vlm_text)
|
||||
monkeypatch.setattr(window, "_apply_seal_ocr", lambda *args: None)
|
||||
monkeypatch.setattr(api_server, "ensure_tier_runtime_dependencies", lambda tier: None)
|
||||
|
||||
|
||||
def _pdf_input(tmp_path: Path) -> Path:
|
||||
"""构造单页 PDF fixture,供同步、异步和 API 解析复用。"""
|
||||
path = tmp_path / "input.pdf"
|
||||
document = canvas.Canvas(str(path), pagesize=(300, 200))
|
||||
document.drawString(20, 160, "Source text")
|
||||
document.save()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_mode", [False, True])
|
||||
@pytest.mark.parametrize("image_input", [False, True])
|
||||
def test_python_parse_uses_global_remote_vlm(
|
||||
openai_server: _OpenAIServer,
|
||||
hybrid_stub: None,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
async_mode: bool,
|
||||
image_input: bool,
|
||||
) -> None:
|
||||
"""同步及异步入口读取全局 VLM 配置,PDF 和图片转 PDF 均产生真实 Middle JSON。"""
|
||||
monkeypatch.setattr(config.model, "vlm", _settings(openai_server))
|
||||
source = _pdf_input(tmp_path)
|
||||
if image_input:
|
||||
source = tmp_path / "input.png"
|
||||
Image.new("RGB", (300, 200), "white").save(source)
|
||||
result = asyncio.run(parse_async(source, ocr_mode="ocr")) if async_mode else parse(source, ocr_mode="ocr")
|
||||
assert result.middle_json.file_suffix == "pdf"
|
||||
assert result.middle_json.effort == "high"
|
||||
assert result.middle_json.pages[0].page_idx == 0
|
||||
assert "Remote VLM text" in result.markdown()
|
||||
assert any(request[2] is not None for request in openai_server.requests)
|
||||
|
||||
|
||||
def test_kit_parse_uses_global_remote_vlm(
|
||||
openai_server: _OpenAIServer, hybrid_stub: None, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""实际执行 mineru-kit parse 命令,验证全局 VLM 配置进入解析并生成 Markdown 文件。"""
|
||||
from mineru.kit.main import app
|
||||
|
||||
monkeypatch.setattr(config.model, "vlm", _settings(openai_server))
|
||||
output = tmp_path / "result.md"
|
||||
result = CliRunner().invoke(app, ["parse", str(_pdf_input(tmp_path)), "-o", str(output), "--ocr-mode", "ocr"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Remote VLM text" in output.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tier", ["standard", "advanced"])
|
||||
@pytest.mark.parametrize("chat_status", [200, 400])
|
||||
def test_api_job_through_http_vlm_to_middle_json(
|
||||
openai_server: _OpenAIServer,
|
||||
hybrid_stub: None,
|
||||
tmp_path: Path,
|
||||
tier: str,
|
||||
chat_status: int,
|
||||
) -> None:
|
||||
"""API 任务经真实 HTTP VLM 生成 Middle JSON,远程推理错误保持任务失败。"""
|
||||
openai_server.chat_status = chat_status
|
||||
source = _pdf_input(tmp_path)
|
||||
app = api_server.create_app(
|
||||
upload_dir=str(tmp_path / "api"),
|
||||
allow_local_source=True,
|
||||
api_key="inbound-key",
|
||||
no_flash=True,
|
||||
vlm_config=_settings(openai_server),
|
||||
)
|
||||
with TestClient(app, headers={"Authorization": "Bearer inbound-key"}) as client:
|
||||
submitted = client.post(
|
||||
"/v1/parse/jobs",
|
||||
json={
|
||||
"files": [{"source": {"type": "local", "path": str(source)}}],
|
||||
"tier": tier,
|
||||
"ocr_mode": "ocr",
|
||||
"output_formats": ["middle_json"],
|
||||
},
|
||||
)
|
||||
assert submitted.status_code == 202, submitted.text
|
||||
job_id = submitted.json()["job_id"]
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
job = client.get(f"/v1/parse/jobs/{job_id}").json()
|
||||
if job["status"] in {"completed", "failed", "partial"}:
|
||||
break
|
||||
assert time.monotonic() < deadline, job
|
||||
time.sleep(0.01)
|
||||
if chat_status != 200:
|
||||
assert job["status"] == "failed", job
|
||||
assert job["files"][0]["error"]["code"] == "parse_failed"
|
||||
return
|
||||
assert job["status"] == "completed", job
|
||||
file_id = job["files"][0]["output_files"]["middle_json"]["file_id"]
|
||||
output = client.get(f"/v1/files/{file_id}/content")
|
||||
assert output.status_code == 200
|
||||
assert output.json()["effort"] == ("high" if tier == "standard" else "xhigh")
|
||||
assert "Remote VLM text" in output.text
|
||||
assert all(request[1] == "Bearer test-key" for request in openai_server.requests)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["flash", "medium"])
|
||||
def test_low_efforts_never_create_remote_client(
|
||||
openai_server: _OpenAIServer,
|
||||
hybrid_stub: None,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
effort: str,
|
||||
) -> None:
|
||||
"""即使配置了远程服务,Flash 和 Basic 分析也不触发 VLM 客户端构造。"""
|
||||
from mineru.backend.analysis.pdf import pipeline
|
||||
|
||||
remote_factory = MagicMock(side_effect=AssertionError("VLM must not initialize"))
|
||||
monkeypatch.setattr(pipeline, "get_vlm_predictor", remote_factory)
|
||||
monkeypatch.setattr(pipeline, "process_pdf_windows", MagicMock(return_value=[]))
|
||||
result = pipeline.analyze_pdf(
|
||||
_pdf_input(tmp_path).read_bytes(), effort=effort, parse_mode="txt", vlm_config=_settings(openai_server)
|
||||
)
|
||||
assert result.effort == effort
|
||||
assert openai_server.requests == []
|
||||
remote_factory.assert_not_called()
|
||||
Reference in New Issue
Block a user