From a95e1b628d14b8c67040a627758d6796cf39ce9d Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Tue, 28 Apr 2026 09:23:31 +0200 Subject: [PATCH] fix(graph): collapse Docling InlineGroup and Picture children (#197) Two patterns in Docling's serialization were mirrored 1:1 by the graph projection and produced node explosions on real documents: - An InlineGroup (paragraph of mixed style runs) emits one `groups[]` entry plus N `texts[]` runs. Naive iteration created one Paragraph node per run. - A Picture's `children` carry internal text labels extracted by the layout model (flowchart boxes, chart axis labels, diagram callouts). Each child became its own Paragraph node, drowning the figure. `build_collapse_index` (in the shared `infra.docling_tree` helper) now returns the `skip_refs` set + `inline_meta` overrides for both cases. The Neo4j `tree_writer` and the in-memory `docling_graph` consume the same index, so both projections stay in sync. InlineGroups are projected as a single :Paragraph carrying the concatenated text and the union of children's provs (re-indexed). Pictures keep their :Figure node and prov; their descendants are dropped. Captions live in the picture's separate `captions` field, not in `children`, so they are unaffected. --- document-parser/infra/docling_graph.py | 33 ++- document-parser/infra/docling_tree.py | 148 +++++++++- document-parser/infra/neo4j/tree_writer.py | 23 +- .../tests/neo4j/test_tree_writer.py | 259 ++++++++++++++++++ document-parser/tests/test_docling_graph.py | 160 +++++++++++ 5 files changed, 608 insertions(+), 15 deletions(-) diff --git a/document-parser/infra/docling_graph.py b/document-parser/infra/docling_graph.py index bd7cdf3..2987aed 100644 --- a/document-parser/infra/docling_graph.py +++ b/document-parser/infra/docling_graph.py @@ -17,8 +17,10 @@ from itertools import pairwise from typing import Any from infra.docling_tree import ( + build_collapse_index, dfs_order, element_label, + is_inline_group, iter_items, iter_pages, iter_provs, @@ -27,15 +29,22 @@ from infra.docling_tree import ( from infra.neo4j.queries import GraphPayload -def _element_node(doc_id: str, item: dict[str, Any], provs: list[dict[str, Any]]) -> dict[str, Any]: +def _element_node( + doc_id: str, + item: dict[str, Any], + provs: list[dict[str, Any]], + *, + text_override: str | None = None, +) -> dict[str, Any]: first_page = provs[0].get("page_no") if provs else None + raw_text = text_override if text_override is not None else (item.get("text") or "") return { "id": f"elem::{item.get('self_ref')}", "group": "element", "label": element_label(item.get("label") or ""), "docling_label": (item.get("label") or "").lower(), "self_ref": item.get("self_ref"), - "text": (item.get("text") or "")[:200], + "text": raw_text[:200], "prov_page": first_page, "provs": provs, "level": item.get("level"), @@ -112,6 +121,10 @@ def build_graph_payload( for p in pages_raw: nodes.append(_page_node(doc_id, p)) + # Issue #197: collapse Docling noise — InlineGroup style runs and the + # internal text labels Docling extracts from pictures/charts. + skip_refs, inline_meta = build_collapse_index(doc_data) + # Element nodes + collect parent/body metadata for edges below. The # `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF # carries the same `order` the Neo4j projection does. @@ -119,11 +132,17 @@ def build_graph_payload( element_idx = 0 for _, item in iter_items(doc_data): ref = item.get("self_ref") - if not ref: + if not ref or ref in skip_refs: continue by_ref[ref] = item - provs = iter_provs(item) - nodes.append(_element_node(doc_id, item, provs)) + if is_inline_group(item): + meta = inline_meta.get(ref, {"text": "", "provs": []}) + provs = meta["provs"] + text_override: str | None = meta["text"] + else: + provs = iter_provs(item) + text_override = None + nodes.append(_element_node(doc_id, item, provs, text_override=text_override)) pref = parent_ref(item) if pref == "#/body": @@ -143,8 +162,8 @@ def build_graph_payload( element_idx += 1 - # NEXT chain (DFS pre-order from body). - for a, b in pairwise(dfs_order(doc_data)): + # NEXT chain (DFS pre-order from body), inline-group children skipped. + for a, b in pairwise(dfs_order(doc_data, skip_refs)): if a in by_ref and b in by_ref: edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT")) diff --git a/document-parser/infra/docling_tree.py b/document-parser/infra/docling_tree.py index dfed869..c003d54 100644 --- a/document-parser/infra/docling_tree.py +++ b/document-parser/infra/docling_tree.py @@ -25,6 +25,7 @@ LABEL_MAP: dict[str, str] = { "text": "Paragraph", "list_item": "ListItem", "list": "List", # distinct from :ListItem — a list is a container + "inline": "Paragraph", # see issue #197 — collapsed into one paragraph node "table": "Table", "picture": "Figure", "formula": "Formula", @@ -44,6 +45,28 @@ def element_label(docling_label: str) -> str: return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL) +def is_inline_group(item: dict[str, Any]) -> bool: + """True iff `item` is a Docling InlineGroup (paragraph of mixed style runs). + + Docling represents an inline-styled paragraph as one entry in `groups[]` + (label `inline`) plus N entries in `texts[]` (label `text`), one per style + run. We collapse them into a single Paragraph projection — see #197. + """ + return (item.get("label") or "").lower() == "inline" + + +def is_picture(item: dict[str, Any]) -> bool: + """True iff `item` is a Docling PictureItem (figure or chart). + + A `picture` keeps its node in the graph (it IS the figure), but its + `children` — internal text labels extracted from a flowchart, diagram, + chart axis labels — are noise for graph readability and are skipped. + Captions live in a separate `captions` field on the picture, not in + `children`, so they are unaffected by this skip. + """ + return (item.get("label") or "").lower() in {"picture", "chart"} + + def iter_items(doc_data: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]: """Yield every item from texts/tables/pictures/groups with its source list key.""" for key in ("texts", "tables", "pictures", "groups"): @@ -95,8 +118,15 @@ def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]: return rows -def dfs_order(doc_data: dict[str, Any]) -> list[str]: - """Return `self_ref`s in reading order (DFS pre-order from body).""" +def dfs_order(doc_data: dict[str, Any], skip_refs: set[str] | None = None) -> list[str]: + """Return `self_ref`s in reading order (DFS pre-order from body). + + `skip_refs` (typically the set returned by `build_inline_index`) is omitted + from the chain. Inline groups themselves are emitted but the walk does not + recurse into their style-run children, so the resulting order references + only nodes that survive the InlineGroup collapse. + """ + skip = skip_refs or set() by_ref: dict[str, dict[str, Any]] = {} for _, item in iter_items(doc_data): ref = item.get("self_ref") @@ -110,17 +140,127 @@ def dfs_order(doc_data: dict[str, Any]) -> list[str]: return for ch in children: ref = ch.get("$ref") or ch.get("cref") - if not ref: + if not ref or ref in skip: continue order.append(ref) child = by_ref.get(ref) - if child: + if child and not is_inline_group(child): walk(child.get("children")) walk(body.get("children")) return order +def build_collapse_index( + doc_data: dict[str, Any], +) -> tuple[set[str], dict[str, dict[str, Any]]]: + """Pre-compute graph-projection collapses for a serialized DoclingDocument. + + Two cases produce noise nodes if mirrored 1:1 — see issue #197: + + 1. **InlineGroup** — Docling emits one `groups[]` entry (label `inline`) + plus N `texts[]` style runs. We collapse the children into the group, + which is then projected as a single `:Paragraph` with concatenated + text and the union of children's provs. + 2. **Picture / Chart** — internal text labels extracted from flowcharts, + diagrams or chart axes hang off the picture's `children`. The picture + node itself stays, but its descendants are skipped so the graph isn't + drowned in dozens of tiny labels. + + Returns `(skip_refs, inline_meta)`: + + - `skip_refs`: every `self_ref` to drop from element / edge projections. + - `inline_meta[group_ref]`: `{"text": str, "provs": list[dict]}` — + override values for the inline group projection. Pictures don't have + an entry here; they keep their own text/prov. + """ + by_ref: dict[str, dict[str, Any]] = {} + for _, item in iter_items(doc_data): + ref = item.get("self_ref") + if ref: + by_ref[ref] = item + + skip_refs: set[str] = set() + inline_meta: dict[str, dict[str, Any]] = {} + + for item in by_ref.values(): + ref = item.get("self_ref") or "" + if not ref: + continue + if is_inline_group(item): + text_parts, provs = _collect_inline_descendants(ref, by_ref, skip_refs) + # Re-index prov order so the resulting :Provenance nodes are 0..N-1 + # contiguous instead of carrying each child's individual indices. + for idx, prov in enumerate(provs): + prov["order"] = idx + inline_meta[ref] = { + "text": " ".join(text_parts), + "provs": provs, + } + elif is_picture(item): + _collect_descendants(ref, by_ref, skip_refs) + + return skip_refs, inline_meta + + +def _collect_descendants( + root_ref: str, + by_ref: dict[str, dict[str, Any]], + skip_refs: set[str], +) -> None: + """DFS `root_ref`'s subtree and add every descendant to `skip_refs`. + + Used for picture children — we just want them dropped, not aggregated. + """ + + def walk(ref: str) -> None: + item = by_ref.get(ref) + if item is None: + return + for ch in item.get("children") or []: + child_ref = ch.get("$ref") or ch.get("cref") + if not child_ref or child_ref in skip_refs: + continue + skip_refs.add(child_ref) + walk(child_ref) + + walk(root_ref) + + +def _collect_inline_descendants( + group_ref: str, + by_ref: dict[str, dict[str, Any]], + skip_refs: set[str], +) -> tuple[list[str], list[dict[str, Any]]]: + """DFS an inline group's subtree, returning its text parts and provs in + document order. `skip_refs` is mutated with every visited descendant.""" + text_parts: list[str] = [] + provs: list[dict[str, Any]] = [] + + def walk(ref: str) -> None: + item = by_ref.get(ref) + if item is None: + return + for ch in item.get("children") or []: + child_ref = ch.get("$ref") or ch.get("cref") + if not child_ref or child_ref in skip_refs: + continue + skip_refs.add(child_ref) + child = by_ref.get(child_ref) + if child is None: + continue + if is_inline_group(child): + walk(child_ref) + continue + text = child.get("text") or "" + if text: + text_parts.append(text) + provs.extend(iter_provs(child)) + + walk(group_ref) + return text_parts, provs + + def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]: """Yield page dicts with `page_no`, `width`, `height` from the `pages` map.""" for page_no_str, page_obj in (doc_data.get("pages") or {}).items(): diff --git a/document-parser/infra/neo4j/tree_writer.py b/document-parser/infra/neo4j/tree_writer.py index 2d94e5f..fd22dec 100644 --- a/document-parser/infra/neo4j/tree_writer.py +++ b/document-parser/infra/neo4j/tree_writer.py @@ -17,8 +17,10 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from infra.docling_tree import ( + build_collapse_index, dfs_order, element_label, + is_inline_group, iter_items, iter_pages, iter_provs, @@ -82,6 +84,12 @@ async def write_document( doc_data = json.loads(document_json) ingested_at = datetime.now(tz=UTC).isoformat() + # Issue #197: collapse two noise patterns from Docling into the projection. + # InlineGroups (paragraph style runs) are merged into a single :Paragraph, + # and Pictures' internal text labels (flowchart/diagram/chart annotations) + # are dropped. Both produce refs that land in `skip_refs`. + skip_refs, inline_meta = build_collapse_index(doc_data) + elements: list[dict[str, Any]] = [] # Parallel list: one row per Provenance — each refers back to its owner # element via `self_ref`, so we can batch MATCH-and-link after both node @@ -89,22 +97,29 @@ async def write_document( provenances: list[dict[str, Any]] = [] for _, item in iter_items(doc_data): ref = item.get("self_ref") - if not ref: + if not ref or ref in skip_refs: continue specific = element_label(item.get("label") or "") + props = _element_props(item, doc_id) + if is_inline_group(item): + meta = inline_meta.get(ref, {"text": "", "provs": []}) + props["text"] = meta["text"] + item_provs = meta["provs"] + else: + item_provs = iter_provs(item) elements.append( { "specific_label": specific, "parent_ref": parent_ref(item), - **_element_props(item, doc_id), + **props, } ) - for prov in iter_provs(item): + for prov in item_provs: provenances.append({"doc_id": doc_id, "self_ref": ref, **prov}) pages: list[dict[str, Any]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)] - reading_order = dfs_order(doc_data) + reading_order = dfs_order(doc_data, skip_refs) async with ( neo.driver.session(database=neo.database) as session, diff --git a/document-parser/tests/neo4j/test_tree_writer.py b/document-parser/tests/neo4j/test_tree_writer.py index 3094be1..af276e5 100644 --- a/document-parser/tests/neo4j/test_tree_writer.py +++ b/document-parser/tests/neo4j/test_tree_writer.py @@ -208,3 +208,262 @@ async def test_reader_returns_verbatim_json(neo4j_driver): async def test_reader_missing_doc_returns_none(neo4j_driver): await bootstrap_schema(neo4j_driver) assert await read_document_json(neo4j_driver, "no-such-doc") is None + + +# Issue #197: Docling emits one InlineGroup per inline-styled paragraph plus N +# child `text` items (one per style run). Naive 1:1 mirroring blew up section +# graphs into per-style-run nodes. The writer now collapses an InlineGroup into +# a single :Paragraph node carrying the concatenated text of its children, and +# skips those children entirely. +INLINE_FIXTURE = { + "name": "inline.html", + "pages": { + "1": {"page_no": 1, "size": {"width": 595, "height": 842}}, + }, + "body": { + "self_ref": "#/body", + "children": [ + {"$ref": "#/texts/0"}, + {"$ref": "#/groups/0"}, + ], + }, + "texts": [ + { + "self_ref": "#/texts/0", + "parent": {"$ref": "#/body"}, + "label": "section_header", + "text": "Heading", + "level": 1, + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}], + }, + { + "self_ref": "#/texts/1", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "Hello", + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}], + }, + { + "self_ref": "#/texts/2", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "world", + "prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}], + }, + { + "self_ref": "#/texts/3", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "!", + "prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}], + }, + ], + "tables": [], + "pictures": [], + "groups": [ + { + "self_ref": "#/groups/0", + "parent": {"$ref": "#/body"}, + "label": "inline", + "children": [ + {"$ref": "#/texts/1"}, + {"$ref": "#/texts/2"}, + {"$ref": "#/texts/3"}, + ], + }, + ], +} + + +async def test_inline_group_collapses_into_single_paragraph(neo4j_driver): + await bootstrap_schema(neo4j_driver) + doc_json = json.dumps(INLINE_FIXTURE) + + result = await write_document( + neo4j_driver, + doc_id="doc-inline", + filename="inline.html", + document_json=doc_json, + ) + + # Section header + collapsed inline group only — NOT 5 (+3 style runs). + assert result.elements_written == 2 + # The inline group inherits its 3 children's provs (1 each); the section + # header has its own prov → 4 Provenance nodes total. + assert result.provenances_written == 4 + + async with neo4j_driver.driver.session(database=neo4j_driver.database) as s: + r = await s.run( + "MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'}) " + "RETURN e.text AS text", + id="doc-inline", + ) + rec = await r.single() + assert rec is not None, "InlineGroup should write a :Paragraph node" + assert rec["text"] == "Hello world !" + + for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"): + assert ( + await _count( + s, + "MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n", + id="doc-inline", + ref=child_ref, + ) + == 0 + ), f"Style-run {child_ref} should be skipped, not written as a node" + + # Inline group inherits provs from all children (order preserved). + assert ( + await _count( + s, + "MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'})" + "-[:HAS_PROV]->(pv:Provenance) RETURN count(pv) AS n", + id="doc-inline", + ) + == 3 + ) + + # Reading order: section_header → inline-as-paragraph (1 NEXT edge). + assert ( + await _count( + s, + "MATCH (a:Element {doc_id: $id})-[:NEXT]->(b:Element) RETURN count(*) AS n", + id="doc-inline", + ) + == 1 + ) + + assert ( + await _count( + s, + "MATCH (:Document {id: $id})-[:HAS_ROOT]->(:Element) RETURN count(*) AS n", + id="doc-inline", + ) + == 2 + ) + + # ON_PAGE through Provenance → Page (matches the new schema). + assert ( + await _count( + s, + "MATCH (:Element {doc_id: $id})-[:HAS_PROV]->" + "(:Provenance)-[:ON_PAGE]->(:Page) RETURN count(*) AS n", + id="doc-inline", + ) + == 4 + ) + + +# Same issue (#197): a Picture's `children` are internal text labels (flowchart +# boxes, chart axis labels, diagram callouts) extracted by Docling's layout +# model. Mirroring them 1:1 drowns the figure in dozens of tiny nodes. +PICTURE_FIXTURE = { + "name": "figure.pdf", + "pages": { + "1": {"page_no": 1, "size": {"width": 595, "height": 842}}, + }, + "body": { + "self_ref": "#/body", + "children": [ + {"$ref": "#/texts/0"}, + {"$ref": "#/pictures/0"}, + ], + }, + "texts": [ + { + "self_ref": "#/texts/0", + "parent": {"$ref": "#/body"}, + "label": "caption", + "text": "Figure 1: Pipeline overview.", + "prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}], + }, + # Internal labels — children of #/pictures/0. + { + "self_ref": "#/texts/1", + "parent": {"$ref": "#/pictures/0"}, + "label": "text", + "text": "Parse", + "prov": [{"page_no": 1, "bbox": {"l": 100, "t": 200, "r": 130, "b": 220}}], + }, + { + "self_ref": "#/texts/2", + "parent": {"$ref": "#/pictures/0"}, + "label": "text", + "text": "Build", + "prov": [{"page_no": 1, "bbox": {"l": 140, "t": 200, "r": 170, "b": 220}}], + }, + { + "self_ref": "#/texts/3", + "parent": {"$ref": "#/pictures/0"}, + "label": "text", + "text": "Enrich", + "prov": [{"page_no": 1, "bbox": {"l": 180, "t": 200, "r": 220, "b": 220}}], + }, + ], + "tables": [], + "pictures": [ + { + "self_ref": "#/pictures/0", + "parent": {"$ref": "#/body"}, + "label": "picture", + "children": [ + {"$ref": "#/texts/1"}, + {"$ref": "#/texts/2"}, + {"$ref": "#/texts/3"}, + ], + "captions": [{"$ref": "#/texts/0"}], + "prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}], + } + ], + "groups": [], +} + + +async def test_picture_internal_labels_are_skipped(neo4j_driver): + await bootstrap_schema(neo4j_driver) + doc_json = json.dumps(PICTURE_FIXTURE) + + result = await write_document( + neo4j_driver, + doc_id="doc-pic", + filename="figure.pdf", + document_json=doc_json, + ) + + # Caption + picture only — the 3 internal labels are skipped. + assert result.elements_written == 2 + + async with neo4j_driver.driver.session(database=neo4j_driver.database) as s: + for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"): + assert ( + await _count( + s, + "MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n", + id="doc-pic", + ref=child_ref, + ) + == 0 + ), f"Picture child {child_ref} should be skipped" + + # Picture stays a :Figure node with its own prov. + assert ( + await _count( + s, + "MATCH (e:Element:Figure {doc_id: $id, self_ref: '#/pictures/0'}) " + "RETURN count(e) AS n", + id="doc-pic", + ) + == 1 + ) + + # No PARENT_OF from the picture to its dropped children. + assert ( + await _count( + s, + "MATCH (:Element {doc_id: $id, self_ref: '#/pictures/0'})-[:PARENT_OF]->" + "(:Element) RETURN count(*) AS n", + id="doc-pic", + ) + == 0 + ) diff --git a/document-parser/tests/test_docling_graph.py b/document-parser/tests/test_docling_graph.py index e184db9..13f8252 100644 --- a/document-parser/tests/test_docling_graph.py +++ b/document-parser/tests/test_docling_graph.py @@ -215,6 +215,166 @@ def test_title_is_surfaced_on_document_node(): assert doc_node["title"] == "My Doc.pdf" +def test_inline_group_collapses_into_single_paragraph(): + """Issue #197: an InlineGroup + N child `text` items must yield ONE + Paragraph node carrying the joined text and the union of children's provs.""" + fixture = { + "pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}}, + "body": { + "self_ref": "#/body", + "children": [{"$ref": "#/texts/0"}, {"$ref": "#/groups/0"}], + }, + "texts": [ + { + "self_ref": "#/texts/0", + "parent": {"$ref": "#/body"}, + "label": "section_header", + "text": "Heading", + "level": 1, + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}], + }, + { + "self_ref": "#/texts/1", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "Hello", + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}], + }, + { + "self_ref": "#/texts/2", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "world", + "prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}], + }, + { + "self_ref": "#/texts/3", + "parent": {"$ref": "#/groups/0"}, + "label": "text", + "text": "!", + "prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}], + }, + ], + "tables": [], + "pictures": [], + "groups": [ + { + "self_ref": "#/groups/0", + "parent": {"$ref": "#/body"}, + "label": "inline", + "children": [ + {"$ref": "#/texts/1"}, + {"$ref": "#/texts/2"}, + {"$ref": "#/texts/3"}, + ], + }, + ], + } + payload = build_graph_payload(json.dumps(fixture), doc_id="doc-inline") + + # Section header + collapsed inline group only — NOT 5 (+3 style runs). + elements = [n for n in payload.nodes if n.get("group") == "element"] + assert {e["self_ref"] for e in elements} == {"#/texts/0", "#/groups/0"} + + inline = next(n for n in elements if n["self_ref"] == "#/groups/0") + assert inline["label"] == "Paragraph" + assert inline["text"] == "Hello world !" + # Provs union: 3 children x 1 prov each = 3 provs on the collapsed node. + assert len(inline["provs"]) == 3 + assert all(p.get("page_no") == 1 for p in inline["provs"]) + + # NEXT follows the post-collapse reading order — section_header → inline only. + nexts = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "NEXT"] + assert nexts == [("elem::#/texts/0", "elem::#/groups/0")] + + # ON_PAGE edges still wire the surviving elements to the page (one per + # element, deduped by `seen_pages`). + on_page = [e for e in payload.edges if e["type"] == "ON_PAGE"] + assert sorted(e["source"] for e in on_page) == ["elem::#/groups/0", "elem::#/texts/0"] + + +def test_picture_internal_labels_are_skipped(): + """Issue #197: a Picture's `children` (flowchart / diagram text labels + extracted by Docling's layout model) must not become standalone graph + nodes — they drown the figure in dozens of tiny labels. The picture + itself stays, and a caption (separate `parent` chain on body) is + unaffected.""" + fixture = { + "pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}}, + "body": { + "self_ref": "#/body", + "children": [{"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}], + }, + "texts": [ + # Caption — separate item under body, referenced from the picture's + # `captions` field (not its `children`). Survives the collapse. + { + "self_ref": "#/texts/0", + "parent": {"$ref": "#/body"}, + "label": "caption", + "text": "Figure 1: Sketch of Docling's pipelines.", + "prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}], + }, + # Internal labels of the figure (flowchart boxes). Live in + # `texts[]` with `parent` pointing at the picture. + *[ + { + "self_ref": f"#/texts/{i}", + "parent": {"$ref": "#/pictures/0"}, + "label": "text", + "text": label, + "prov": [ + { + "page_no": 1, + "bbox": {"l": 100 + i * 30, "t": 200, "r": 130 + i * 30, "b": 220}, + } + ], + } + for i, label in enumerate( + ["Parse", "Build", "Enrich", "Assemble", "Document"], start=1 + ) + ], + ], + "tables": [], + "pictures": [ + { + "self_ref": "#/pictures/0", + "parent": {"$ref": "#/body"}, + "label": "picture", + "children": [ + {"$ref": "#/texts/1"}, + {"$ref": "#/texts/2"}, + {"$ref": "#/texts/3"}, + {"$ref": "#/texts/4"}, + {"$ref": "#/texts/5"}, + ], + "captions": [{"$ref": "#/texts/0"}], + "prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}], + } + ], + "groups": [], + } + payload = build_graph_payload(json.dumps(fixture), doc_id="doc-pic") + + elements = [n for n in payload.nodes if n.get("group") == "element"] + refs = {e["self_ref"] for e in elements} + # Picture + caption only — the 5 internal labels must be dropped. + assert refs == {"#/pictures/0", "#/texts/0"} + + # Picture keeps its own label / text / prov (no inline-style override). + pic = next(e for e in elements if e["self_ref"] == "#/pictures/0") + assert pic["label"] == "Figure" + assert pic["docling_label"] == "picture" + + # No PARENT_OF edges from the picture to its (skipped) children. + parent_edges = [e for e in payload.edges if e["type"] == "PARENT_OF"] + assert all(e["source"] != "elem::#/pictures/0" for e in parent_edges) + + # NEXT chain only includes surviving elements. + next_targets = [e["target"] for e in payload.edges if e["type"] == "NEXT"] + assert all(t in {"elem::#/texts/0", "elem::#/pictures/0"} for t in next_targets) + + def test_element_text_is_capped_at_200_chars(): long = "x" * 500 fixture = {