knowhive-pdf 0.1.0__py3-none-any.whl
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.
- knowhive_pdf/__init__.py +13 -0
- knowhive_pdf/cli.py +81 -0
- knowhive_pdf/mapping.py +114 -0
- knowhive_pdf/triage.py +77 -0
- knowhive_pdf-0.1.0.dist-info/METADATA +54 -0
- knowhive_pdf-0.1.0.dist-info/RECORD +8 -0
- knowhive_pdf-0.1.0.dist-info/WHEEL +4 -0
- knowhive_pdf-0.1.0.dist-info/entry_points.txt +2 -0
knowhive_pdf/__init__.py
ADDED
|
@@ -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
|
knowhive_pdf/cli.py
ADDED
|
@@ -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()
|
knowhive_pdf/mapping.py
ADDED
|
@@ -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
|
knowhive_pdf/triage.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
knowhive_pdf/__init__.py,sha256=5nQP3NiICS3T8OaygLhQPQfPpBd1I7dCk6ERi9Gyx7I,492
|
|
2
|
+
knowhive_pdf/cli.py,sha256=c9VHpub6DFnj7TkDMuI9D2cnFE6JnCterxK7OWCueWA,2738
|
|
3
|
+
knowhive_pdf/mapping.py,sha256=yLAAmjbykhaE0kcP_lGLiaa3As775q7xi7I0iMVfQNY,4469
|
|
4
|
+
knowhive_pdf/triage.py,sha256=IeYqgcbC7DxNtV9Wlargbw7MD7_mpPH3_tFjy4gDjL8,2801
|
|
5
|
+
knowhive_pdf-0.1.0.dist-info/METADATA,sha256=Gsvxn28eCbsyf7jdLZhVkUApfxMCM7HBTbERpGWNhCQ,2422
|
|
6
|
+
knowhive_pdf-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
knowhive_pdf-0.1.0.dist-info/entry_points.txt,sha256=P92yRiSMVFVU5nr2E8ZRqt7Xs8Usrn5O2_VIr0fr0mM,55
|
|
8
|
+
knowhive_pdf-0.1.0.dist-info/RECORD,,
|