1139 lines
38 KiB
Python
1139 lines
38 KiB
Python
"""Tests for :class:`DoclingIRBuilder`.
|
|
|
|
Each test constructs a minimal inline DoclingDocument dict — the smallest
|
|
JSON that exercises one mapping rule from
|
|
``docs/DoclingSidecarRefactorPlan-zh.md`` §5. The point is to lock down
|
|
contracts that the integration test (running against the live fixture)
|
|
cannot inspect cleanly, not to faithfully replicate the docling-serve
|
|
output schema.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from lightrag.parser.external.docling.ir_builder import DoclingIRBuilder
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers to build inline fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_doc(tmp_path: Path, payload: dict, *, stem: str = "demo") -> Path:
|
|
raw_dir = tmp_path / f"{stem}.docling_raw"
|
|
raw_dir.mkdir()
|
|
(raw_dir / f"{stem}.json").write_text(json.dumps(payload), encoding="utf-8")
|
|
return raw_dir
|
|
|
|
|
|
def _doc(
|
|
*,
|
|
body_children: list[str],
|
|
texts: list[dict] | None = None,
|
|
tables: list[dict] | None = None,
|
|
pictures: list[dict] | None = None,
|
|
groups: list[dict] | None = None,
|
|
key_value_items: list[dict] | None = None,
|
|
form_items: list[dict] | None = None,
|
|
) -> dict:
|
|
return {
|
|
"schema_name": "DoclingDocument",
|
|
"version": "1.10.0",
|
|
"origin": {"filename": "demo.pdf", "mimetype": "application/pdf"},
|
|
"body": {
|
|
"self_ref": "#/body",
|
|
"children": [{"$ref": r} for r in body_children],
|
|
"content_layer": "body",
|
|
"label": "unspecified",
|
|
},
|
|
"groups": groups or [],
|
|
"texts": texts or [],
|
|
"pictures": pictures or [],
|
|
"tables": tables or [],
|
|
"key_value_items": key_value_items or [],
|
|
"form_items": form_items or [],
|
|
}
|
|
|
|
|
|
def _text_item(
|
|
*,
|
|
label: str,
|
|
text: str,
|
|
self_ref: str,
|
|
level: int | None = None,
|
|
orig: str | None = None,
|
|
page_no: int = 1,
|
|
bbox: tuple[float, float, float, float] = (10.0, 100.0, 200.0, 80.0),
|
|
coord_origin: str = "BOTTOMLEFT",
|
|
content_layer: str = "body",
|
|
marker: str | None = None,
|
|
) -> dict:
|
|
item: dict[str, Any] = {
|
|
"self_ref": self_ref,
|
|
"label": label,
|
|
"text": text,
|
|
"orig": orig if orig is not None else text,
|
|
"content_layer": content_layer,
|
|
"prov": [
|
|
{
|
|
"page_no": page_no,
|
|
"bbox": {
|
|
"l": bbox[0],
|
|
"t": bbox[1],
|
|
"r": bbox[2],
|
|
"b": bbox[3],
|
|
"coord_origin": coord_origin,
|
|
},
|
|
"charspan": [0, len(text)],
|
|
}
|
|
],
|
|
}
|
|
if level is not None:
|
|
item["level"] = level
|
|
if marker is not None:
|
|
item["marker"] = marker
|
|
return item
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name in ("DOCLING_BBOX_ATTRIBUTES", "DOCLING_ENGINE_VERSION"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Heading hierarchy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_simple_heading_hierarchy(tmp_path: Path) -> None:
|
|
"""Three distinct sections without adjacency-merge folding.
|
|
|
|
Background and Details each carry their own body, so we end up with one
|
|
block per heading and a clean parent-heading chain.
|
|
"""
|
|
texts = [
|
|
_text_item(label="title", text="Whole Doc Title", self_ref="#/texts/0"),
|
|
_text_item(label="text", text="Title-level body.", self_ref="#/texts/1"),
|
|
_text_item(
|
|
label="section_header", text="Background", level=1, self_ref="#/texts/2"
|
|
),
|
|
_text_item(label="text", text="Some intro body.", self_ref="#/texts/3"),
|
|
_text_item(
|
|
label="section_header", text="Details", level=2, self_ref="#/texts/4"
|
|
),
|
|
_text_item(label="text", text="Detail content.", self_ref="#/texts/5"),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=[
|
|
"#/texts/0",
|
|
"#/texts/1",
|
|
"#/texts/2",
|
|
"#/texts/3",
|
|
"#/texts/4",
|
|
"#/texts/5",
|
|
],
|
|
texts=texts,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
|
|
assert ir.doc_title == "Whole Doc Title"
|
|
headings = [(b.heading, b.level, b.parent_headings) for b in ir.blocks]
|
|
# title (level=1), section_header level=1 → IR level 2, section_header level=2 → IR level 3
|
|
assert headings == [
|
|
("Whole Doc Title", 1, []),
|
|
("Background", 2, ["Whole Doc Title"]),
|
|
("Details", 3, ["Whole Doc Title", "Background"]),
|
|
]
|
|
# heading line is rendered with markdown prefix as the FIRST line
|
|
assert ir.blocks[0].content_template.splitlines()[0] == "# Whole Doc Title"
|
|
assert ir.blocks[1].content_template.splitlines()[0] == "## Background"
|
|
assert ir.blocks[2].content_template.splitlines()[0] == "### Details"
|
|
|
|
|
|
def test_docling_adapter_empty_heading_becomes_standalone_block(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A heading with no body becomes its own standalone block whose content
|
|
is just the heading line; the following heading (with body) is a separate
|
|
block. No adjacency folding (matches the native docx parser)."""
|
|
texts = [
|
|
_text_item(label="title", text="Whole Doc Title", self_ref="#/texts/0"),
|
|
_text_item(
|
|
label="section_header", text="Background", level=1, self_ref="#/texts/1"
|
|
),
|
|
_text_item(label="text", text="Body for Background.", self_ref="#/texts/2"),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0", "#/texts/1", "#/texts/2"],
|
|
texts=texts,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
summary = [
|
|
(b.heading, b.content_template, b.level, b.parent_headings) for b in ir.blocks
|
|
]
|
|
assert summary == [
|
|
("Whole Doc Title", "# Whole Doc Title", 1, []),
|
|
(
|
|
"Background",
|
|
"## Background\nBody for Background.",
|
|
2,
|
|
["Whole Doc Title"],
|
|
),
|
|
]
|
|
|
|
|
|
def test_docling_adapter_existing_markdown_heading_not_double_prefixed(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A heading whose source text already carries a markdown ``#`` prefix is
|
|
kept verbatim in content but stored cleanly in metadata."""
|
|
texts = [
|
|
_text_item(label="title", text="# Already MD", self_ref="#/texts/0"),
|
|
_text_item(
|
|
label="section_header",
|
|
text="## Child MD",
|
|
level=1,
|
|
self_ref="#/texts/1",
|
|
),
|
|
_text_item(label="text", text="Body.", self_ref="#/texts/2"),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/texts/0", "#/texts/1", "#/texts/2"], texts=texts),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.blocks[0].content_template.splitlines()[0] == "# Already MD"
|
|
assert ir.blocks[1].content_template.splitlines()[0] == "## Child MD"
|
|
assert ir.doc_title == "Already MD"
|
|
assert [(b.heading, b.parent_headings) for b in ir.blocks] == [
|
|
("Already MD", []),
|
|
("Child MD", ["Already MD"]),
|
|
]
|
|
|
|
|
|
def test_docling_adapter_preserves_docling_heading_level(tmp_path: Path) -> None:
|
|
"""When Docling reports all section_headers at level=1, the adapter
|
|
preserves that (no numbering-based level inference)."""
|
|
texts = [
|
|
_text_item(
|
|
label="section_header", text="1 Purpose", level=1, self_ref="#/texts/0"
|
|
),
|
|
_text_item(
|
|
label="section_header", text="2.1 Electrical", level=1, self_ref="#/texts/1"
|
|
),
|
|
_text_item(
|
|
label="section_header",
|
|
text="2.4.5 Temperature",
|
|
level=1,
|
|
self_ref="#/texts/2",
|
|
),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0", "#/texts/1", "#/texts/2"],
|
|
texts=texts,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
levels = [b.level for b in ir.blocks]
|
|
assert levels == [2, 2, 2] # all bumped by +1, no normalization
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Multimodal payloads under one heading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_merges_payloads_under_heading(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(
|
|
label="section_header", text="Section", level=1, self_ref="#/texts/0"
|
|
),
|
|
_text_item(label="text", text="Inline body line.", self_ref="#/texts/1"),
|
|
]
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"data": {
|
|
"num_rows": 1,
|
|
"num_cols": 2,
|
|
"grid": [[{"text": "A"}, {"text": "B"}]],
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {"uri": "artifacts/foo.png", "mimetype": "image/png"},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=[
|
|
"#/texts/0",
|
|
"#/texts/1",
|
|
"#/tables/0",
|
|
"#/pictures/0",
|
|
],
|
|
texts=texts,
|
|
tables=tables,
|
|
pictures=pictures,
|
|
),
|
|
)
|
|
(raw_dir / "artifacts").mkdir()
|
|
(raw_dir / "artifacts" / "foo.png").write_bytes(b"\x89PNG fake")
|
|
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert len(ir.blocks) == 1
|
|
block = ir.blocks[0]
|
|
template = block.content_template
|
|
# one of each placeholder appears in source order
|
|
assert "{{TBL:tb1}}" in template
|
|
assert "{{IMG:im2}}" in template
|
|
assert template.index("{{TBL:tb1}}") < template.index("{{IMG:im2}}")
|
|
assert len(block.tables) == 1
|
|
assert block.tables[0].rows == [["A", "B"]]
|
|
assert len(block.drawings) == 1
|
|
assert block.drawings[0].asset_ref == "artifacts/foo.png"
|
|
assert block.drawings[0].fmt == "png"
|
|
assert any(a.ref == "artifacts/foo.png" for a in ir.assets)
|
|
|
|
|
|
def test_docling_adapter_visits_text_children_for_modalities(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
texts = [
|
|
_text_item(
|
|
label="section_header",
|
|
text="Section",
|
|
level=1,
|
|
self_ref="#/texts/0",
|
|
),
|
|
_text_item(label="text", text="Child paragraph.", self_ref="#/texts/1"),
|
|
_text_item(
|
|
label="formula",
|
|
text="E = mc^2",
|
|
orig="E = mc^2",
|
|
self_ref="#/texts/2",
|
|
),
|
|
]
|
|
texts[0]["children"] = [
|
|
{"$ref": "#/texts/1"},
|
|
{"$ref": "#/tables/0"},
|
|
{"$ref": "#/pictures/0"},
|
|
{"$ref": "#/texts/2"},
|
|
]
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"data": {
|
|
"num_rows": 1,
|
|
"num_cols": 2,
|
|
"grid": [[{"text": "A"}, {"text": "B"}]],
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {"uri": "artifacts/foo.png", "mimetype": "image/png"},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0"],
|
|
texts=texts,
|
|
tables=tables,
|
|
pictures=pictures,
|
|
),
|
|
)
|
|
(raw_dir / "artifacts").mkdir()
|
|
(raw_dir / "artifacts" / "foo.png").write_bytes(b"\x89PNG fake")
|
|
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert len(ir.blocks) == 1
|
|
block = ir.blocks[0]
|
|
assert "Child paragraph." in block.content_template
|
|
assert "{{TBL:tb1}}" in block.content_template
|
|
assert "{{IMG:im2}}" in block.content_template
|
|
assert "{{EQ:eq3}}" in block.content_template
|
|
assert len(block.tables) == 1
|
|
assert len(block.drawings) == 1
|
|
assert len(block.equations) == 1
|
|
assert block.equations[0].is_block is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Inline groups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_inline_group_joins_children(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(label="section_header", text="S", level=1, self_ref="#/texts/0"),
|
|
_text_item(label="text", text="hello", self_ref="#/texts/1"),
|
|
_text_item(label="text", text="world", self_ref="#/texts/2"),
|
|
]
|
|
groups = [
|
|
{
|
|
"self_ref": "#/groups/0",
|
|
"label": "inline",
|
|
"content_layer": "body",
|
|
"children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0", "#/groups/0"],
|
|
texts=texts,
|
|
groups=groups,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert "hello world" in ir.blocks[0].content_template
|
|
|
|
|
|
def test_docling_adapter_inline_group_emits_inline_formula(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
texts = [
|
|
_text_item(label="section_header", text="S", level=1, self_ref="#/texts/0"),
|
|
_text_item(label="text", text="alpha", self_ref="#/texts/1"),
|
|
_text_item(
|
|
label="formula",
|
|
text="x_i",
|
|
orig="x_i",
|
|
self_ref="#/texts/2",
|
|
),
|
|
_text_item(label="text", text="omega", self_ref="#/texts/3"),
|
|
]
|
|
groups = [
|
|
{
|
|
"self_ref": "#/groups/0",
|
|
"label": "inline",
|
|
"content_layer": "body",
|
|
"children": [
|
|
{"$ref": "#/texts/1"},
|
|
{"$ref": "#/texts/2"},
|
|
{"$ref": "#/texts/3"},
|
|
],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0", "#/groups/0"],
|
|
texts=texts,
|
|
groups=groups,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert "alpha {{EQI:eq1}} omega" in block.content_template
|
|
assert [eq.is_block for eq in block.equations] == [False]
|
|
assert block.equations[0].latex == "x_i"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Tables — grid & header
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_table_grid_and_header(tmp_path: Path) -> None:
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"captions": [{"$ref": "#/texts/0"}],
|
|
"footnotes": [{"$ref": "#/texts/1"}],
|
|
"data": {
|
|
"num_rows": 2,
|
|
"num_cols": 2,
|
|
"grid": [
|
|
[
|
|
{
|
|
"text": "h1",
|
|
"column_header": True,
|
|
"start_row_offset_idx": 0,
|
|
},
|
|
{
|
|
"text": "h2",
|
|
"column_header": True,
|
|
"start_row_offset_idx": 0,
|
|
},
|
|
],
|
|
[{"text": "a"}, {"text": "b"}],
|
|
],
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
texts = [
|
|
_text_item(label="caption", text="Table caption", self_ref="#/texts/0"),
|
|
_text_item(label="footnote", text="Note: x", self_ref="#/texts/1"),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/tables/0"],
|
|
texts=texts,
|
|
tables=tables,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert len(ir.blocks) == 1
|
|
table = ir.blocks[0].tables[0]
|
|
assert table.rows == [["h1", "h2"], ["a", "b"]]
|
|
assert table.num_rows == 2
|
|
assert table.num_cols == 2
|
|
assert table.caption == "Table caption"
|
|
assert table.footnotes == ["Note: x"]
|
|
assert table.table_header == [["h1", "h2"]]
|
|
assert table.self_ref == "#/tables/0"
|
|
|
|
|
|
def test_docling_adapter_empty_table_dropped(tmp_path: Path) -> None:
|
|
"""Table items with no usable body MUST NOT enter the IR.
|
|
|
|
Docling never populates ``IRTable.html``, so a body-less table would
|
|
land in the sidecar as ``content=""`` and trip the analyze worker's
|
|
"missing table content" path. Mirrors the MinerU-side filter in
|
|
lightrag/parser/external/mineru/ir_builder.py.
|
|
"""
|
|
# Four shapes of "no visible content" — all must be dropped.
|
|
tables = [
|
|
# 1) ``data`` missing entirely.
|
|
{"self_ref": "#/tables/0", "label": "table", "content_layer": "body"},
|
|
# 2) Empty grid.
|
|
{
|
|
"self_ref": "#/tables/1",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"data": {"num_rows": 0, "num_cols": 0, "grid": []},
|
|
},
|
|
# 3) Grid with only blank cell text.
|
|
{
|
|
"self_ref": "#/tables/2",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"data": {
|
|
"num_rows": 1,
|
|
"num_cols": 2,
|
|
"grid": [[{"text": ""}, {"text": " "}]],
|
|
},
|
|
},
|
|
# 4) table_cells fallback yields a blank grid.
|
|
{
|
|
"self_ref": "#/tables/3",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"data": {
|
|
"num_rows": 1,
|
|
"num_cols": 1,
|
|
"table_cells": [
|
|
{
|
|
"text": "",
|
|
"start_row_offset_idx": 0,
|
|
"end_row_offset_idx": 1,
|
|
"start_col_offset_idx": 0,
|
|
"end_col_offset_idx": 1,
|
|
}
|
|
],
|
|
},
|
|
},
|
|
]
|
|
texts = [_text_item(label="text", text="kept", self_ref="#/texts/0")]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=[
|
|
"#/tables/0",
|
|
"#/tables/1",
|
|
"#/tables/2",
|
|
"#/tables/3",
|
|
"#/texts/0",
|
|
],
|
|
texts=texts,
|
|
tables=tables,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
table_count = sum(len(b.tables) for b in ir.blocks)
|
|
assert table_count == 0
|
|
joined = "\n".join(b.content_template for b in ir.blocks)
|
|
assert "TBL:" not in joined
|
|
assert "kept" in joined
|
|
|
|
|
|
def test_docling_adapter_table_extras_is_empty(tmp_path: Path) -> None:
|
|
"""`IRTable.extras` is intentionally left blank by the docling adapter:
|
|
the historical ``parent`` / ``children_refs`` / ``references`` /
|
|
``annotations`` / ``cells`` fields were never consumed downstream and
|
|
bloated ``tables.json`` by ~50%. Construct a table that *would* have
|
|
populated all five legacy fields and assert ``extras == {}``."""
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"parent": {"$ref": "#/body"},
|
|
"children": [{"$ref": "#/texts/0"}],
|
|
"references": [{"foo": "bar"}],
|
|
"annotations": [{"note": "x"}],
|
|
"data": {
|
|
"num_rows": 1,
|
|
"num_cols": 1,
|
|
"grid": [[{"text": "x"}]],
|
|
"table_cells": [
|
|
{
|
|
"text": "x",
|
|
"row_span": 1,
|
|
"col_span": 1,
|
|
"start_row_offset_idx": 0,
|
|
"end_row_offset_idx": 1,
|
|
"start_col_offset_idx": 0,
|
|
"end_col_offset_idx": 1,
|
|
"bbox": {"l": 1, "t": 2, "r": 3, "b": 4},
|
|
}
|
|
],
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
texts = [_text_item(label="caption", text="c", self_ref="#/texts/0")]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/tables/0"], texts=texts, tables=tables),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.blocks[0].tables[0].extras == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Picture — referenced asset
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_picture_referenced_asset(tmp_path: Path) -> None:
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {
|
|
"uri": "artifacts/image_000000_abc.png",
|
|
"mimetype": "image/png",
|
|
"size": {"width": 100.0, "height": 200.0},
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], pictures=pictures),
|
|
)
|
|
art = raw_dir / "artifacts"
|
|
art.mkdir()
|
|
asset = art / "image_000000_abc.png"
|
|
asset.write_bytes(b"\x89PNG fake")
|
|
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
drawing = ir.blocks[0].drawings[0]
|
|
assert drawing.asset_ref == "artifacts/image_000000_abc.png"
|
|
assert drawing.fmt == "png"
|
|
assert drawing.self_ref == "#/pictures/0"
|
|
[a] = [a for a in ir.assets if a.ref == drawing.asset_ref]
|
|
assert a.source == asset
|
|
assert a.suggested_name == "image_000000_abc.png"
|
|
# intrinsic_size lands in extras for downstream VLM filtering
|
|
assert drawing.extras["intrinsic_size"] == [100.0, 200.0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Positions & bbox_attributes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_positions_and_bbox_attributes(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(
|
|
label="text",
|
|
text="A",
|
|
self_ref="#/texts/0",
|
|
page_no=1,
|
|
bbox=(10.0, 100.0, 200.0, 80.0),
|
|
coord_origin="BOTTOMLEFT",
|
|
),
|
|
_text_item(
|
|
label="text",
|
|
text="B",
|
|
self_ref="#/texts/1",
|
|
page_no=2,
|
|
bbox=(20.0, 50.0, 220.0, 30.0),
|
|
coord_origin="TOPLEFT",
|
|
),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/texts/0", "#/texts/1"], texts=texts),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.bbox_attributes == {"origin": "LEFTBOTTOM"}
|
|
# no max / page_sizes leaks
|
|
assert set(ir.bbox_attributes.keys()) == {"origin"}
|
|
|
|
positions = ir.blocks[0].positions
|
|
bbox_positions = [p for p in positions if p.range]
|
|
assert len(bbox_positions) == 2
|
|
bl = next(p for p in bbox_positions if p.anchor == "1")
|
|
tl = next(p for p in bbox_positions if p.anchor == "2")
|
|
assert bl.range == [10.0, 100.0, 200.0, 80.0]
|
|
assert bl.origin is None # inherits doc-level LEFTBOTTOM
|
|
assert tl.origin == "LEFTTOP" # per-position override
|
|
assert tl.range == [20.0, 50.0, 220.0, 30.0]
|
|
|
|
|
|
def test_docling_adapter_bbox_attributes_env_override(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("DOCLING_BBOX_ATTRIBUTES", '{"origin":"LEFTTOP"}')
|
|
texts = [
|
|
_text_item(
|
|
label="text",
|
|
text="A",
|
|
self_ref="#/texts/0",
|
|
coord_origin="BOTTOMLEFT",
|
|
)
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/texts/0"], texts=texts),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.bbox_attributes == {"origin": "LEFTTOP"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. caption / footnote refs (positive + sibling-not-consumed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_caption_refs_only(tmp_path: Path) -> None:
|
|
"""The caption referenced by tables[0].captions is consumed (kept in
|
|
IRTable.caption, dropped from reading flow). Sibling text NOT
|
|
referenced — even when it looks like a caption — stays in the reading
|
|
flow."""
|
|
texts = [
|
|
_text_item(label="caption", text="Tab1 caption", self_ref="#/texts/0"),
|
|
_text_item(label="text", text="Tab1 sibling", self_ref="#/texts/1"),
|
|
_text_item(label="caption", text="Orphan caption", self_ref="#/texts/2"),
|
|
]
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"captions": [{"$ref": "#/texts/0"}],
|
|
"data": {"num_rows": 1, "num_cols": 1, "grid": [[{"text": "x"}]]},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/tables/0", "#/texts/1", "#/texts/2"],
|
|
texts=texts,
|
|
tables=tables,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert block.tables[0].caption == "Tab1 caption"
|
|
# consumed caption ref does not leak into body text
|
|
assert "Tab1 caption" not in block.content_template
|
|
# orphan caption and sibling text DO appear in body
|
|
assert "Tab1 sibling" in block.content_template
|
|
assert "Orphan caption" in block.content_template
|
|
|
|
|
|
def test_docling_adapter_footnotes_refs_only(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(label="footnote", text="Linked footnote", self_ref="#/texts/0"),
|
|
_text_item(label="text", text="注: this is sibling note", self_ref="#/texts/1"),
|
|
]
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"footnotes": [{"$ref": "#/texts/0"}],
|
|
"data": {"num_rows": 1, "num_cols": 1, "grid": [[{"text": "x"}]]},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/tables/0", "#/texts/1"],
|
|
texts=texts,
|
|
tables=tables,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert block.tables[0].footnotes == ["Linked footnote"]
|
|
assert "Linked footnote" not in block.content_template
|
|
assert "注: this is sibling note" in block.content_template
|
|
|
|
|
|
def test_docling_adapter_table_refs_skip_non_body_caption_footnote(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# A body table references a caption/footnote whose targets sit in
|
|
# content_layer="furniture" — typically a page header/footer that
|
|
# docling mislabeled and linked to the table. The adapter contract is
|
|
# that furniture text must never leak into sidecar metadata, so the
|
|
# IRTable's caption/footnotes lists must come back empty (and the body
|
|
# reading flow must not pick up the furniture text either).
|
|
texts = [
|
|
_text_item(
|
|
label="caption",
|
|
text="Page header masquerading as caption",
|
|
self_ref="#/texts/0",
|
|
content_layer="furniture",
|
|
),
|
|
_text_item(
|
|
label="footnote",
|
|
text="Page footer masquerading as footnote",
|
|
self_ref="#/texts/1",
|
|
content_layer="furniture",
|
|
),
|
|
]
|
|
tables = [
|
|
{
|
|
"self_ref": "#/tables/0",
|
|
"label": "table",
|
|
"content_layer": "body",
|
|
"captions": [{"$ref": "#/texts/0"}],
|
|
"footnotes": [{"$ref": "#/texts/1"}],
|
|
"data": {"num_rows": 1, "num_cols": 1, "grid": [[{"text": "x"}]]},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/tables/0"], texts=texts, tables=tables),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert block.tables[0].caption == ""
|
|
assert block.tables[0].footnotes == []
|
|
assert "Page header masquerading" not in block.content_template
|
|
assert "Page footer masquerading" not in block.content_template
|
|
|
|
|
|
def test_docling_adapter_picture_children_fallback_skips_non_body(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# Same invariant for the children fallback path: a body picture has no
|
|
# explicit captions/footnotes, but its ``children`` list refs a caption
|
|
# whose target is furniture. ``_resolve_children_with_label`` must
|
|
# skip it rather than silently surfacing furniture text as the
|
|
# picture's caption.
|
|
texts = [
|
|
_text_item(
|
|
label="caption",
|
|
text="Furniture caption via children",
|
|
self_ref="#/texts/0",
|
|
content_layer="furniture",
|
|
),
|
|
]
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {
|
|
"uri": "artifacts/p0.png",
|
|
"mimetype": "image/png",
|
|
},
|
|
"children": [{"$ref": "#/texts/0"}],
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], texts=texts, pictures=pictures),
|
|
)
|
|
(raw_dir / "artifacts").mkdir()
|
|
(raw_dir / "artifacts" / "p0.png").write_bytes(b"\x89PNG fake")
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert block.drawings[0].caption == ""
|
|
assert "Furniture caption via children" not in block.content_template
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. furniture skipped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_furniture_skipped_by_content_layer(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(label="section_header", text="H", level=1, self_ref="#/texts/0"),
|
|
_text_item(label="text", text="Body sentence.", self_ref="#/texts/1"),
|
|
_text_item(
|
|
label="page_footer",
|
|
text="footer 1/5",
|
|
self_ref="#/texts/2",
|
|
content_layer="furniture",
|
|
),
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=["#/texts/0", "#/texts/1", "#/texts/2"],
|
|
texts=texts,
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
full = "\n".join(b.content_template for b in ir.blocks)
|
|
assert "footer 1/5" not in full
|
|
# the furniture's prov page_no=1 must not leak into any block position
|
|
for block in ir.blocks:
|
|
for pos in block.positions:
|
|
assert (
|
|
pos.anchor != "1"
|
|
or pos.range is not None
|
|
or any(p.range is not None for p in block.positions)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Picture inner children dropped from reading flow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_picture_children_dropped(tmp_path: Path) -> None:
|
|
texts = [
|
|
_text_item(label="caption", text="Picture caption", self_ref="#/texts/0"),
|
|
_text_item(label="text", text="Inner OCR text 1", self_ref="#/texts/1"),
|
|
_text_item(label="text", text="Inner OCR text 2", self_ref="#/texts/2"),
|
|
_text_item(label="text", text="", self_ref="#/texts/3"),
|
|
]
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {"uri": "artifacts/img.png", "mimetype": "image/png"},
|
|
"children": [
|
|
{"$ref": "#/texts/0"},
|
|
{"$ref": "#/texts/1"},
|
|
{"$ref": "#/texts/2"},
|
|
{"$ref": "#/texts/3"},
|
|
],
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], texts=texts, pictures=pictures),
|
|
)
|
|
art = raw_dir / "artifacts"
|
|
art.mkdir()
|
|
(art / "img.png").write_bytes(b"png")
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
drawing = block.drawings[0]
|
|
# caption (label=caption) is taken via children fallback
|
|
assert drawing.caption == "Picture caption"
|
|
assert "Picture caption" not in drawing.extras.get("ocr_texts", "")
|
|
# OCR-only children do NOT appear in body content
|
|
assert "Inner OCR text 1" not in block.content_template
|
|
assert "Inner OCR text 2" not in block.content_template
|
|
# extras records non-empty OCR paragraphs, not raw child refs.
|
|
assert drawing.extras["ocr_texts"] == "Inner OCR text 1\n\nInner OCR text 2"
|
|
assert drawing.extras["ocr_texts_count"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Picture with missing image is skipped
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_picture_missing_image_skipped(tmp_path: Path) -> None:
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": None,
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], pictures=pictures),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.blocks == []
|
|
assert ir.assets == []
|
|
|
|
|
|
def test_docling_adapter_picture_rejects_traversal_uri(tmp_path: Path) -> None:
|
|
# A poisoned bundle JSON points the image URI outside raw_dir via "..".
|
|
# The asset must NOT pick up the outside file — otherwise write_sidecar
|
|
# would copy it into parsed assets, turning a parser-side compromise
|
|
# into arbitrary local-file exfiltration.
|
|
outside = tmp_path / "secret.png"
|
|
outside.write_bytes(b"\x89PNG outside")
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {
|
|
"uri": "../secret.png",
|
|
"mimetype": "image/png",
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], pictures=pictures),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.blocks == []
|
|
assert ir.assets == []
|
|
|
|
|
|
def test_docling_adapter_picture_rejects_absolute_uri(tmp_path: Path) -> None:
|
|
# ``Path("raw_dir") / "/etc/passwd"`` discards raw_dir on POSIX, so an
|
|
# absolute URI would escape even without a "..". Reject these too.
|
|
outside = tmp_path / "leak.png"
|
|
outside.write_bytes(b"\x89PNG outside")
|
|
pictures = [
|
|
{
|
|
"self_ref": "#/pictures/0",
|
|
"label": "picture",
|
|
"content_layer": "body",
|
|
"image": {
|
|
"uri": str(outside),
|
|
"mimetype": "image/png",
|
|
},
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/pictures/0"], pictures=pictures),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
assert ir.blocks == []
|
|
assert ir.assets == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 11. Formula
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_formula_text_equals_orig_still_emits_equation(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
texts = [
|
|
{
|
|
"self_ref": "#/texts/0",
|
|
"label": "formula",
|
|
"content_layer": "body",
|
|
"text": "C = 2 * P / X",
|
|
"orig": "C = 2 * P / X",
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/texts/0"], texts=texts),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert len(block.equations) == 1
|
|
assert block.equations[0].is_block is True
|
|
assert "C = 2 * P / X" in block.equations[0].latex
|
|
assert "{{EQ:eq1}}" in block.content_template
|
|
|
|
|
|
def test_docling_adapter_formula_with_latex_wraps_dollars(tmp_path: Path) -> None:
|
|
texts = [
|
|
{
|
|
"self_ref": "#/texts/0",
|
|
"label": "formula",
|
|
"content_layer": "body",
|
|
"text": "C = 2 \\cdot P",
|
|
"orig": "<unreadable>",
|
|
"prov": [],
|
|
}
|
|
]
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(body_children=["#/texts/0"], texts=texts),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
block = ir.blocks[0]
|
|
assert len(block.equations) == 1
|
|
eq = block.equations[0]
|
|
assert eq.latex.startswith("$$") and eq.latex.endswith("$$")
|
|
assert "C = 2 \\cdot P" in eq.latex
|
|
assert eq.self_ref == "#/texts/0"
|
|
assert "{{EQ:eq1}}" in block.content_template
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 12. key_value_items / form_items audit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_docling_adapter_kv_form_items_audit_in_split_option(tmp_path: Path) -> None:
|
|
raw_dir = _write_doc(
|
|
tmp_path,
|
|
_doc(
|
|
body_children=[],
|
|
key_value_items=[{"id": "kv1"}, {"id": "kv2"}],
|
|
form_items=[{"id": "f1"}],
|
|
),
|
|
)
|
|
ir = DoclingIRBuilder().normalize_from_workdir(raw_dir, document_name="demo.pdf")
|
|
extras = ir.split_option["docling_extras"]
|
|
assert extras == {"key_value_items": 2, "form_items": 1}
|