Merge pull request #5466 from myhloli/next

Next
This commit is contained in:
Xiaomeng Zhao
2026-09-05 15:41:46 +08:00
committed by GitHub
98 changed files with 16386 additions and 11354 deletions
+4 -1
View File
@@ -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` 返回值作为能力发现事实。客户端不应根据启动参数猜测具体能力。
+4
View File
@@ -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 的安全根目录内。
+53 -3
View File
@@ -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
+37 -6
View File
@@ -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 内。
## 预览与兼容边界
+4 -4
View File
@@ -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-服务)。
边界:
+45
View File
@@ -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` 不进入正式命令设计。
原因:
+6 -3
View File
@@ -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`。
## 输入与输出
+2
View File
@@ -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 产物读取。 |
## 行为流程
-19
View File
@@ -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")
+4 -10
View File
@@ -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
+5 -1
View File
@@ -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,
)
+72
View File
@@ -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",
+26 -5
View File
@@ -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 []),
+6 -8
View File
@@ -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,
)
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+88
View File
@@ -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"]
+193
View File
@@ -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
View File
@@ -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] = []
+113
View File
@@ -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",
]
+591
View File
@@ -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",
]
+963
View File
@@ -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",
]
+386
View File
@@ -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",
]
+1 -11
View File
@@ -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,
+9 -5
View File
@@ -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(
+1 -1
View File
@@ -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)
+20 -19
View File
@@ -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
+1 -1
View File
@@ -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:
+175
View File
@@ -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
View File
@@ -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,
+1 -25
View File
@@ -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
+41
View File
@@ -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),
)
+174 -34
View File
@@ -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,
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
# Copyright (c) Opendatalab. All rights reserved.
"""VLM model runtime helpers."""
__all__ = ["engine_utils", "runtime"]
__all__ = ["client", "engine_utils", "runtime"]
+37
View File
@@ -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"]
+21 -5
View File
@@ -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:
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+8
View File
@@ -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(
+44
View File
@@ -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;
+50 -1
View File
@@ -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";
});
}
+60
View File
@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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 [];
}
+11 -11
View File
@@ -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
View File
@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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),
];
}
+16
View File
@@ -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 回退应独立复测并定位。
+220
View File
@@ -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:
"""验证页首宽幅私用区重复字形不再进入文本输出。"""
+53 -1
View File
@@ -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
+6 -6
View File
@@ -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,
+45 -5
View File
@@ -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"]
+511 -30
View File
@@ -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("&lt;bad&gt; &amp; 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, "&lt;bad&gt;");
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
+106
View File
@@ -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())
+120 -14
View File
@@ -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
+108
View File
@@ -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
+108
View File
@@ -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",
+95
View File
@@ -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
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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),
)
+172
View File
@@ -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"
+373
View File
@@ -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()