diff --git a/docs/next/api/health-models.md b/docs/next/api/health-models.md index 713da2bd..d33f19c4 100644 --- a/docs/next/api/health-models.md +++ b/docs/next/api/health-models.md @@ -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` 返回值作为能力发现事实。客户端不应根据启动参数猜测具体能力。 diff --git a/docs/next/api/parse-jobs.md b/docs/next/api/parse-jobs.md index 3ac7ef5b..2f9eb765 100644 --- a/docs/next/api/parse-jobs.md +++ b/docs/next/api/parse-jobs.md @@ -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 的安全根目录内。 diff --git a/docs/next/cli/mineru-kit-api-server.md b/docs/next/cli/mineru-kit-api-server.md index f5c95264..1ebef0b5 100644 --- a/docs/next/cli/mineru-kit-api-server.md +++ b/docs/next/cli/mineru-kit-api-server.md @@ -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 diff --git a/docs/next/cli/mineru-kit-gradio.md b/docs/next/cli/mineru-kit-gradio.md index bfbb6ba1..335530ad 100644 --- a/docs/next/cli/mineru-kit-gradio.md +++ b/docs/next/cli/mineru-kit-gradio.md @@ -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//` 目录,包含源文件、Middle JSON、基础文本产物以及可选的 `origin.pdf`、`layout.pdf` 和图片资源。路径只向 Gradio 暴露在配置的 output root 内。 ## 预览与兼容边界 diff --git a/docs/next/cli/mineru-kit-vlm-server.md b/docs/next/cli/mineru-kit-vlm-server.md index bacedf75..cbfb8256 100644 --- a/docs/next/cli/mineru-kit-vlm-server.md +++ b/docs/next/cli/mineru-kit-vlm-server.md @@ -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-服务)。 边界: diff --git a/docs/next/config.md b/docs/next/config.md index c7f41672..cd57ada3 100644 --- a/docs/next/config.md +++ b/docs/next/config.md @@ -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 diff --git a/docs/next/decisions/0017-mineru-kit-api-server-command.md b/docs/next/decisions/0017-mineru-kit-api-server-command.md index 839529e4..a3b1465d 100644 --- a/docs/next/decisions/0017-mineru-kit-api-server-command.md +++ b/docs/next/decisions/0017-mineru-kit-api-server-command.md @@ -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` 不进入正式命令设计。 原因: diff --git a/docs/next/page-ranges.md b/docs/next/page-ranges.md index 96f48f81..4e5cd871 100644 --- a/docs/next/page-ranges.md +++ b/docs/next/page-ranges.md @@ -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`。 ## 输入与输出 diff --git a/docs/next/sdk/api-parser.md b/docs/next/sdk/api-parser.md index 6ac1d3e6..84869c11 100644 --- a/docs/next/sdk/api-parser.md +++ b/docs/next/sdk/api-parser.md @@ -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 产物读取。 | ## 行为流程 diff --git a/mineru/backend/analysis/pdf/layout.py b/mineru/backend/analysis/pdf/layout.py index b6714f60..eb0014a2 100644 --- a/mineru/backend/analysis/pdf/layout.py +++ b/mineru/backend/analysis/pdf/layout.py @@ -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") diff --git a/mineru/backend/analysis/pdf/pipeline.py b/mineru/backend/analysis/pdf/pipeline.py index 4709b23a..8e5dd52e 100644 --- a/mineru/backend/analysis/pdf/pipeline.py +++ b/mineru/backend/analysis/pdf/pipeline.py @@ -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 diff --git a/mineru/backend/analyze.py b/mineru/backend/analyze.py index 1564b071..077b57bc 100644 --- a/mineru/backend/analyze.py +++ b/mineru/backend/analyze.py @@ -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, ) diff --git a/mineru/config.py b/mineru/config.py index bd74d577..a64c04d2 100644 --- a/mineru/config.py +++ b/mineru/config.py @@ -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", diff --git a/mineru/kit/commands/api_server.py b/mineru/kit/commands/api_server.py index 1ab39237..ad8f2388 100644 --- a/mineru/kit/commands/api_server.py +++ b/mineru/kit/commands/api_server.py @@ -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 []), diff --git a/mineru/kit/commands/gradio.py b/mineru/kit/commands/gradio.py index 29db668e..25a08406 100644 --- a/mineru/kit/commands/gradio.py +++ b/mineru/kit/commands/gradio.py @@ -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, ) diff --git a/mineru/kit/gradio/__init__.py b/mineru/kit/gradio/__init__.py index 49c4b72b..97f01308 100644 --- a/mineru/kit/gradio/__init__.py +++ b/mineru/kit/gradio/__init__.py @@ -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", diff --git a/mineru/kit/gradio/app.py b/mineru/kit/gradio/app.py index 3f81cc05..3d48aa1e 100644 --- a/mineru/kit/gradio/app.py +++ b/mineru/kit/gradio/app.py @@ -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'{label}') - return ( - '
' - f'
{"".join(items)}
' - f"{safe_message}" - "
" - ) - - 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, '
暂无源文档预览
', 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, '
源文档预览
', 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, '
源文档预览
', 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, '
该格式暂无源文档预览
', 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='
暂无源文档预览
', 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 '
结果已生成
' + ) + 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 '
结果已生成
' - 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='
暂无源文档预览
', 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 diff --git a/mineru/kit/gradio/client.py b/mineru/kit/gradio/client.py index 49796383..f7bfc7ce 100644 --- a/mineru/kit/gradio/client.py +++ b/mineru/kit/gradio/client.py @@ -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", diff --git a/mineru/kit/gradio/page_range.py b/mineru/kit/gradio/page_range.py new file mode 100644 index 00000000..62a917be --- /dev/null +++ b/mineru/kit/gradio/page_range.py @@ -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"] diff --git a/mineru/kit/gradio/status.py b/mineru/kit/gradio/status.py new file mode 100644 index 00000000..ed80d5de --- /dev/null +++ b/mineru/kit/gradio/status.py @@ -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'{html.escape(chinese)}' + ) + + +@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'
' + f'{_localized_text("status_step_" + key, english, chinese)}
' + ) + 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 ( + '
' + f'
{title}
' + f'
{"".join(items)}
' + f'
{latest_html}
' + ) + + +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", +] diff --git a/mineru/model/flash/pdf/document.py b/mineru/model/flash/pdf/document.py index b78c4a42..015c0c0c 100644 --- a/mineru/model/flash/pdf/document.py +++ b/mineru/model/flash/pdf/document.py @@ -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, + ) diff --git a/mineru/model/flash/pdf/inline/__init__.py b/mineru/model/flash/pdf/inline/__init__.py new file mode 100644 index 00000000..b2b2d2a5 --- /dev/null +++ b/mineru/model/flash/pdf/inline/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Opendatalab. All rights reserved. +"""按职责拆分的 Flash PDF 内部实现。""" + +__all__: list[str] = [] diff --git a/mineru/model/flash/pdf/inline/common.py b/mineru/model/flash/pdf/inline/common.py new file mode 100644 index 00000000..174f9009 --- /dev/null +++ b/mineru/model/flash/pdf/inline/common.py @@ -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", +] diff --git a/mineru/model/flash/pdf/inline/detection.py b/mineru/model/flash/pdf/inline/detection.py new file mode 100644 index 00000000..37ea8a78 --- /dev/null +++ b/mineru/model/flash/pdf/inline/detection.py @@ -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", +] diff --git a/mineru/model/flash/pdf/inline/matching.py b/mineru/model/flash/pdf/inline/matching.py new file mode 100644 index 00000000..682d3ce7 --- /dev/null +++ b/mineru/model/flash/pdf/inline/matching.py @@ -0,0 +1,1186 @@ +# Copyright (c) Opendatalab. All rights reserved. +"""对齐原生行证据与输出块文本,保留来源和偏移。""" + +from __future__ import annotations + +import html +from typing import TYPE_CHECKING, Any, Sequence + +from loguru import logger + +from .....types import BBox +from .....utils.text import is_hyphen_at_line_end + +if TYPE_CHECKING: + from ..models import _LineItem + from ..native_text import _NativeVisualResplit +from .common import _bbox_overlap_ratio, _canonical_styles, _coerce_bbox, _normalize_match_fragment, _ordered_line_chars +from .types import ( + _PDF_GEOMETRIC_TEXT_STYLES, + _PDF_TEXT_STYLE_TARGET_BLOCK_TYPES, + PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES, + PDFTextEvidenceLine, + PDFTextLinkLine, + PDFTextLinkRange, + PDFTextScriptLine, + PDFTextScriptRange, + PDFTextStyle, + PDFTextStyleLine, + PDFTextStyleRange, + _LineProjectionMatch, + _MatchedLinkRange, + _ProjectedChar, + _RawLinkInterval, +) + + +def _resplit_evidence_segments( + evidence_text: str, + resplit: _NativeVisualResplit, +) -> list[tuple[_LineItem, int, int, str]] | None: + """按原字符身份把紧凑 evidence 文本映射到每个重切成员区间。""" + + source_spans: list[tuple[int, int]] = [] + spans_by_object_id: dict[int, tuple[int, int]] = {} + spans_by_char_idx: dict[int, list[tuple[int, int]]] = {} + source_parts: list[str] = [] + cursor = 0 + for char in _ordered_line_chars(resplit.source): + fragment = _normalize_match_fragment(char.get("char")) + if not fragment: + continue + span = (cursor, cursor + len(fragment)) + source_spans.append(span) + spans_by_object_id[id(char)] = span + char_idx = char.get("char_idx") + if isinstance(char_idx, int) and not isinstance(char_idx, bool): + spans_by_char_idx.setdefault(char_idx, []).append(span) + source_parts.append(fragment) + cursor = span[1] + if "".join(source_parts) != evidence_text: + return None + + used_spans: list[tuple[int, int]] = [] + segments: list[tuple[_LineItem, int, int, str]] = [] + for member in sorted( + resplit.members, + key=lambda item: (item.run_index, item.source_index), + ): + member_spans: list[tuple[int, int]] = [] + member_parts: list[str] = [] + for char in _ordered_line_chars(member): + fragment = _normalize_match_fragment(char.get("char")) + if not fragment: + continue + span = spans_by_object_id.get(id(char)) + if span is None: + char_idx = char.get("char_idx") + candidates = ( + spans_by_char_idx.get(char_idx, []) if isinstance(char_idx, int) and not isinstance(char_idx, bool) else [] + ) + span = candidates[0] if len(candidates) == 1 else None + if span is None or evidence_text[span[0] : span[1]] != fragment: + return None + member_spans.append(span) + member_parts.append(fragment) + if not member_spans: + return None + member_start = min(start for start, _end in member_spans) + member_end = max(end for _start, end in member_spans) + member_text = "".join(member_parts) + if ( + sum(end - start for start, end in member_spans) != member_end - member_start + or evidence_text[member_start:member_end] != member_text + ): + return None + used_spans.extend(member_spans) + segments.append( + (member, member_start, member_end, member_text), + ) + if sorted(used_spans) != source_spans: + return None + return segments + + +def _partition_resplit_text_evidence( + style_lines: list[PDFTextStyleLine], + link_lines: list[PDFTextLinkLine], + resplits: dict[int, _NativeVisualResplit], +) -> tuple[list[PDFTextStyleLine], list[PDFTextLinkLine]]: + """只替换被重切粗行的样式与链接 evidence,其它行保持原对象和顺序。""" + + if not resplits: + return style_lines, link_lines + + partitioned_styles: list[PDFTextStyleLine] = [] + for line in style_lines: + resplit = resplits.get(line.source_index) + if resplit is None: + partitioned_styles.append(line) + continue + segments = _resplit_evidence_segments(line.text, resplit) + if segments is None: + logger.warning( + "Keep coarse PDF style evidence after an unsafe resplit mapping: " + f"source_index={line.source_index}, text={line.text!r}" + ) + partitioned_styles.append(line) + continue + for member, member_start, member_end, member_text in segments: + style_ranges = tuple( + PDFTextStyleRange( + start=max(member_start, style_range.start) - member_start, + end=min(member_end, style_range.end) - member_start, + styles=style_range.styles, + ) + for style_range in line.style_ranges + if max(member_start, style_range.start) < min(member_end, style_range.end) + ) + partitioned_styles.append( + PDFTextStyleLine( + bbox=member.bbox, + text=member_text, + style_ranges=style_ranges, + source_index=member.source_index, + ) + ) + + partitioned_links: list[PDFTextLinkLine] = [] + for line in link_lines: + resplit = resplits.get(line.source_index) + if resplit is None: + partitioned_links.append(line) + continue + segments = _resplit_evidence_segments(line.text, resplit) + if segments is None: + logger.warning( + "Keep coarse PDF link evidence after an unsafe resplit mapping: " + f"source_index={line.source_index}, text={line.text!r}" + ) + partitioned_links.append(line) + continue + for member, member_start, member_end, member_text in segments: + link_ranges = tuple( + PDFTextLinkRange( + start=max(member_start, link_range.start) - member_start, + end=min(member_end, link_range.end) - member_start, + target=link_range.target, + ) + for link_range in line.link_ranges + if max(member_start, link_range.start) < min(member_end, link_range.end) + ) + if not link_ranges: + continue + partitioned_links.append( + PDFTextLinkLine( + bbox=member.bbox, + text=member_text, + link_ranges=link_ranges, + source_index=member.source_index, + ) + ) + return partitioned_styles, partitioned_links + + +def _realign_repaired_text_evidence( + style_lines: list[PDFTextStyleLine], + link_lines: list[PDFTextLinkLine], + line_bboxes: dict[int, BBox], + resplits: dict[int, _NativeVisualResplit], +) -> tuple[list[PDFTextStyleLine], list[PDFTextLinkLine]]: + """同步未重切修复行的 evidence 框,再按字符身份切分发生重切的样式与链接。""" + + aligned_styles = style_lines + for index, line in enumerate(style_lines): + bbox = line_bboxes.get(line.source_index) + if line.source_index in resplits or bbox is None or bbox == line.bbox: + continue + if aligned_styles is style_lines: + aligned_styles = list(style_lines) + aligned_styles[index] = PDFTextStyleLine( + bbox=bbox, + text=line.text, + style_ranges=line.style_ranges, + source_index=line.source_index, + ) + + aligned_links = link_lines + for index, line in enumerate(link_lines): + bbox = line_bboxes.get(line.source_index) + if line.source_index in resplits or bbox is None or bbox == line.bbox: + continue + if aligned_links is link_lines: + aligned_links = list(link_lines) + aligned_links[index] = PDFTextLinkLine( + bbox=bbox, + text=line.text, + link_ranges=line.link_ranges, + source_index=line.source_index, + ) + + return _partition_resplit_text_evidence( + aligned_styles, + aligned_links, + resplits, + ) + + +def _block_bbox_to_page_bbox(value: Any, page_size: tuple[float, float]) -> BBox | None: + """把 model-list 的归一化 bbox 转回页面 point,同时兼容已是绝对坐标的内部输入。""" + + bbox = _coerce_bbox(value) + if bbox is None: + return None + if all(0.0 <= coordinate <= 1.0 for coordinate in bbox): + return ( + bbox[0] * page_size[0], + bbox[1] * page_size[1], + bbox[2] * page_size[0], + bbox[3] * page_size[1], + ) + return bbox + + +def _line_block_score(line_bbox: BBox, block_bbox: BBox) -> tuple[float, float, float]: + """计算文本行归属 block 的中心包含、重叠率与紧致度评分。""" + + center_x = (line_bbox[0] + line_bbox[2]) / 2 + center_y = (line_bbox[1] + line_bbox[3]) / 2 + center_inside = float(block_bbox[0] <= center_x <= block_bbox[2] and block_bbox[1] <= center_y <= block_bbox[3]) + overlap_ratio = _bbox_overlap_ratio(line_bbox, block_bbox) + block_area = (block_bbox[2] - block_bbox[0]) * (block_bbox[3] - block_bbox[1]) + return center_inside, overlap_ratio, -block_area + + +def _assign_lines_to_blocks( + blocks: list[dict[str, Any]], + lines: Sequence[PDFTextEvidenceLine], + page_size: tuple[float, float], +) -> dict[int, list[PDFTextEvidenceLine]]: + """把每个视觉文本行唯一分配给最匹配的自然语言 block。""" + + target_bboxes = { + block_index: block_bbox + for block_index, block in enumerate(blocks) + if block.get("type") in PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES + and isinstance(block.get("content"), str) + and (block_bbox := _block_bbox_to_page_bbox(block.get("bbox"), page_size)) is not None + } + assignments: dict[int, list[PDFTextEvidenceLine]] = {} + for line in lines: + matches = [ + (block_index, _line_block_score(line.bbox, block_bbox)) + for block_index, block_bbox in target_bboxes.items() + if ( + block_bbox[0] <= (line.bbox[0] + line.bbox[2]) / 2 <= block_bbox[2] + and block_bbox[1] <= (line.bbox[1] + line.bbox[3]) / 2 <= block_bbox[3] + ) + or _bbox_overlap_ratio(line.bbox, block_bbox) >= 0.5 + ] + if not matches: + continue + block_index, _score = max(matches, key=lambda item: (*item[1], -item[0])) + assignments.setdefault(block_index, []).append(line) + for block_lines in assignments.values(): + block_lines.sort( + key=lambda line: ( + line.source_index, + line.bbox[1], + line.bbox[0], + ) + ) + return assignments + + +def _assign_script_lines_to_blocks( + blocks: list[dict[str, Any]], + lines: Sequence[PDFTextScriptLine], + page_size: tuple[float, float], +) -> dict[int, list[PDFTextScriptLine]]: + """保留整行主归属,并为无法投影的脚本区间补充 tight bbox 备用归属。""" + + target_bboxes = { + block_index: block_bbox + for block_index, block in enumerate(blocks) + if block.get("type") in PDF_NATURAL_TEXT_STYLE_BLOCK_TYPES + and isinstance(block.get("content"), str) + and (block_bbox := _block_bbox_to_page_bbox(block.get("bbox"), page_size)) is not None + } + target_projected = { + block_index: _project_content_chars(str(blocks[block_index]["content"])) for block_index in target_bboxes + } + primary_assignments = _assign_lines_to_blocks(blocks, lines, page_size) + assignments: dict[int, list[PDFTextScriptLine]] = { + block_index: [line for line in block_lines if isinstance(line, PDFTextScriptLine)] + for block_index, block_lines in primary_assignments.items() + } + primary_block_by_line = {id(line): block_index for block_index, block_lines in assignments.items() for line in block_lines} + fallback_ranges: dict[tuple[int, int], list[PDFTextScriptRange]] = {} + for line_index, line in enumerate(lines): + for script_range in line.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, + ) + primary_block_index = primary_block_by_line.get(id(line)) + if primary_block_index is not None and _match_script_line_ranges( + target_projected[primary_block_index], + evidence_line, + ): + continue + matches = [ + ( + block_index, + _line_block_score(script_range.bbox, block_bbox), + ) + for block_index, block_bbox in target_bboxes.items() + if block_index != primary_block_index + and _match_script_line_ranges( + target_projected[block_index], + evidence_line, + ) + if ( + block_bbox[0] <= (script_range.bbox[0] + script_range.bbox[2]) / 2 <= block_bbox[2] + and block_bbox[1] <= (script_range.bbox[1] + script_range.bbox[3]) / 2 <= block_bbox[3] + ) + or _bbox_overlap_ratio(script_range.bbox, block_bbox) >= 0.5 + ] + if not matches: + continue + block_index, _score = max(matches, key=lambda item: (*item[1], -item[0])) + fallback_ranges.setdefault((block_index, line_index), []).append(script_range) + for (block_index, line_index), script_ranges in fallback_ranges.items(): + line = lines[line_index] + assignments.setdefault(block_index, []).append( + PDFTextScriptLine( + bbox=line.bbox, + text=line.text, + script_ranges=tuple(script_ranges), + source_index=line.source_index, + angle=line.angle, + ) + ) + for block_lines in assignments.values(): + block_lines.sort(key=lambda line: (line.source_index, line.bbox[1], line.bbox[0])) + return assignments + + +def _filter_line_styles_for_block( + lines: Sequence[PDFTextStyleLine], + block_type: Any, +) -> list[PDFTextStyleLine]: + """按目标 block 类型过滤样式区间,同时保留无样式物理行用于顺序对齐。""" + + output: list[PDFTextStyleLine] = [] + for line in lines: + filtered_ranges: list[PDFTextStyleRange] = [] + for style_range in line.style_ranges: + styles = _canonical_styles( + style + for style in style_range.styles + if block_type + in _PDF_TEXT_STYLE_TARGET_BLOCK_TYPES.get( + style, + frozenset(), + ) + ) + if styles: + filtered_ranges.append( + PDFTextStyleRange( + style_range.start, + style_range.end, + styles, + ) + ) + output.append( + PDFTextStyleLine( + bbox=line.bbox, + text=line.text, + style_ranges=tuple(filtered_ranges), + source_index=line.source_index, + ) + ) + return output + + +def _project_content_chars(content: str) -> list[_ProjectedChar]: + """把原始文字投影为忽略空白和圆括号公式的可比较字符。""" + + projected: list[_ProjectedChar] = [] + pending_formula_gap = False + cursor = 0 + while cursor < len(content): + if content.startswith(r"\(", cursor): + formula_end = content.find(r"\)", cursor + 2) + if formula_end >= 0: + cursor = formula_end + 2 + pending_formula_gap = True + continue + raw_char = content[cursor] + fragment = _normalize_match_fragment(raw_char) + for fragment_index, value in enumerate(fragment): + projected.append( + _ProjectedChar( + value=value, + raw_start=cursor, + raw_end=cursor + 1, + existing_styles=frozenset(), + formula_gap_before=pending_formula_gap and fragment_index == 0, + inside_hyperlink=False, + ) + ) + if fragment: + pending_formula_gap = False + cursor += 1 + return projected + + +def _all_occurrences(content: str, target: str, start: int) -> list[int]: + """返回 target 在 content 指定位置后的全部精确匹配起点。""" + + output: list[int] = [] + cursor = start + while target and (match := content.find(target, cursor)) >= 0: + output.append(match) + cursor = match + 1 + return output + + +def _resolve_fallback_occurrence( + content: str, + line: PDFTextStyleLine, + style_range: PDFTextStyleRange, + start: int, +) -> int | None: + """在整行无法对齐时,用唯一样式片段及两侧精确上下文选择位置。""" + + target = line.text[style_range.start : style_range.end] + occurrences = _all_occurrences(content, target, start) + if not occurrences: + return None + left_context = line.text[max(0, style_range.start - 12) : style_range.start] + right_context = line.text[style_range.end : style_range.end + 12] + scored = [ + ( + int(bool(left_context) and content[max(0, position - len(left_context)) : position] == left_context) + + int( + bool(right_context) + and content[position + len(target) : position + len(target) + len(right_context)] == right_context + ), + position, + ) + for position in occurrences + ] + has_geometric_style = bool(_PDF_GEOMETRIC_TEXT_STYLES.intersection(style_range.styles)) + if len(occurrences) == 1 and (has_geometric_style or len(target) >= 3): + return occurrences[0] + best_score = max(score for score, _position in scored) + best_positions = [position for score, position in scored if score == best_score] + if has_geometric_style: + return best_positions[0] if best_score > 0 and len(best_positions) == 1 else None + required_context_score = int(bool(left_context)) + int(bool(right_context)) + return ( + best_positions[0] + if required_context_score > 0 and best_score == required_context_score and len(best_positions) == 1 + else None + ) + + +def _match_line_across_formula_gaps( + line_text: str, + projected: Sequence[_ProjectedChar], + start: int, +) -> _LineProjectionMatch | None: + """用精确字符序列跨过公式空洞,将一个物理行对齐到 block 文本。""" + + if not line_text or start >= len(projected) or not any(token.formula_gap_before for token in projected[start:]): + return None + for projected_start in range(max(0, start), len(projected)): + first_token = projected[projected_start] + if not first_token.formula_gap_before and first_token.value != line_text[0]: + continue + states: dict[int, tuple[int | None, ...]] = {0: ()} + for projected_index in range(projected_start, len(projected)): + token = projected[projected_index] + next_states: dict[int, tuple[int | None, ...]] = {} + for source_index, mapping in states.items(): + if source_index >= len(line_text): + continue + if token.formula_gap_before: + candidate_source_indices = range( + source_index + 1, + len(line_text), + ) + elif line_text[source_index] == token.value: + candidate_source_indices = (source_index,) + else: + continue + for matched_source_index in candidate_source_indices: + if line_text[matched_source_index] != token.value: + continue + next_source_index = matched_source_index + 1 + next_mapping = ( + *mapping, + *([None] * (matched_source_index - source_index)), + projected_index, + ) + next_states.setdefault(next_source_index, next_mapping) + complete = next_states.get(len(line_text)) + if complete is not None: + return _LineProjectionMatch( + start=projected_start, + end=projected_index + 1, + source_to_projected=complete, + ) + if not next_states: + break + states = next_states + return None + + +def _ranges_from_line_projection( + line: PDFTextStyleLine, + match: _LineProjectionMatch, +) -> list[PDFTextStyleRange]: + """把物理行样式区间投影为公式字符被跳过后的 block 文本区间。""" + + output: list[PDFTextStyleRange] = [] + for style_range in line.style_ranges: + current_start: int | None = None + previous_index: int | None = None + for projected_index in match.source_to_projected[style_range.start : style_range.end]: + if projected_index is None: + if current_start is not None and previous_index is not None: + output.append( + PDFTextStyleRange( + current_start, + previous_index + 1, + style_range.styles, + ) + ) + current_start = None + previous_index = None + continue + if current_start is not None and previous_index is not None and projected_index != previous_index + 1: + output.append( + PDFTextStyleRange( + current_start, + previous_index + 1, + style_range.styles, + ) + ) + current_start = None + if current_start is None: + current_start = projected_index + previous_index = projected_index + if current_start is not None and previous_index is not None: + output.append( + PDFTextStyleRange( + current_start, + previous_index + 1, + style_range.styles, + ) + ) + return output + + +def _lines_form_dehyphenated_continuation( + line: PDFTextEvidenceLine, + next_line: PDFTextEvidenceLine | None, +) -> bool: + """判断相邻物理行是否符合正文回填使用的英文断词规则。""" + + return bool( + next_line is not None + and next_line.source_index == line.source_index + 1 + and line.text + and next_line.text + and is_hyphen_at_line_end(line.text) + and next_line.text[0].islower() + ) + + +def _match_line_without_terminal_hyphen( + projected_text: str, + line: PDFTextEvidenceLine, + next_line: PDFTextEvidenceLine | None, + start: int, +) -> _LineProjectionMatch | None: + """将 block 已删除的行末断词符映射为空洞,歧义时拒绝匹配。""" + + if not _lines_form_dehyphenated_continuation(line, next_line): + return None + candidate = line.text[:-1] + next_first_char = next_line.text[0] if next_line is not None else "" + occurrences = [ + position + for position in _all_occurrences(projected_text, candidate, start) + if (position + len(candidate) < len(projected_text) and projected_text[position + len(candidate)] == next_first_char) + ] + if len(occurrences) != 1: + logger.debug(f"Skip ambiguous PDF text dehyphenation mapping: line={line.text!r}, occurrences={len(occurrences)}") + return None + position = occurrences[0] + return _LineProjectionMatch( + start=position, + end=position + len(candidate), + source_to_projected=( + *range(position, position + len(candidate)), + None, + ), + ) + + +def _match_style_ranges( + projected: Sequence[_ProjectedChar], + lines: Sequence[PDFTextStyleLine], +) -> list[PDFTextStyleRange]: + """按物理行顺序把字体与装饰线证据确定性对齐到 block 文本。""" + + projected_text = "".join(token.value for token in projected) + output: list[PDFTextStyleRange] = [] + cursor = 0 + for line_index, line in enumerate(lines): + next_line = lines[line_index + 1] if line_index + 1 < len(lines) else None + line_start = projected_text.find(line.text, cursor) + if line_start >= 0: + output.extend( + PDFTextStyleRange( + line_start + style_range.start, + line_start + style_range.end, + style_range.styles, + ) + for style_range in line.style_ranges + ) + cursor = line_start + len(line.text) + continue + formula_match = _match_line_across_formula_gaps( + line.text, + projected, + cursor, + ) + if formula_match is not None: + output.extend(_ranges_from_line_projection(line, formula_match)) + cursor = formula_match.end + continue + dehyphenated_match = _match_line_without_terminal_hyphen( + projected_text, + line, + next_line, + cursor, + ) + if dehyphenated_match is not None: + output.extend(_ranges_from_line_projection(line, dehyphenated_match)) + cursor = dehyphenated_match.end + continue + skipped_ranges: list[PDFTextStyleRange] = [] + for style_range in line.style_ranges: + position = _resolve_fallback_occurrence( + projected_text, + line, + style_range, + cursor, + ) + if position is None: + skipped_ranges.append(style_range) + continue + output.append( + PDFTextStyleRange( + position, + position + style_range.end - style_range.start, + style_range.styles, + ) + ) + cursor = position + style_range.end - style_range.start + if skipped_ranges: + skipped_samples = [ + ( + line.text[style_range.start : style_range.end], + style_range.styles, + ) + for style_range in skipped_ranges[:3] + ] + logger.debug( + "Skip ambiguous PDF text style mapping: " + f"line={line.text!r}, skipped={len(skipped_ranges)}, " + f"samples={skipped_samples!r}" + ) + return _merge_style_ranges(output) + + +def _match_script_line_ranges( + projected: Sequence[_ProjectedChar], + line: PDFTextStyleLine, +) -> list[PDFTextStyleRange]: + """独立投影单条脚本行,避免其它视觉行推进 cursor 后吞掉短脚本。""" + + projected_text = "".join(token.value for token in projected) + exact_occurrences = _all_occurrences(projected_text, line.text, 0) + if len(exact_occurrences) == 1: + line_start = exact_occurrences[0] + return [ + PDFTextStyleRange( + line_start + style_range.start, + line_start + style_range.end, + style_range.styles, + ) + for style_range in line.style_ranges + ] + formula_match = _match_line_across_formula_gaps( + line.text, + projected, + 0, + ) + if formula_match is not None: + return _ranges_from_line_projection(line, formula_match) + output: list[PDFTextStyleRange] = [] + for style_range in line.style_ranges: + position = _resolve_fallback_occurrence( + projected_text, + line, + style_range, + 0, + ) + if position is None: + continue + output.append( + PDFTextStyleRange( + position, + position + style_range.end - style_range.start, + style_range.styles, + ) + ) + return _merge_style_ranges(output) + + +def _merge_style_ranges(ranges: Sequence[PDFTextStyleRange]) -> list[PDFTextStyleRange]: + """把重叠样式取并集,并合并相邻且样式集合相同的区间。""" + + events: dict[int, dict[PDFTextStyle, int]] = {} + for style_range in ranges: + styles = _canonical_styles(style_range.styles) + if style_range.start >= style_range.end or not styles: + continue + for position, delta in ( + (style_range.start, 1), + (style_range.end, -1), + ): + position_events = events.setdefault(position, {}) + for style in styles: + position_events[style] = position_events.get(style, 0) + delta + + active_counts: dict[PDFTextStyle, int] = {} + merged: list[PDFTextStyleRange] = [] + previous_position: int | None = None + for position in sorted(events): + active_styles = _canonical_styles(style for style, count in active_counts.items() if count > 0) + if previous_position is not None and previous_position < position and active_styles: + if merged and merged[-1].end == previous_position and merged[-1].styles == active_styles: + merged[-1] = PDFTextStyleRange( + merged[-1].start, + position, + active_styles, + ) + else: + merged.append( + PDFTextStyleRange( + previous_position, + position, + active_styles, + ) + ) + for style, delta in events[position].items(): + active_counts[style] = active_counts.get(style, 0) + delta + previous_position = position + return merged + + +def _resolve_link_fallback_occurrence( + content: str, + line: PDFTextLinkLine, + link_range: PDFTextLinkRange, + start: int, +) -> int | None: + """整行无法对齐时,用唯一标签或两侧精确上下文定位链接片段。""" + + target_text = line.text[link_range.start : link_range.end] + occurrences = _all_occurrences(content, target_text, start) + if not occurrences: + return None + if len(occurrences) == 1: + return occurrences[0] + + left_context = line.text[max(0, link_range.start - 12) : link_range.start] + right_context = line.text[link_range.end : link_range.end + 12] + required_context_score = int(bool(left_context)) + int(bool(right_context)) + if required_context_score == 0: + return None + scored = [ + ( + int(bool(left_context) and content[max(0, position - len(left_context)) : position] == left_context) + + int( + bool(right_context) + and content[position + len(target_text) : position + len(target_text) + len(right_context)] == right_context + ), + position, + ) + for position in occurrences + ] + best_score = max(score for score, _position in scored) + best_positions = [position for score, position in scored if score == best_score] + return best_positions[0] if best_score == required_context_score and len(best_positions) == 1 else None + + +def _project_link_range_from_line_match( + link_range: PDFTextLinkRange, + match: _LineProjectionMatch, + source_index: int, +) -> list[_MatchedLinkRange]: + """按行字符投影映射链接区间,并在缺失字符处安全分段。""" + + output: list[_MatchedLinkRange] = [] + current_start: int | None = None + previous_index: int | None = None + for projected_index in match.source_to_projected[link_range.start : link_range.end]: + if projected_index is None: + if current_start is not None and previous_index is not None: + output.append( + _MatchedLinkRange( + current_start, + previous_index + 1, + link_range.target, + source_index, + ) + ) + current_start = None + previous_index = None + continue + if current_start is not None and previous_index is not None and projected_index != previous_index + 1: + output.append( + _MatchedLinkRange( + current_start, + previous_index + 1, + link_range.target, + source_index, + ) + ) + current_start = None + if current_start is None: + current_start = projected_index + previous_index = projected_index + if current_start is not None and previous_index is not None: + output.append( + _MatchedLinkRange( + current_start, + previous_index + 1, + link_range.target, + source_index, + ) + ) + return output + + +def _link_lines_form_dehyphenated_continuation( + line: PDFTextLinkLine, + next_line: PDFTextLinkLine | None, +) -> bool: + """判断相邻同 href 链接行是否符合文本回填的英文断词规则。""" + + if not _lines_form_dehyphenated_continuation(line, next_line): + return False + tail_targets = {link_range.target for link_range in line.link_ranges if link_range.start < link_range.end == len(line.text)} + head_targets = {link_range.target for link_range in next_line.link_ranges if link_range.start == 0 < link_range.end} + return bool(tail_targets.intersection(head_targets)) + + +def _match_link_line_without_terminal_hyphen( + projected_text: str, + line: PDFTextLinkLine, + next_line: PDFTextLinkLine | None, + start: int, +) -> _LineProjectionMatch | None: + """在严格跨行条件下将已被 block 回填删除的行末断词符投影为空洞。""" + + if not _link_lines_form_dehyphenated_continuation(line, next_line): + return None + return _match_line_without_terminal_hyphen( + projected_text, + line, + next_line, + start, + ) + + +def _merge_matched_link_ranges( + ranges: Sequence[_MatchedLinkRange], +) -> list[_MatchedLinkRange]: + """删除不同目标重叠区,并合并同一物理行内的同目标相邻区间。""" + + valid_ranges = [link_range for link_range in ranges if link_range.start < link_range.end and link_range.target] + if not valid_ranges: + return [] + boundaries = sorted({position for link_range in valid_ranges for position in (link_range.start, link_range.end)}) + merged: list[_MatchedLinkRange] = [] + for start, end in zip(boundaries, boundaries[1:]): + active = [link_range for link_range in valid_ranges if link_range.start < end and link_range.end > start] + targets = {link_range.target for link_range in active} + if len(targets) != 1: + continue + target = next(iter(targets)) + source_index = min(link_range.source_index for link_range in active if link_range.target == target) + if merged and merged[-1].end == start and merged[-1].target == target and merged[-1].source_index == source_index: + merged[-1] = _MatchedLinkRange( + merged[-1].start, + end, + target, + source_index, + ) + else: + merged.append( + _MatchedLinkRange( + start, + end, + target, + source_index, + ) + ) + return merged + + +def _match_link_ranges( + projected: Sequence[_ProjectedChar], + lines: Sequence[PDFTextLinkLine], +) -> list[_MatchedLinkRange]: + """按物理行顺序把 Link 几何证据确定性对齐到 block 文本。""" + + projected_text = "".join(token.value for token in projected) + output: list[_MatchedLinkRange] = [] + cursor = 0 + for line_index, line in enumerate(lines): + next_line = lines[line_index + 1] if line_index + 1 < len(lines) else None + line_start = projected_text.find(line.text, cursor) + if line_start >= 0: + output.extend( + _MatchedLinkRange( + line_start + link_range.start, + line_start + link_range.end, + link_range.target, + line.source_index, + ) + for link_range in line.link_ranges + ) + cursor = line_start + len(line.text) + continue + formula_match = _match_line_across_formula_gaps( + line.text, + projected, + cursor, + ) + if formula_match is not None: + for link_range in line.link_ranges: + output.extend( + _project_link_range_from_line_match( + link_range, + formula_match, + line.source_index, + ) + ) + cursor = formula_match.end + continue + + dehyphenated_match = _match_link_line_without_terminal_hyphen( + projected_text, + line, + next_line, + cursor, + ) + if dehyphenated_match is not None: + for link_range in line.link_ranges: + output.extend( + _project_link_range_from_line_match( + link_range, + dehyphenated_match, + line.source_index, + ) + ) + cursor = dehyphenated_match.end + continue + + skipped_ranges: list[PDFTextLinkRange] = [] + for link_range in line.link_ranges: + position = _resolve_link_fallback_occurrence( + projected_text, + line, + link_range, + cursor, + ) + if position is None: + skipped_ranges.append(link_range) + continue + output.append( + _MatchedLinkRange( + position, + position + link_range.end - link_range.start, + link_range.target, + line.source_index, + ) + ) + cursor = position + link_range.end - link_range.start + if skipped_ranges: + logger.debug( + "Skip ambiguous PDF hyperlink mapping: " + f"line={line.text!r}, skipped={len(skipped_ranges)}, " + f"samples={[(line.text[item.start : item.end], item.target) for item in skipped_ranges[:3]]!r}" + ) + return _merge_matched_link_ranges(output) + + +def _append_raw_link_interval( + intervals: list[_RawLinkInterval], + start: int | None, + end: int, + target: str, + source_index: int, +) -> None: + """向结果追加一个合法原字符串链接区间。""" + + if start is not None and start < end and target: + intervals.append( + _RawLinkInterval( + start, + end, + target, + source_index, + ) + ) + + +def _raw_link_intervals( + content: str, + projected: Sequence[_ProjectedChar], + ranges: Sequence[_MatchedLinkRange], +) -> list[_RawLinkInterval]: + """把链接区间转换为不跨公式或已有 hyperlink 的原字符串区间。""" + + intervals: list[_RawLinkInterval] = [] + for link_range in ranges: + current_start: int | None = None + current_end = 0 + for token in projected[link_range.start : link_range.end]: + if token.inside_hyperlink or (token.formula_gap_before and current_start is not None): + _append_raw_link_interval( + intervals, + current_start, + current_end, + link_range.target, + link_range.source_index, + ) + current_start = None + if token.inside_hyperlink: + continue + if current_start is None: + current_start = token.raw_start + current_end = token.raw_end + continue + gap = content[current_end : token.raw_start] + if token.raw_start <= current_end or not gap or gap.isspace(): + current_end = max(current_end, token.raw_end) + else: + _append_raw_link_interval( + intervals, + current_start, + current_end, + link_range.target, + link_range.source_index, + ) + current_start = token.raw_start + current_end = token.raw_end + _append_raw_link_interval( + intervals, + current_start, + current_end, + link_range.target, + link_range.source_index, + ) + return intervals + + +def _raw_link_gap_is_boundary_only(gap: str) -> bool: + """判断两个跨行链接片段之间是否只包含空白或非正文边界符号。""" + + if not gap: + return True + if r"\(" in gap or r"\)" in gap: + return False + return not any(char.isalnum() for char in html.unescape(gap)) + + +def _merge_raw_link_intervals( + content: str, + intervals: Sequence[_RawLinkInterval], +) -> list[_RawLinkInterval]: + """合并相邻物理行中同 href 的首尾链接片段,不跨越正文或公式。""" + + merged: list[_RawLinkInterval] = [] + for interval in sorted( + intervals, + key=lambda item: (item.start, item.end, item.source_index, item.target), + ): + if interval.start >= interval.end or not interval.target: + continue + if ( + merged + and merged[-1].target == interval.target + and interval.source_index == merged[-1].source_index + 1 + and interval.start >= merged[-1].end + and _raw_link_gap_is_boundary_only(content[merged[-1].end : interval.start]) + ): + merged[-1] = _RawLinkInterval( + merged[-1].start, + interval.end, + interval.target, + interval.source_index, + ) + else: + merged.append(interval) + return merged + + +__all__ = [ + "_resplit_evidence_segments", + "_partition_resplit_text_evidence", + "_realign_repaired_text_evidence", + "_block_bbox_to_page_bbox", + "_line_block_score", + "_assign_lines_to_blocks", + "_assign_script_lines_to_blocks", + "_filter_line_styles_for_block", + "_project_content_chars", + "_all_occurrences", + "_resolve_fallback_occurrence", + "_match_line_across_formula_gaps", + "_ranges_from_line_projection", + "_lines_form_dehyphenated_continuation", + "_match_line_without_terminal_hyphen", + "_match_style_ranges", + "_match_script_line_ranges", + "_merge_style_ranges", + "_resolve_link_fallback_occurrence", + "_project_link_range_from_line_match", + "_link_lines_form_dehyphenated_continuation", + "_match_link_line_without_terminal_hyphen", + "_merge_matched_link_ranges", + "_match_link_ranges", + "_append_raw_link_interval", + "_raw_link_intervals", + "_raw_link_gap_is_boundary_only", + "_merge_raw_link_intervals", +] diff --git a/mineru/model/flash/pdf/inline/materialize.py b/mineru/model/flash/pdf/inline/materialize.py new file mode 100644 index 00000000..073cd0e0 --- /dev/null +++ b/mineru/model/flash/pdf/inline/materialize.py @@ -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.*?)\\\)", 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", +] diff --git a/mineru/model/flash/pdf/inline/scripts.py b/mineru/model/flash/pdf/inline/scripts.py new file mode 100644 index 00000000..39f427b8 --- /dev/null +++ b/mineru/model/flash/pdf/inline/scripts.py @@ -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", +] diff --git a/mineru/model/flash/pdf/inline/types.py b/mineru/model/flash/pdf/inline/types.py new file mode 100644 index 00000000..07757f87 --- /dev/null +++ b/mineru/model/flash/pdf/inline/types.py @@ -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/)?(?Psup|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", +] diff --git a/mineru/model/flash/pdf/line_layout.py b/mineru/model/flash/pdf/line_layout.py index d3950b68..bc0471cd 100644 --- a/mineru/model/flash/pdf/line_layout.py +++ b/mineru/model/flash/pdf/line_layout.py @@ -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, diff --git a/mineru/model/flash/pdf/line_merging.py b/mineru/model/flash/pdf/line_merging.py index eaf081d6..1a36f248 100644 --- a/mineru/model/flash/pdf/line_merging.py +++ b/mineru/model/flash/pdf/line_merging.py @@ -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( diff --git a/mineru/model/flash/pdf/models.py b/mineru/model/flash/pdf/models.py index f6f8e1b6..c770fe67 100644 --- a/mineru/model/flash/pdf/models.py +++ b/mineru/model/flash/pdf/models.py @@ -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) diff --git a/mineru/model/flash/pdf/native_text.py b/mineru/model/flash/pdf/native_text.py index 7a41d8c8..92df5a7a 100644 --- a/mineru/model/flash/pdf/native_text.py +++ b/mineru/model/flash/pdf/native_text.py @@ -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 diff --git a/mineru/model/flash/pdf/pdfium.py b/mineru/model/flash/pdf/pdfium.py index fc35dea8..93c9e8ec 100644 --- a/mineru/model/flash/pdf/pdfium.py +++ b/mineru/model/flash/pdf/pdfium.py @@ -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: diff --git a/mineru/model/flash/pdf/pdftext_adapter.py b/mineru/model/flash/pdf/pdftext_adapter.py new file mode 100644 index 00000000..35a6b5c8 --- /dev/null +++ b/mineru/model/flash/pdf/pdftext_adapter.py @@ -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"] diff --git a/mineru/model/flash/pdf/pipeline.py b/mineru/model/flash/pdf/pipeline.py index 7f1a2c5a..8f40e7af 100644 --- a/mineru/model/flash/pdf/pipeline.py +++ b/mineru/model/flash/pdf/pipeline.py @@ -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, diff --git a/mineru/model/flash/pdf/spatial_text.py b/mineru/model/flash/pdf/spatial_text.py index 904824e6..d59c51d2 100644 --- a/mineru/model/flash/pdf/spatial_text.py +++ b/mineru/model/flash/pdf/spatial_text.py @@ -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 diff --git a/mineru/model/flash/pdf/table_geometry.py b/mineru/model/flash/pdf/table_geometry.py new file mode 100644 index 00000000..716103e4 --- /dev/null +++ b/mineru/model/flash/pdf/table_geometry.py @@ -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"] diff --git a/mineru/model/flash/pdf/table_recovery/geometry.py b/mineru/model/flash/pdf/table_recovery/geometry.py index 981371d0..4070f104 100644 --- a/mineru/model/flash/pdf/table_recovery/geometry.py +++ b/mineru/model/flash/pdf/table_recovery/geometry.py @@ -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, diff --git a/mineru/model/flash/pdf/table_recovery/sparse_common.py b/mineru/model/flash/pdf/table_recovery/sparse_common.py new file mode 100644 index 00000000..b58a64d2 --- /dev/null +++ b/mineru/model/flash/pdf/table_recovery/sparse_common.py @@ -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"] diff --git a/mineru/model/flash/pdf/table_recovery/sparse_hybrid.py b/mineru/model/flash/pdf/table_recovery/sparse_hybrid.py index bb901fe3..fddfed0e 100644 --- a/mineru/model/flash/pdf/table_recovery/sparse_hybrid.py +++ b/mineru/model/flash/pdf/table_recovery/sparse_hybrid.py @@ -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})) diff --git a/mineru/model/flash/pdf/table_recovery/sparse_multiline.py b/mineru/model/flash/pdf/table_recovery/sparse_multiline.py index 635865a4..e8e8086b 100644 --- a/mineru/model/flash/pdf/table_recovery/sparse_multiline.py +++ b/mineru/model/flash/pdf/table_recovery/sparse_multiline.py @@ -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), ) diff --git a/mineru/model/flash/pdf/table_recovery/vector.py b/mineru/model/flash/pdf/table_recovery/vector.py index ffe10a8b..1b90913c 100644 --- a/mineru/model/flash/pdf/table_recovery/vector.py +++ b/mineru/model/flash/pdf/table_recovery/vector.py @@ -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, diff --git a/mineru/model/flash/pdf/table_text_styles.py b/mineru/model/flash/pdf/table_text_styles.py index 788415e0..1cab8e42 100644 --- a/mineru/model/flash/pdf/table_text_styles.py +++ b/mineru/model/flash/pdf/table_text_styles.py @@ -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]: diff --git a/mineru/model/flash/pdf/text_assembly/__init__.py b/mineru/model/flash/pdf/text_assembly/__init__.py new file mode 100644 index 00000000..b2b2d2a5 --- /dev/null +++ b/mineru/model/flash/pdf/text_assembly/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) Opendatalab. All rights reserved. +"""按职责拆分的 Flash PDF 内部实现。""" + +__all__: list[str] = [] diff --git a/mineru/model/flash/pdf/text_assembly/annotations.py b/mineru/model/flash/pdf/text_assembly/annotations.py new file mode 100644 index 00000000..4547824a --- /dev/null +++ b/mineru/model/flash/pdf/text_assembly/annotations.py @@ -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", +] diff --git a/mineru/model/flash/pdf/text_assembly/assembly.py b/mineru/model/flash/pdf/text_assembly/assembly.py new file mode 100644 index 00000000..ba3f5f1f --- /dev/null +++ b/mineru/model/flash/pdf/text_assembly/assembly.py @@ -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"] diff --git a/mineru/model/flash/pdf/text_assembly/common.py b/mineru/model/flash/pdf/text_assembly/common.py new file mode 100644 index 00000000..b1d47771 --- /dev/null +++ b/mineru/model/flash/pdf/text_assembly/common.py @@ -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