knowhive-pdf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ matrix:
12
+ os: [ubuntu-latest, macos-latest]
13
+ python: ["3.10", "3.13"]
14
+ runs-on: ${{ matrix.os }}
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: ${{ matrix.python }}
20
+ - run: uv sync
21
+ # Fast suite only: never downloads docling's layout models in CI.
22
+ # Full-parse coverage runs locally via KNOWHIVE_PDF_SLOW=1.
23
+ - run: uv run pytest -q
@@ -0,0 +1,35 @@
1
+ # Publish to PyPI via Trusted Publishing (OIDC) — no API tokens stored anywhere.
2
+ # Trigger: push a tag like v0.1.0. PyPI must have this repo + workflow registered
3
+ # as a (pending) trusted publisher for the `knowhive-pdf` project.
4
+ name: Publish
5
+
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv sync
17
+ - run: uv run pytest -q
18
+ - run: uv build
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: dist
22
+ path: dist/
23
+
24
+ publish:
25
+ needs: build
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ permissions:
29
+ id-token: write # OIDC token for PyPI Trusted Publishing
30
+ steps:
31
+ - uses: actions/download-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: knowhive-pdf
3
+ Version: 0.1.0
4
+ Summary: KnowHive PDF plugin: docling-powered PDF → DocumentIR JSON over a stdio protocol
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: docling<3,>=2.0
8
+ Requires-Dist: pypdfium2>=4
9
+ Description-Content-Type: text/markdown
10
+
11
+ # knowhive-pdf
12
+
13
+ KnowHive 的 PDF 解析插件:用 [docling](https://github.com/docling-project/docling)
14
+ 把 PDF 解析成 KnowHive 的 DocumentIR JSON(`server/src/documentIr.ts` 的形状),
15
+ 通过 stdio 协议供主程序调用。主程序保持 single runtime——本插件经
16
+ `uv tool install knowhive-pdf` 按需安装,不装则主程序完全无感。
17
+
18
+ > 开发期暂驻主仓库 `pdf-plugin/`;发布时整体迁出为独立 repo + PyPI +
19
+ > GitHub Actions Trusted Publishing。
20
+
21
+ ## 协议
22
+
23
+ ```
24
+ $ knowhive-pdf --stdio
25
+ → {"type":"ready","schema_version":1,"plugin_version":"0.1.0","docling_version":"..."}
26
+ ← /path/to/file.pdf
27
+ → {"type":"result","path":"...","ir":{"format":"pdf","blocks":[...]}}
28
+ → {"type":"error","path":"...","code":"needs_ocr|bad_text_layer|parse_failed","message":"..."}
29
+ ```
30
+
31
+ 调试用一次性模式:`knowhive-pdf file.pdf`。
32
+
33
+ ## v1 范围与设计决定
34
+
35
+ - **不含 OCR**(`do_ocr=False`)。扫描件由 triage 拦截返回 `needs_ocr`;中文 OCR 留 v2。
36
+ - **triage 先行**(pypdfium2,毫秒级):每页字符中位数 <50 且页面含图 → `needs_ocr`;
37
+ 无图或乱码字符占比 >10% → `bad_text_layer`。坏文字层是最阴的静默失败——页面渲染
38
+ 正常但抽出来是空/乱码(校准样本:pdf.js `issue9534_reduced.pdf`)。
39
+ - **NFKC 归一化**:子集化中文字体会把部分汉字映射到康熙部首码位(⼀ U+2F00 ≠ 一
40
+ U+4E00),肉眼相同、检索必死。所有输出文本过 NFKC。
41
+ - **标题层级从编号推导**("3.1"→L3):docling 的 section_header 是平的。
42
+ - **表格 caption 拆分**:docling 的表格 markdown 前缀 caption;拆成独立段落块,
43
+ 表格块从表头行开始——主程序 `splitTable()` 的表头重复逻辑依赖"首行=表头、
44
+ 次行=分隔行"。
45
+
46
+ ## 开发
47
+
48
+ ```bash
49
+ uv sync
50
+ uv run pytest # 快速套件(不加载 docling 模型)
51
+ KNOWHIVE_PDF_SLOW=1 uv run pytest # 含完整 docling 解析(首跑下载版面模型)
52
+ ```
53
+
54
+ `tests/fixtures/bad-textlayer.pdf` 来自 mozilla/pdf.js 测试语料(issue9534_reduced)。
@@ -0,0 +1,44 @@
1
+ # knowhive-pdf
2
+
3
+ KnowHive 的 PDF 解析插件:用 [docling](https://github.com/docling-project/docling)
4
+ 把 PDF 解析成 KnowHive 的 DocumentIR JSON(`server/src/documentIr.ts` 的形状),
5
+ 通过 stdio 协议供主程序调用。主程序保持 single runtime——本插件经
6
+ `uv tool install knowhive-pdf` 按需安装,不装则主程序完全无感。
7
+
8
+ > 开发期暂驻主仓库 `pdf-plugin/`;发布时整体迁出为独立 repo + PyPI +
9
+ > GitHub Actions Trusted Publishing。
10
+
11
+ ## 协议
12
+
13
+ ```
14
+ $ knowhive-pdf --stdio
15
+ → {"type":"ready","schema_version":1,"plugin_version":"0.1.0","docling_version":"..."}
16
+ ← /path/to/file.pdf
17
+ → {"type":"result","path":"...","ir":{"format":"pdf","blocks":[...]}}
18
+ → {"type":"error","path":"...","code":"needs_ocr|bad_text_layer|parse_failed","message":"..."}
19
+ ```
20
+
21
+ 调试用一次性模式:`knowhive-pdf file.pdf`。
22
+
23
+ ## v1 范围与设计决定
24
+
25
+ - **不含 OCR**(`do_ocr=False`)。扫描件由 triage 拦截返回 `needs_ocr`;中文 OCR 留 v2。
26
+ - **triage 先行**(pypdfium2,毫秒级):每页字符中位数 <50 且页面含图 → `needs_ocr`;
27
+ 无图或乱码字符占比 >10% → `bad_text_layer`。坏文字层是最阴的静默失败——页面渲染
28
+ 正常但抽出来是空/乱码(校准样本:pdf.js `issue9534_reduced.pdf`)。
29
+ - **NFKC 归一化**:子集化中文字体会把部分汉字映射到康熙部首码位(⼀ U+2F00 ≠ 一
30
+ U+4E00),肉眼相同、检索必死。所有输出文本过 NFKC。
31
+ - **标题层级从编号推导**("3.1"→L3):docling 的 section_header 是平的。
32
+ - **表格 caption 拆分**:docling 的表格 markdown 前缀 caption;拆成独立段落块,
33
+ 表格块从表头行开始——主程序 `splitTable()` 的表头重复逻辑依赖"首行=表头、
34
+ 次行=分隔行"。
35
+
36
+ ## 开发
37
+
38
+ ```bash
39
+ uv sync
40
+ uv run pytest # 快速套件(不加载 docling 模型)
41
+ KNOWHIVE_PDF_SLOW=1 uv run pytest # 含完整 docling 解析(首跑下载版面模型)
42
+ ```
43
+
44
+ `tests/fixtures/bad-textlayer.pdf` 来自 mozilla/pdf.js 测试语料(issue9534_reduced)。
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "knowhive-pdf"
3
+ version = "0.1.0"
4
+ description = "KnowHive PDF plugin: docling-powered PDF → DocumentIR JSON over a stdio protocol"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "docling>=2.0,<3",
10
+ "pypdfium2>=4",
11
+ ]
12
+
13
+ [project.scripts]
14
+ knowhive-pdf = "knowhive_pdf.cli:main"
15
+
16
+ [dependency-groups]
17
+ dev = ["pytest>=8"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/knowhive_pdf"]
@@ -0,0 +1,13 @@
1
+ """KnowHive PDF plugin: PDF → DocumentIR JSON.
2
+
3
+ The host app (KnowHive's TS sidecar) spawns `knowhive-pdf --stdio`, feeds file
4
+ paths on stdin, and reads one JSON object per line on stdout. The IR schema is
5
+ the TS side's DocumentIR (server/src/documentIr.ts); SCHEMA_VERSION gates
6
+ compatibility across releases.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ # Bump when the emitted IR shape changes incompatibly. The host refuses to talk
12
+ # to a plugin whose schema_version it doesn't know.
13
+ SCHEMA_VERSION = 1
@@ -0,0 +1,81 @@
1
+ """CLI entry: stdio batch protocol (host-facing) + one-shot mode (debugging).
2
+
3
+ Protocol (`knowhive-pdf --stdio`), one JSON object per line on stdout:
4
+
5
+ → {"type":"ready","schema_version":1,"plugin_version":"0.1.0","docling_version":"..."}
6
+ ← /path/to/file.pdf\n (one path per stdin line)
7
+ → {"type":"result","path":"...","ir":{"format":"pdf","blocks":[...]}}
8
+ → {"type":"error","path":"...","code":"needs_ocr|bad_text_layer|parse_failed","message":"..."}
9
+
10
+ Triage runs before docling, so scanned/broken files come back in milliseconds
11
+ and never trigger the (seconds-long, model-loading) conversion pipeline.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import SCHEMA_VERSION, __version__
21
+ from .mapping import convert_pdf
22
+ from .triage import triage_pdf
23
+
24
+
25
+ def _emit(obj: dict) -> None:
26
+ sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
27
+ sys.stdout.flush()
28
+
29
+
30
+ def process_file(path: str) -> dict:
31
+ if not Path(path).is_file():
32
+ return {"type": "error", "path": path, "code": "parse_failed", "message": "file not found"}
33
+ try:
34
+ t = triage_pdf(path)
35
+ if t.code != "ok":
36
+ return {"type": "error", "path": path, "code": t.code, "message": t.message}
37
+ blocks = convert_pdf(path)
38
+ return {"type": "result", "path": path, "ir": {"format": "pdf", "blocks": blocks}}
39
+ except Exception as e: # noqa: BLE001 — protocol boundary: everything becomes an error line
40
+ return {"type": "error", "path": path, "code": "parse_failed", "message": str(e)}
41
+
42
+
43
+ def run_stdio() -> None:
44
+ try:
45
+ from docling import __version__ as docling_version
46
+ except Exception: # pragma: no cover
47
+ docling_version = "unknown"
48
+ _emit(
49
+ {
50
+ "type": "ready",
51
+ "schema_version": SCHEMA_VERSION,
52
+ "plugin_version": __version__,
53
+ "docling_version": docling_version,
54
+ }
55
+ )
56
+ for line in sys.stdin:
57
+ path = line.strip()
58
+ if not path:
59
+ continue
60
+ _emit(process_file(path))
61
+
62
+
63
+ def main() -> None:
64
+ ap = argparse.ArgumentParser(description="KnowHive PDF plugin: PDF → DocumentIR JSON")
65
+ ap.add_argument("pdf", nargs="?", help="one-shot mode: parse a single PDF and print its IR")
66
+ ap.add_argument("--stdio", action="store_true", help="batch mode: paths on stdin, JSONL on stdout")
67
+ args = ap.parse_args()
68
+
69
+ if args.stdio:
70
+ run_stdio()
71
+ return
72
+ if not args.pdf:
73
+ ap.error("provide a PDF path or --stdio")
74
+ result = process_file(args.pdf)
75
+ _emit(result)
76
+ if result["type"] == "error":
77
+ sys.exit(1)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,114 @@
1
+ """docling result → DocumentIR blocks (the TS side's documentIr.ts shape).
2
+
3
+ Three quirks this mapping exists to absorb (found in the 2026-08-01 spike):
4
+
5
+ 1. Docling reports section headers at a flat tree depth, so the real hierarchy
6
+ lives in the numbering — "3.1 Pre-training" is level 3, "3 BERT" level 2.
7
+ 2. TableItem.export_to_markdown prepends the caption and a blank line. The TS
8
+ chunker's splitTable() requires line 1 = header row / line 2 = separator to
9
+ repeat headers when splitting oversized tables, so the caption becomes its
10
+ own paragraph block and the table block starts at the first pipe row.
11
+ 3. Subsetted CJK fonts map some ideographs to Kangxi-radical codepoints
12
+ (⼀ U+2F00 instead of 一 U+4E00) — visually identical, retrieval-fatal.
13
+ NFKC normalization folds them back (and full-width ASCII, ligatures too).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import unicodedata
18
+ from typing import Any
19
+
20
+ _converter = None
21
+
22
+
23
+ def get_converter():
24
+ """Singleton DocumentConverter — model load costs seconds, pay it once."""
25
+ global _converter
26
+ if _converter is None:
27
+ from docling.datamodel.base_models import InputFormat
28
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
29
+ from docling.document_converter import DocumentConverter, PdfFormatOption
30
+
31
+ opts = PdfPipelineOptions()
32
+ # v1 scope: text-layer PDFs only. Scanned files never reach docling —
33
+ # triage_pdf() returns needs_ocr first.
34
+ opts.do_ocr = False
35
+ opts.do_table_structure = True
36
+ _converter = DocumentConverter(
37
+ format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)}
38
+ )
39
+ return _converter
40
+
41
+
42
+ def normalize_text(text: str) -> str:
43
+ return unicodedata.normalize("NFKC", text).strip()
44
+
45
+
46
+ def heading_level_from_numbering(text: str) -> int:
47
+ """"3 BERT"→2, "3.1 Pre-training"→3; unnumbered→2 (doc title holds 1)."""
48
+ lead = text.split(" ", 1)[0].rstrip(".")
49
+ parts = lead.split(".")
50
+ if parts and parts[0] and all(p.isdigit() for p in parts):
51
+ return min(6, 1 + len(parts))
52
+ return 2
53
+
54
+
55
+ def split_table_markdown(md: str) -> tuple[str, str]:
56
+ """Split docling's table markdown into (caption, pipe-table)."""
57
+ lines = md.split("\n")
58
+ first_pipe = next((i for i, l in enumerate(lines) if l.startswith("|")), None)
59
+ if first_pipe is None:
60
+ return md.strip(), ""
61
+ caption = "\n".join(lines[:first_pipe]).strip()
62
+ table_md = "\n".join(lines[first_pipe:]).strip()
63
+ return caption, table_md
64
+
65
+
66
+ def convert_pdf(pdf_path: str) -> list[dict[str, Any]]:
67
+ from docling_core.types.doc import DocItemLabel, TableItem, TextItem
68
+
69
+ doc = get_converter().convert(pdf_path).document
70
+
71
+ label_map = {
72
+ DocItemLabel.TITLE: "heading",
73
+ DocItemLabel.SECTION_HEADER: "heading",
74
+ DocItemLabel.TEXT: "paragraph",
75
+ DocItemLabel.PARAGRAPH: "paragraph",
76
+ DocItemLabel.LIST_ITEM: "list",
77
+ DocItemLabel.CODE: "code",
78
+ DocItemLabel.FORMULA: "paragraph",
79
+ DocItemLabel.CAPTION: "paragraph",
80
+ }
81
+
82
+ blocks: list[dict[str, Any]] = []
83
+
84
+ def push(block: dict[str, Any], item: Any) -> None:
85
+ prov = getattr(item, "prov", None)
86
+ if prov:
87
+ block["page"] = prov[0].page_no
88
+ bb = prov[0].bbox
89
+ block["bbox"] = [round(bb.l, 1), round(bb.t, 1), round(bb.r, 1), round(bb.b, 1)]
90
+ block["order"] = len(blocks)
91
+ blocks.append(block)
92
+
93
+ for item, _level in doc.iterate_items():
94
+ if isinstance(item, TableItem):
95
+ caption, table_md = split_table_markdown(item.export_to_markdown(doc=doc))
96
+ if caption:
97
+ push({"type": "paragraph", "text": normalize_text(caption)}, item)
98
+ if table_md:
99
+ push({"type": "table", "text": normalize_text(table_md)}, item)
100
+ elif isinstance(item, TextItem):
101
+ text = normalize_text(item.text or "")
102
+ if not text:
103
+ continue
104
+ btype = label_map.get(item.label)
105
+ if btype is None:
106
+ continue # page furniture (headers/footers/footnotes): skip
107
+ block: dict[str, Any] = {"type": btype, "text": text}
108
+ if btype == "heading":
109
+ block["level"] = (
110
+ 1 if item.label == DocItemLabel.TITLE else heading_level_from_numbering(text)
111
+ )
112
+ push(block, item)
113
+
114
+ return blocks
@@ -0,0 +1,77 @@
1
+ """Cheap pre-flight triage: decide whether a PDF is worth running docling on.
2
+
3
+ Runs pypdfium2 text extraction only (milliseconds, no ML models). Catches the
4
+ two failure shapes that would otherwise fail SILENTLY — a scanned PDF parsed
5
+ with OCR off yields empty text, and a broken text layer (CID/cmap damage)
6
+ yields garbage that would be embedded as if it were real content.
7
+
8
+ Thresholds calibrated against real samples (2026-08-01):
9
+ - scanned page (image-only): 0 chars/page
10
+ - pdf.js issue9534 (broken layer): 15 chars on a visibly full page
11
+ - normal papers: thousands of chars/page, garbage ≈ 0.5%
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from statistics import median
17
+
18
+ # A page with fewer extractable chars than this is not a text page.
19
+ MIN_CHARS_PER_PAGE = 50
20
+ # Share of private-use/replacement chars above which the layer is garbage.
21
+ MAX_GARBAGE_RATIO = 0.10
22
+
23
+
24
+ @dataclass
25
+ class TriageResult:
26
+ code: str # "ok" | "needs_ocr" | "bad_text_layer"
27
+ message: str
28
+ chars_per_page: list[int]
29
+
30
+
31
+ def _is_garbage_char(c: str) -> bool:
32
+ cp = ord(c)
33
+ return 0xE000 <= cp <= 0xF8FF or c == "�"
34
+
35
+
36
+ def _page_has_image(page) -> bool:
37
+ # FPDF_PAGEOBJ_IMAGE == 3 in pdfium's page-object enum.
38
+ return any(obj.type == 3 for obj in page.get_objects())
39
+
40
+
41
+ def triage_pdf(pdf_path: str) -> TriageResult:
42
+ import pypdfium2 as pdfium
43
+
44
+ doc = pdfium.PdfDocument(pdf_path)
45
+ try:
46
+ texts = [doc[i].get_textpage().get_text_bounded() for i in range(len(doc))]
47
+ chars_per_page = [len(t.strip()) for t in texts]
48
+ all_text = "".join(texts)
49
+
50
+ if all_text:
51
+ garbage = sum(1 for c in all_text if _is_garbage_char(c)) / len(all_text)
52
+ if garbage > MAX_GARBAGE_RATIO:
53
+ return TriageResult(
54
+ "bad_text_layer",
55
+ f"{garbage:.0%} of extracted characters are unmapped glyphs",
56
+ chars_per_page,
57
+ )
58
+
59
+ if median(chars_per_page) < MIN_CHARS_PER_PAGE:
60
+ # Distinguish "pages are pictures" from "pages should have text but
61
+ # the layer is broken": image-dominated pages mean a scan.
62
+ scannish = any(_page_has_image(doc[i]) for i in range(min(3, len(doc))))
63
+ if scannish:
64
+ return TriageResult(
65
+ "needs_ocr",
66
+ "pages are images with no usable text layer (scanned document)",
67
+ chars_per_page,
68
+ )
69
+ return TriageResult(
70
+ "bad_text_layer",
71
+ "pages render content but the text layer is empty or broken",
72
+ chars_per_page,
73
+ )
74
+
75
+ return TriageResult("ok", "", chars_per_page)
76
+ finally:
77
+ doc.close()
@@ -0,0 +1,33 @@
1
+ from knowhive_pdf.mapping import (
2
+ heading_level_from_numbering,
3
+ normalize_text,
4
+ split_table_markdown,
5
+ )
6
+
7
+
8
+ def test_heading_levels_from_numbering():
9
+ assert heading_level_from_numbering("3 BERT") == 2
10
+ assert heading_level_from_numbering("3.1 Pre-training") == 3
11
+ assert heading_level_from_numbering("3.2.1 Scaled Dot-Product Attention") == 4
12
+ assert heading_level_from_numbering("Abstract") == 2
13
+ assert heading_level_from_numbering("A.1 Appendix") == 2 # non-numeric → default
14
+
15
+
16
+ def test_nfkc_folds_kangxi_radicals():
17
+ # Subsetted CJK fonts emit ⼀ (U+2F00) for 一 (U+4E00) — retrieval-fatal.
18
+ assert normalize_text("⼀、⽗⼦切分") == "一、父子切分"
19
+ assert normalize_text("⼆、⽰例") == "二、示例"
20
+
21
+
22
+ def test_table_caption_splits_off():
23
+ md = "Table 1: results overview.\n\n| a | b |\n|---|---|\n| 1 | 2 |"
24
+ caption, table = split_table_markdown(md)
25
+ assert caption == "Table 1: results overview."
26
+ assert table.split("\n")[0] == "| a | b |"
27
+ assert table.split("\n")[1] == "|---|---|"
28
+
29
+
30
+ def test_table_without_pipes_becomes_caption_only():
31
+ caption, table = split_table_markdown("just a stray caption")
32
+ assert caption == "just a stray caption"
33
+ assert table == ""
@@ -0,0 +1,63 @@
1
+ """Protocol test: spawn the real CLI, feed paths, read JSONL.
2
+
3
+ Uses only triage-rejected fixtures so the test never loads docling's layout
4
+ models — the full-parse path is covered by the slow opt-in test below.
5
+ """
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ FIXTURES = Path(__file__).parent / "fixtures"
15
+
16
+
17
+ def run_stdio(paths: list[str]) -> list[dict]:
18
+ proc = subprocess.run(
19
+ [sys.executable, "-m", "knowhive_pdf.cli", "--stdio"],
20
+ input="\n".join(paths) + "\n",
21
+ capture_output=True,
22
+ text=True,
23
+ timeout=600,
24
+ )
25
+ assert proc.returncode == 0, proc.stderr
26
+ return [json.loads(line) for line in proc.stdout.strip().split("\n")]
27
+
28
+
29
+ def test_handshake_and_error_lines():
30
+ lines = run_stdio(
31
+ [str(FIXTURES / "zh-scanned.pdf"), str(FIXTURES / "bad-textlayer.pdf"), "/nope.pdf"]
32
+ )
33
+ ready, scanned, broken, missing = lines
34
+ assert ready["type"] == "ready"
35
+ assert ready["schema_version"] == 1
36
+ assert scanned == {
37
+ "type": "error",
38
+ "path": str(FIXTURES / "zh-scanned.pdf"),
39
+ "code": "needs_ocr",
40
+ "message": scanned["message"],
41
+ }
42
+ assert broken["code"] == "bad_text_layer"
43
+ assert missing["code"] == "parse_failed"
44
+
45
+
46
+ @pytest.mark.skipif(
47
+ not os.environ.get("KNOWHIVE_PDF_SLOW"),
48
+ reason="full docling parse (downloads layout models); set KNOWHIVE_PDF_SLOW=1",
49
+ )
50
+ def test_full_parse_emits_ir():
51
+ lines = run_stdio([str(FIXTURES / "zh-textlayer.pdf")])
52
+ result = lines[1]
53
+ assert result["type"] == "result"
54
+ ir = result["ir"]
55
+ assert ir["format"] == "pdf"
56
+ types = {b["type"] for b in ir["blocks"]}
57
+ assert "heading" in types and "table" in types
58
+ # NFKC applied: no Kangxi radicals survive into the IR.
59
+ assert not any("⼀" in b["text"] or "⽗" in b["text"] for b in ir["blocks"])
60
+ # Table blocks start at the header row (splitTable contract).
61
+ for b in ir["blocks"]:
62
+ if b["type"] == "table":
63
+ assert b["text"].startswith("|")
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from knowhive_pdf.triage import triage_pdf
4
+
5
+ FIXTURES = Path(__file__).parent / "fixtures"
6
+
7
+
8
+ def test_text_layer_pdf_is_ok():
9
+ r = triage_pdf(str(FIXTURES / "zh-textlayer.pdf"))
10
+ assert r.code == "ok"
11
+ assert max(r.chars_per_page) > 50
12
+
13
+
14
+ def test_scanned_pdf_needs_ocr():
15
+ r = triage_pdf(str(FIXTURES / "zh-scanned.pdf"))
16
+ assert r.code == "needs_ocr"
17
+
18
+
19
+ def test_broken_text_layer_detected():
20
+ # pdf.js issue9534_reduced: the page renders text but extraction yields
21
+ # almost nothing — the silent-garbage case triage exists for.
22
+ r = triage_pdf(str(FIXTURES / "bad-textlayer.pdf"))
23
+ assert r.code == "bad_text_layer"