1073 lines
38 KiB
Python
1073 lines
38 KiB
Python
"""Docling IR builder: ``DoclingDocument`` JSON → :class:`IRDoc`.
|
|
|
|
Input contract: a ``*.docling_raw/`` directory containing a ``<stem>.json``
|
|
produced by docling-serve with ``to_formats=[json,md]`` +
|
|
``image_export_mode=referenced``. Companion ``<stem>.md`` and
|
|
``artifacts/`` are not read by the builder (markdown stays for human
|
|
inspection; image bytes are referenced by relative URI).
|
|
|
|
Conversion rules (informed by
|
|
``docs/DoclingSidecarRefactorPlan-zh.md`` §5):
|
|
|
|
- **Faithful** mapping. We do NOT correct heading levels from numbering,
|
|
do NOT bind orphan ``caption`` / ``footnote`` text to neighbouring
|
|
tables/pictures via proximity, do NOT merge continuation tables, do NOT
|
|
invent captions or refer to inline neighbours. If docling didn't make
|
|
the link, the sidecar doesn't make it either.
|
|
- ``content_layer != "body"`` is filtered everywhere (top-level traversal,
|
|
group expansion, picture children). Furniture / background never leaks
|
|
into blocks, positions, or consumed_refs.
|
|
- ``texts[*].label="title"`` → heading level 1; ``"section_header"`` →
|
|
Docling ``level + 1`` (default 2 when level missing).
|
|
- ``texts[*].label="caption"|"footnote"`` are dropped from the reading
|
|
stream **iff** their ref is referenced by a table/picture (via
|
|
``captions`` / ``footnotes`` refs, or as a direct ``children`` ref
|
|
whose target is itself a caption/footnote). Otherwise they remain as
|
|
regular text in the reading flow.
|
|
- ``pictures[*]`` without a usable image reference are skipped instead of
|
|
emitting empty-path drawings. ``pictures[*].children`` references that
|
|
are NOT caption/footnote are treated as inner-OCR text and excluded from
|
|
the reading stream only for pictures that are emitted.
|
|
- ``IRPosition`` writes ``origin="LEFTTOP"`` only when the source
|
|
``prov.bbox.coord_origin == "TOPLEFT"``. ``BOTTOMLEFT`` inherits the
|
|
doc-level meta (``{"origin":"LEFTBOTTOM"}`` by default). Coordinates
|
|
are written verbatim — never flipped.
|
|
- ``DOCLING_BBOX_ATTRIBUTES`` env (JSON) can override the doc-level
|
|
``bbox_attributes``, mirroring MinerU's behaviour.
|
|
- Equations: ``texts[k].label == "formula"`` is treated as a structural
|
|
formula signal whenever text/orig/content is non-empty. Top-level formulas
|
|
become block equations; formulas inside inline groups become inline
|
|
equations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from lightrag.parser._markdown import (
|
|
render_heading_line,
|
|
strip_heading_markdown_prefix,
|
|
)
|
|
from lightrag.parser.external._common import env_json
|
|
from lightrag.parser.external.docling.manifest import select_main_json
|
|
from lightrag.sidecar.ir import (
|
|
AssetSpec,
|
|
IRBlock,
|
|
IRDoc,
|
|
IRDrawing,
|
|
IREquation,
|
|
IRPosition,
|
|
IRTable,
|
|
)
|
|
from lightrag.utils import logger
|
|
|
|
|
|
PREFACE_HEADING = "Preface/Uncategorized"
|
|
|
|
# Docling JSON Pointer ``#/texts/3``, ``#/tables/2``, ``#/pictures/0``,
|
|
# ``#/groups/5``, or ``#/body``.
|
|
_REF_PATTERN = re.compile(r"^#/(?P<kind>[a-z_]+)(?:/(?P<index>\d+))?$")
|
|
|
|
|
|
class DoclingIRBuilder:
|
|
"""Stateless except for env-driven config. Reusable across calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.engine_version = os.getenv("DOCLING_ENGINE_VERSION", "").strip()
|
|
self.bbox_attributes = self._load_bbox_attributes_env()
|
|
|
|
@staticmethod
|
|
def _load_bbox_attributes_env() -> dict[str, Any]:
|
|
default = {"origin": "LEFTBOTTOM"}
|
|
parsed = env_json("DOCLING_BBOX_ATTRIBUTES", default)
|
|
if not isinstance(parsed, dict):
|
|
logger.warning(
|
|
"[docling_ir_builder] DOCLING_BBOX_ATTRIBUTES must decode to an object; "
|
|
"falling back to %s",
|
|
default,
|
|
)
|
|
return dict(default)
|
|
return parsed
|
|
|
|
# ------------------------------------------------------------------
|
|
# Entry point
|
|
# ------------------------------------------------------------------
|
|
|
|
def normalize_from_workdir(
|
|
self,
|
|
raw_dir: Path,
|
|
*,
|
|
document_name: str,
|
|
) -> IRDoc:
|
|
main_json = select_main_json(raw_dir, Path(document_name))
|
|
try:
|
|
doc = json.loads(main_json.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
f"Docling raw JSON malformed at {main_json}: {exc}"
|
|
) from exc
|
|
if not isinstance(doc, dict):
|
|
raise ValueError(f"Docling raw JSON is not an object at {main_json}")
|
|
return self._normalize(doc, raw_dir, document_name=document_name)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Core traversal
|
|
# ------------------------------------------------------------------
|
|
|
|
def _normalize(
|
|
self,
|
|
doc: dict,
|
|
raw_dir: Path,
|
|
*,
|
|
document_name: str,
|
|
) -> IRDoc:
|
|
document_format = Path(document_name).suffix.lower().lstrip(".")
|
|
ref_index = _build_ref_index(doc)
|
|
consumed_refs, picture_inner_refs = _precompute_consumed_refs(doc, raw_dir)
|
|
|
|
blocks: list[IRBlock] = []
|
|
assets: list[AssetSpec] = []
|
|
seen_asset_refs: dict[str, str] = {}
|
|
doc_title = ""
|
|
placeholder_counter = 0
|
|
|
|
def _next_key(prefix: str) -> str:
|
|
nonlocal placeholder_counter
|
|
placeholder_counter += 1
|
|
return f"{prefix}{placeholder_counter}"
|
|
|
|
# Heading stack + current block accumulator — identical structure
|
|
# to MinerUIRBuilder so downstream P-chunking and provenance behave
|
|
# the same way regardless of engine.
|
|
heading_stack: list[str] = []
|
|
cb_lines: list[str] = []
|
|
cb_tables: list[IRTable] = []
|
|
cb_drawings: list[IRDrawing] = []
|
|
cb_equations: list[IREquation] = []
|
|
cb_page_set: set[str] = set()
|
|
cb_bbox_positions: list[IRPosition] = []
|
|
cb_heading = PREFACE_HEADING
|
|
cb_level = 0
|
|
cb_parents: list[str] = []
|
|
|
|
visited: set[str] = set()
|
|
kv_count = len(doc.get("key_value_items") or [])
|
|
form_count = len(doc.get("form_items") or [])
|
|
|
|
# --- closures over the accumulator -----------------------------
|
|
|
|
def _flush_block() -> None:
|
|
nonlocal cb_lines, cb_tables, cb_drawings, cb_equations
|
|
nonlocal cb_page_set, cb_bbox_positions
|
|
has_payload = bool(cb_lines or cb_tables or cb_drawings or cb_equations)
|
|
if not has_payload:
|
|
return
|
|
content = "\n".join(line for line in cb_lines if line)
|
|
if not content.strip() and not (cb_tables or cb_drawings or cb_equations):
|
|
cb_lines = []
|
|
cb_page_set = set()
|
|
cb_bbox_positions = []
|
|
return
|
|
positions = [
|
|
IRPosition(type="bbox", anchor=p)
|
|
for p in _sort_page_anchors(cb_page_set)
|
|
] + list(cb_bbox_positions)
|
|
blocks.append(
|
|
IRBlock(
|
|
content_template=content,
|
|
heading=cb_heading,
|
|
level=cb_level,
|
|
parent_headings=list(cb_parents),
|
|
positions=positions,
|
|
tables=list(cb_tables),
|
|
drawings=list(cb_drawings),
|
|
equations=list(cb_equations),
|
|
)
|
|
)
|
|
cb_lines = []
|
|
cb_tables = []
|
|
cb_drawings = []
|
|
cb_equations = []
|
|
cb_page_set = set()
|
|
cb_bbox_positions = []
|
|
|
|
def _open_block(
|
|
heading: str, level: int, parents: list[str], raw_heading: str | None = None
|
|
) -> None:
|
|
nonlocal cb_heading, cb_level, cb_parents
|
|
cb_heading = heading
|
|
cb_level = level
|
|
cb_parents = parents
|
|
# Cap at 6 ``#`` and leave already-markdown headings untouched.
|
|
cb_lines.append(render_heading_line(level, raw_heading or heading))
|
|
|
|
def _append_text(text: str) -> bool:
|
|
if not text:
|
|
return False
|
|
cb_lines.append(text)
|
|
return True
|
|
|
|
def _record_positions(item: dict) -> None:
|
|
for prov in item.get("prov") or []:
|
|
if not isinstance(prov, dict):
|
|
continue
|
|
bbox = prov.get("bbox") or {}
|
|
page_raw = prov.get("page_no")
|
|
charspan = prov.get("charspan")
|
|
if isinstance(bbox, dict) and all(
|
|
k in bbox for k in ("l", "t", "r", "b")
|
|
):
|
|
coord_origin = str(bbox.get("coord_origin") or "").upper()
|
|
origin_override: str | None = None
|
|
if coord_origin == "TOPLEFT":
|
|
origin_override = "LEFTTOP"
|
|
elif coord_origin == "BOTTOMLEFT":
|
|
origin_override = None
|
|
elif coord_origin:
|
|
logger.warning(
|
|
"[docling_ir_builder] unknown coord_origin %r; "
|
|
"writing through as override",
|
|
coord_origin,
|
|
)
|
|
origin_override = coord_origin
|
|
anchor = str(page_raw) if page_raw is not None else None
|
|
range_ = [
|
|
bbox["l"],
|
|
bbox["t"],
|
|
bbox["r"],
|
|
bbox["b"],
|
|
]
|
|
cb_bbox_positions.append(
|
|
IRPosition(
|
|
type="bbox",
|
|
anchor=anchor,
|
|
range=range_,
|
|
charspan=(
|
|
list(charspan) if isinstance(charspan, list) else None
|
|
),
|
|
origin=origin_override,
|
|
)
|
|
)
|
|
elif page_raw is not None:
|
|
cb_page_set.add(str(page_raw))
|
|
|
|
# --- main traversal -------------------------------------------
|
|
|
|
def _visit_ref(ref: str) -> None:
|
|
if not ref or ref in consumed_refs or ref in visited:
|
|
return
|
|
visited.add(ref)
|
|
item = ref_index.get(ref)
|
|
if item is None:
|
|
return
|
|
if _content_layer(item) != "body":
|
|
return
|
|
kind = _ref_kind(ref)
|
|
|
|
if kind == "groups":
|
|
_visit_group(item)
|
|
return
|
|
if kind == "texts":
|
|
_handle_text(item)
|
|
return
|
|
if kind == "tables":
|
|
_handle_table(item)
|
|
return
|
|
if kind == "pictures":
|
|
_handle_picture(item)
|
|
return
|
|
# Unknown kind — log and ignore; falling through silently would
|
|
# hide schema drift in future docling releases.
|
|
logger.warning(
|
|
"[docling_ir_builder] unknown ref kind %r (ref=%r); skipping", kind, ref
|
|
)
|
|
|
|
def _visit_group(group: dict) -> None:
|
|
label = str(group.get("label") or "").lower()
|
|
if label not in {
|
|
"list",
|
|
"inline",
|
|
"picture_area",
|
|
"section",
|
|
"form_area",
|
|
"key_value_area",
|
|
"ordered_list",
|
|
"unordered_list",
|
|
"chapter",
|
|
}:
|
|
logger.warning(
|
|
"[docling_ir_builder] unrecognized group label %r; "
|
|
"expanding children as default reading order",
|
|
label,
|
|
)
|
|
if label == "inline":
|
|
_handle_inline_group(group)
|
|
return
|
|
_visit_children(group)
|
|
|
|
def _visit_children(item: dict) -> None:
|
|
for child_ref in item.get("children") or []:
|
|
ref = _ref_str(child_ref)
|
|
_visit_ref(ref)
|
|
|
|
def _handle_inline_group(group: dict) -> None:
|
|
"""``inline`` groups concatenate text and inline formulas on one line."""
|
|
buf: list[str] = []
|
|
pages_recorded = False
|
|
for child_ref in group.get("children") or []:
|
|
ref = _ref_str(child_ref)
|
|
if ref in consumed_refs:
|
|
continue
|
|
child = ref_index.get(ref)
|
|
if not isinstance(child, dict):
|
|
continue
|
|
if _content_layer(child) != "body":
|
|
continue
|
|
if _ref_kind(ref) != "texts":
|
|
continue
|
|
visited.add(ref)
|
|
label = str(child.get("label") or "").lower()
|
|
piece = (
|
|
_make_equation_placeholder(child, is_block=False)
|
|
if label == "formula"
|
|
else _text_of(child)
|
|
)
|
|
if piece:
|
|
buf.append(piece)
|
|
if not pages_recorded:
|
|
_record_positions(child)
|
|
pages_recorded = True
|
|
line = " ".join(buf).strip()
|
|
if line:
|
|
_append_text(line)
|
|
|
|
def _handle_text(item: dict) -> None:
|
|
nonlocal doc_title, heading_stack
|
|
label = str(item.get("label") or "").lower()
|
|
text = _text_of(item).strip()
|
|
|
|
# Heading?
|
|
heading_level = _docling_heading_level(label, item)
|
|
if heading_level > 0 and text:
|
|
clean_heading = strip_heading_markdown_prefix(text)
|
|
heading_stack = heading_stack[: max(heading_level - 1, 0)]
|
|
parents = [h for h in heading_stack if h]
|
|
heading_stack.append(clean_heading)
|
|
# Every recognized heading starts its own block: flush the
|
|
# in-flight block (body or bare heading) and open a fresh one.
|
|
# A heading with no following body becomes a standalone block
|
|
# whose content is just the heading line.
|
|
_flush_block()
|
|
_open_block(clean_heading, heading_level, parents, text)
|
|
_record_positions(item)
|
|
if not doc_title and heading_level == 1:
|
|
doc_title = clean_heading
|
|
_visit_children(item)
|
|
return
|
|
|
|
# Formula — Docling's label is the structural signal. For DOCX,
|
|
# valid LaTeX may have text == orig, so do not use that equality
|
|
# as an enrichment-off heuristic.
|
|
if label == "formula":
|
|
_handle_formula(item)
|
|
_visit_children(item)
|
|
return
|
|
|
|
# list_item: keep the marker if Docling captured one
|
|
if label == "list_item":
|
|
marker = str(item.get("marker") or "").strip()
|
|
line = f"{marker} {text}".strip() if marker else text
|
|
if line and _append_text(line):
|
|
_record_positions(item)
|
|
_visit_children(item)
|
|
return
|
|
|
|
# Caption/footnote not consumed by any table/picture → keep in
|
|
# reading flow as ordinary text (preserves original prefixes).
|
|
if label in {"caption", "footnote", "text", "code"}:
|
|
if _append_text(text):
|
|
_record_positions(item)
|
|
_visit_children(item)
|
|
return
|
|
|
|
# page_header / page_footer should have been filtered by
|
|
# content_layer; reach here only if someone misuses the label.
|
|
if label in {"page_header", "page_footer"}:
|
|
return
|
|
|
|
# Unknown label: fall back to writing the text and warn once.
|
|
if text:
|
|
logger.warning(
|
|
"[docling_ir_builder] unknown text label %r; treating as body",
|
|
label,
|
|
)
|
|
if _append_text(text):
|
|
_record_positions(item)
|
|
_visit_children(item)
|
|
|
|
def _handle_formula(item: dict) -> None:
|
|
placeholder = _make_equation_placeholder(item, is_block=True)
|
|
if not placeholder:
|
|
return
|
|
cb_lines.append(placeholder)
|
|
_record_positions(item)
|
|
|
|
def _make_equation_placeholder(item: dict, *, is_block: bool) -> str:
|
|
latex_raw = _text_of(item).strip()
|
|
if not latex_raw:
|
|
return ""
|
|
placeholder = _next_key("eq")
|
|
token = "EQ" if is_block else "EQI"
|
|
latex = f"$$ {latex_raw} $$" if is_block else latex_raw
|
|
cb_equations.append(
|
|
IREquation(
|
|
placeholder_key=placeholder,
|
|
latex=latex,
|
|
is_block=is_block,
|
|
self_ref=str(item.get("self_ref") or "") if is_block else "",
|
|
)
|
|
)
|
|
return f"{{{{{token}:{placeholder}}}}}"
|
|
|
|
def _handle_table(item: dict) -> None:
|
|
table = _build_ir_table(item, ref_index)
|
|
if table is None:
|
|
# Empty body — _build_ir_table already logged the drop.
|
|
# Skip placeholder allocation and position recording so the
|
|
# body-less table item leaves no trace in the IR.
|
|
return
|
|
placeholder = _next_key("tb")
|
|
table.placeholder_key = placeholder
|
|
cb_tables.append(table)
|
|
cb_lines.append(f"{{{{TBL:{placeholder}}}}}")
|
|
_record_positions(item)
|
|
|
|
def _handle_picture(item: dict) -> None:
|
|
built = _build_ir_drawing(
|
|
item,
|
|
ref_index=ref_index,
|
|
picture_inner_refs=picture_inner_refs,
|
|
raw_dir=raw_dir,
|
|
seen_asset_refs=seen_asset_refs,
|
|
)
|
|
if built is None:
|
|
return
|
|
drawing, asset = built
|
|
placeholder = _next_key("im")
|
|
drawing.placeholder_key = placeholder
|
|
if asset is not None and asset.ref not in {a.ref for a in assets}:
|
|
assets.append(asset)
|
|
cb_drawings.append(drawing)
|
|
cb_lines.append(f"{{{{IMG:{placeholder}}}}}")
|
|
_record_positions(item)
|
|
|
|
# Kick off traversal from body.children
|
|
body = doc.get("body") or {}
|
|
for child_ref in body.get("children") or []:
|
|
_visit_ref(_ref_str(child_ref))
|
|
|
|
_flush_block()
|
|
|
|
if not doc_title:
|
|
doc_title = Path(document_name).stem or document_name
|
|
|
|
split_option: dict[str, Any] = {}
|
|
if self.engine_version:
|
|
split_option["engine_version"] = self.engine_version
|
|
docling_extras: dict[str, Any] = {}
|
|
if kv_count:
|
|
docling_extras["key_value_items"] = kv_count
|
|
if form_count:
|
|
docling_extras["form_items"] = form_count
|
|
if docling_extras:
|
|
split_option["docling_extras"] = docling_extras
|
|
|
|
return IRDoc(
|
|
document_name=document_name,
|
|
document_format=document_format,
|
|
doc_title=doc_title,
|
|
split_option=split_option,
|
|
blocks=blocks,
|
|
assets=assets,
|
|
bbox_attributes=dict(self.bbox_attributes),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ref_str(node: Any) -> str:
|
|
"""Normalize a Docling reference (``{"$ref": "#/texts/0"}`` or a bare
|
|
string) to its string form. Returns ``""`` on garbage input."""
|
|
if isinstance(node, str):
|
|
return node
|
|
if isinstance(node, dict):
|
|
v = node.get("$ref") or node.get("ref")
|
|
if isinstance(v, str):
|
|
return v
|
|
return ""
|
|
|
|
|
|
def _ref_kind(ref: str) -> str:
|
|
m = _REF_PATTERN.match(ref)
|
|
return m.group("kind") if m else ""
|
|
|
|
|
|
def _build_ref_index(doc: dict) -> dict[str, dict]:
|
|
"""Map every JSON-pointer-style ref to its target object.
|
|
|
|
Builds entries for ``#/body``, ``#/texts/N``, ``#/tables/N``,
|
|
``#/pictures/N``, ``#/groups/N``. The body object is *not* a
|
|
typical content item but we index it so callers don't need a
|
|
special case when chasing arbitrary refs.
|
|
"""
|
|
index: dict[str, dict] = {}
|
|
body = doc.get("body")
|
|
if isinstance(body, dict):
|
|
index["#/body"] = body
|
|
for key, prefix in (
|
|
("texts", "#/texts/"),
|
|
("tables", "#/tables/"),
|
|
("pictures", "#/pictures/"),
|
|
("groups", "#/groups/"),
|
|
):
|
|
items = doc.get(key)
|
|
if not isinstance(items, list):
|
|
continue
|
|
for i, obj in enumerate(items):
|
|
if isinstance(obj, dict):
|
|
index[f"{prefix}{i}"] = obj
|
|
return index
|
|
|
|
|
|
def _precompute_consumed_refs(doc: dict, raw_dir: Path) -> tuple[set[str], set[str]]:
|
|
"""Return ``(consumed_refs, picture_inner_refs)``.
|
|
|
|
``consumed_refs`` enumerates text refs that must NOT enter the reading
|
|
stream. The rules below apply only when the owning table/picture is
|
|
itself in the body content layer — refs harvested from furniture or
|
|
background items are ignored so they do not block legitimate body text
|
|
that might be reachable through ``body.children``:
|
|
|
|
- body ``tables[*].captions`` and ``tables[*].footnotes``
|
|
- body ``pictures[*].captions`` and ``pictures[*].footnotes`` only when
|
|
the picture has a usable image reference and will be emitted
|
|
- body ``tables[*].children`` / ``pictures[*].children`` that resolve
|
|
to ``texts[*]`` with ``label="caption"`` or ``"footnote"``
|
|
- All body ``pictures[*].children`` that are non-caption/footnote texts
|
|
(the picture's inner OCR text). These also land in
|
|
``picture_inner_refs`` so the builder can attribute them to the
|
|
drawing's extras.
|
|
|
|
Sibling text nodes are NOT touched: only refs explicitly linked from a
|
|
table/picture object qualify.
|
|
"""
|
|
consumed: set[str] = set()
|
|
picture_inner: set[str] = set()
|
|
|
|
text_label_index: dict[str, str] = {}
|
|
for i, obj in enumerate(doc.get("texts") or []):
|
|
if isinstance(obj, dict):
|
|
text_label_index[f"#/texts/{i}"] = str(obj.get("label") or "").lower()
|
|
|
|
# Furniture/background tables/pictures must not consume refs that may
|
|
# appear under body.children — the builder contract is that non-body
|
|
# items are filtered everywhere, including their outgoing refs.
|
|
for table in doc.get("tables") or []:
|
|
if not isinstance(table, dict):
|
|
continue
|
|
if _content_layer(table) != "body":
|
|
continue
|
|
for ref in _iter_refs(table.get("captions")):
|
|
consumed.add(ref)
|
|
for ref in _iter_refs(table.get("footnotes")):
|
|
consumed.add(ref)
|
|
for ref in _iter_refs(table.get("children")):
|
|
label = text_label_index.get(ref)
|
|
if label in {"caption", "footnote"}:
|
|
consumed.add(ref)
|
|
|
|
for pic in doc.get("pictures") or []:
|
|
if not isinstance(pic, dict):
|
|
continue
|
|
if _content_layer(pic) != "body":
|
|
continue
|
|
if not _has_usable_picture_image(pic, raw_dir):
|
|
continue
|
|
for ref in _iter_refs(pic.get("captions")):
|
|
consumed.add(ref)
|
|
for ref in _iter_refs(pic.get("footnotes")):
|
|
consumed.add(ref)
|
|
for ref in _iter_refs(pic.get("children")):
|
|
label = text_label_index.get(ref)
|
|
if label in {"caption", "footnote"}:
|
|
consumed.add(ref)
|
|
elif ref.startswith("#/texts/"):
|
|
consumed.add(ref)
|
|
picture_inner.add(ref)
|
|
|
|
return consumed, picture_inner
|
|
|
|
|
|
def _iter_refs(value: Any):
|
|
"""Yield refs from either a list of ref dicts/strings, or a single one."""
|
|
if value is None:
|
|
return
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
ref = _ref_str(item)
|
|
if ref:
|
|
yield ref
|
|
else:
|
|
ref = _ref_str(value)
|
|
if ref:
|
|
yield ref
|
|
|
|
|
|
def _content_layer(item: dict) -> str:
|
|
return str(item.get("content_layer") or "body").lower()
|
|
|
|
|
|
def _text_of(item: dict) -> str:
|
|
for key in ("text", "orig", "content"):
|
|
v = item.get(key)
|
|
if isinstance(v, str) and v.strip():
|
|
return v
|
|
return ""
|
|
|
|
|
|
def _docling_heading_level(label: str, item: dict) -> int:
|
|
"""Map a Docling text item to its IR heading level.
|
|
|
|
- ``title`` → level 1
|
|
- ``section_header`` → ``item.level + 1`` (fallback 2)
|
|
Returns 0 when the item is not a heading.
|
|
"""
|
|
if label == "title":
|
|
return 1
|
|
if label == "section_header":
|
|
raw = item.get("level")
|
|
try:
|
|
level = int(raw)
|
|
except (TypeError, ValueError):
|
|
level = 0
|
|
if level <= 0:
|
|
return 2
|
|
return level + 1
|
|
return 0
|
|
|
|
|
|
def _resolve_text_refs(refs: Any, ref_index: dict[str, dict]) -> list[str]:
|
|
"""Resolve a list of ``$ref`` entries to their text bodies.
|
|
|
|
Skips targets whose ``content_layer`` is not ``"body"``. The builder
|
|
contract (see module docstring) is that furniture/background items
|
|
never leak into sidecar metadata — even when a body table or picture
|
|
explicitly references them, because such refs are typically the
|
|
consequence of a page-header/footer being mislabeled as a caption.
|
|
"""
|
|
out: list[str] = []
|
|
for ref in _iter_refs(refs):
|
|
target = ref_index.get(ref)
|
|
if not isinstance(target, dict):
|
|
continue
|
|
if _content_layer(target) != "body":
|
|
continue
|
|
txt = _text_of(target).strip()
|
|
if txt:
|
|
out.append(txt)
|
|
return out
|
|
|
|
|
|
def _build_ir_table(
|
|
item: dict,
|
|
ref_index: dict[str, dict],
|
|
) -> IRTable | None:
|
|
data = item.get("data") or {}
|
|
grid = data.get("grid") if isinstance(data, dict) else None
|
|
rows = _rows_from_grid(grid)
|
|
if not rows and isinstance(data, dict) and data.get("table_cells"):
|
|
rows = _rows_from_table_cells(data)
|
|
|
|
# Docling never populates IRTable.html, so a table without visible row
|
|
# content would land in the sidecar as ``content=""`` and trip the
|
|
# analyze worker's "missing table content" path (mirrors the MinerU
|
|
# filter in lightrag/parser/external/mineru/ir_builder.py). Drop the
|
|
# item up here so the IR stays clean.
|
|
if not _table_rows_have_content(rows):
|
|
logger.info(
|
|
"[docling_ir_builder] dropping empty table item "
|
|
"(self_ref=%s, num_rows=%s, num_cols=%s)",
|
|
item.get("self_ref"),
|
|
data.get("num_rows") if isinstance(data, dict) else None,
|
|
data.get("num_cols") if isinstance(data, dict) else None,
|
|
)
|
|
return None
|
|
|
|
num_rows = (
|
|
int(data.get("num_rows") or len(rows) or 0)
|
|
if isinstance(data, dict)
|
|
else len(rows)
|
|
)
|
|
num_cols = int(
|
|
(data.get("num_cols") if isinstance(data, dict) else 0)
|
|
or (max((len(r) for r in rows), default=0))
|
|
)
|
|
|
|
table_header = _extract_table_header(grid)
|
|
|
|
captions = _resolve_text_refs(item.get("captions"), ref_index)
|
|
if not captions:
|
|
# Fallback: direct children with label="caption"
|
|
captions = _resolve_children_with_label(
|
|
item.get("children"), ref_index, "caption"
|
|
)
|
|
footnotes = _resolve_text_refs(item.get("footnotes"), ref_index)
|
|
if not footnotes:
|
|
footnotes = _resolve_children_with_label(
|
|
item.get("children"), ref_index, "footnote"
|
|
)
|
|
|
|
return IRTable(
|
|
placeholder_key="",
|
|
rows=rows or None,
|
|
html=None,
|
|
num_rows=num_rows,
|
|
num_cols=num_cols,
|
|
caption=" / ".join(captions),
|
|
footnotes=footnotes,
|
|
table_header=table_header,
|
|
self_ref=str(item.get("self_ref") or ""),
|
|
)
|
|
|
|
|
|
def _table_rows_have_content(rows: list[list[str]]) -> bool:
|
|
"""True iff at least one cell carries visible text."""
|
|
for row in rows:
|
|
for cell in row:
|
|
if isinstance(cell, str) and cell.strip():
|
|
return True
|
|
return False
|
|
|
|
|
|
def _rows_from_grid(grid: Any) -> list[list[str]]:
|
|
out: list[list[str]] = []
|
|
if not isinstance(grid, list):
|
|
return out
|
|
for row in grid:
|
|
if not isinstance(row, list):
|
|
continue
|
|
out.append(
|
|
[str((c or {}).get("text", "") if isinstance(c, dict) else c) for c in row]
|
|
)
|
|
return out
|
|
|
|
|
|
def _rows_from_table_cells(data: dict) -> list[list[str]]:
|
|
num_rows = int(data.get("num_rows") or 0)
|
|
num_cols = int(data.get("num_cols") or 0)
|
|
cells = data.get("table_cells") or []
|
|
if num_rows <= 0 or num_cols <= 0 or not isinstance(cells, list):
|
|
return []
|
|
grid = [[""] * num_cols for _ in range(num_rows)]
|
|
for cell in cells:
|
|
if not isinstance(cell, dict):
|
|
continue
|
|
text = str(cell.get("text") or "")
|
|
rs = int(cell.get("start_row_offset_idx") or 0)
|
|
re_ = int(cell.get("end_row_offset_idx") or rs + 1)
|
|
cs = int(cell.get("start_col_offset_idx") or 0)
|
|
ce_ = int(cell.get("end_col_offset_idx") or cs + 1)
|
|
for r in range(max(rs, 0), min(re_, num_rows)):
|
|
for c in range(max(cs, 0), min(ce_, num_cols)):
|
|
grid[r][c] = text
|
|
return grid
|
|
|
|
|
|
def _extract_table_header(grid: Any) -> list[list[str]] | None:
|
|
"""Return the contiguous top rows where every cell has
|
|
``column_header=True`` and ``start_row_offset_idx==0`` (the spec calls
|
|
out both conditions to defeat false positives from spanning cells).
|
|
"""
|
|
if not isinstance(grid, list):
|
|
return None
|
|
header_rows: list[list[str]] = []
|
|
for row in grid:
|
|
if not isinstance(row, list):
|
|
break
|
|
if (
|
|
all(
|
|
isinstance(c, dict)
|
|
and bool(c.get("column_header"))
|
|
and int(c.get("start_row_offset_idx") or 0) == 0
|
|
for c in row
|
|
)
|
|
and row
|
|
):
|
|
header_rows.append([str((c or {}).get("text", "")) for c in row])
|
|
else:
|
|
break
|
|
return header_rows or None
|
|
|
|
|
|
def _resolve_children_with_label(
|
|
children: Any, ref_index: dict[str, dict], expected_label: str
|
|
) -> list[str]:
|
|
out: list[str] = []
|
|
for ref in _iter_refs(children):
|
|
target = ref_index.get(ref)
|
|
if not isinstance(target, dict):
|
|
continue
|
|
# Same body-only filter as _resolve_text_refs; see its docstring.
|
|
if _content_layer(target) != "body":
|
|
continue
|
|
if str(target.get("label") or "").lower() != expected_label:
|
|
continue
|
|
txt = _text_of(target).strip()
|
|
if txt:
|
|
out.append(txt)
|
|
return out
|
|
|
|
|
|
def _resolve_picture_ocr_paragraphs(
|
|
children: Any, ref_index: dict[str, dict], picture_inner_refs: set[str]
|
|
) -> list[str]:
|
|
"""Resolve picture OCR child refs into non-empty body-layer paragraphs."""
|
|
paragraphs: list[str] = []
|
|
for ref in _iter_refs(children):
|
|
if ref not in picture_inner_refs:
|
|
continue
|
|
target = ref_index.get(ref)
|
|
if not isinstance(target, dict):
|
|
continue
|
|
if _content_layer(target) != "body":
|
|
continue
|
|
txt = _text_of(target).strip()
|
|
if txt:
|
|
paragraphs.append(txt)
|
|
return paragraphs
|
|
|
|
|
|
def _build_ir_drawing(
|
|
item: dict,
|
|
*,
|
|
ref_index: dict[str, dict],
|
|
picture_inner_refs: set[str],
|
|
raw_dir: Path,
|
|
seen_asset_refs: dict[str, str],
|
|
) -> tuple[IRDrawing, AssetSpec | None] | None:
|
|
image = item.get("image") or {}
|
|
uri = ""
|
|
mimetype = ""
|
|
image_size: tuple[float, float] | None = None
|
|
dpi: Any = None
|
|
if isinstance(image, dict):
|
|
uri = str(image.get("uri") or "")
|
|
mimetype = str(image.get("mimetype") or "")
|
|
size = image.get("size") or {}
|
|
if isinstance(size, dict) and "width" in size and "height" in size:
|
|
image_size = (float(size["width"]), float(size["height"]))
|
|
dpi = image.get("dpi")
|
|
|
|
fmt = _image_fmt_from_mimetype(mimetype) or (
|
|
Path(uri).suffix.lstrip(".").lower() if uri else ""
|
|
)
|
|
|
|
captions = _resolve_text_refs(item.get("captions"), ref_index)
|
|
if not captions:
|
|
captions = _resolve_children_with_label(
|
|
item.get("children"), ref_index, "caption"
|
|
)
|
|
footnotes = _resolve_text_refs(item.get("footnotes"), ref_index)
|
|
if not footnotes:
|
|
footnotes = _resolve_children_with_label(
|
|
item.get("children"), ref_index, "footnote"
|
|
)
|
|
|
|
extras: dict[str, Any] = {}
|
|
if image_size is not None:
|
|
extras["intrinsic_size"] = list(image_size)
|
|
if dpi is not None:
|
|
extras["dpi"] = dpi
|
|
if mimetype:
|
|
extras["mimetype"] = mimetype
|
|
if "parent" in item:
|
|
extras["parent"] = item.get("parent")
|
|
ocr_paragraphs = _resolve_picture_ocr_paragraphs(
|
|
item.get("children"), ref_index, picture_inner_refs
|
|
)
|
|
if ocr_paragraphs:
|
|
extras["ocr_texts"] = "\n\n".join(ocr_paragraphs)
|
|
extras["ocr_texts_count"] = len(ocr_paragraphs)
|
|
if item.get("annotations"):
|
|
extras["annotations"] = item.get("annotations")
|
|
if item.get("references"):
|
|
extras["references"] = item.get("references")
|
|
|
|
asset_ref = ""
|
|
asset: AssetSpec | None = None
|
|
path_override: str | None = None
|
|
drawing_kwargs: dict[str, Any] = {}
|
|
|
|
if not uri:
|
|
return None
|
|
if uri.startswith("data:"):
|
|
decoded = _decode_data_uri(uri)
|
|
if decoded is not None:
|
|
payload, ext = decoded
|
|
stem = (
|
|
(item.get("self_ref") or "picture").replace("#/", "").replace("/", "_")
|
|
)
|
|
suggested = f"{stem}.{ext or fmt or 'bin'}"
|
|
asset_ref = uri # use the data URI as a stable ref
|
|
if asset_ref not in seen_asset_refs:
|
|
asset = AssetSpec(
|
|
ref=asset_ref,
|
|
suggested_name=suggested,
|
|
source=payload,
|
|
)
|
|
seen_asset_refs[asset_ref] = suggested
|
|
else:
|
|
logger.warning(
|
|
"[docling_ir_builder] skipping picture %s because data URI could "
|
|
"not be decoded",
|
|
item.get("self_ref") or "<unknown>",
|
|
)
|
|
return None
|
|
elif uri.startswith(("http://", "https://")):
|
|
path_override = uri
|
|
asset_ref = uri
|
|
else:
|
|
asset_ref = uri
|
|
if asset_ref not in seen_asset_refs:
|
|
# A malicious/corrupted bundle JSON could point at "../../etc/..."
|
|
# or an absolute path; the zip extractor's traversal guard only
|
|
# covers member names, not refs embedded in JSON metadata. Resolve
|
|
# against raw_dir and require the result to stay inside.
|
|
source_path = _resolve_local_image_path(raw_dir, uri)
|
|
suggested = Path(uri).name or f"image_{len(seen_asset_refs):06d}"
|
|
asset = AssetSpec(
|
|
ref=asset_ref,
|
|
suggested_name=suggested,
|
|
source=source_path if source_path is not None else None,
|
|
)
|
|
if source_path is None:
|
|
logger.warning(
|
|
"[docling_ir_builder] skipping picture %s because image URI "
|
|
"%r could not be resolved inside %s",
|
|
item.get("self_ref") or "<unknown>",
|
|
uri,
|
|
raw_dir,
|
|
)
|
|
return None
|
|
seen_asset_refs[asset_ref] = suggested
|
|
|
|
if path_override is not None:
|
|
drawing_kwargs["path_override"] = path_override
|
|
|
|
drawing = IRDrawing(
|
|
placeholder_key="",
|
|
asset_ref=asset_ref,
|
|
fmt=fmt,
|
|
caption=" / ".join(captions),
|
|
footnotes=footnotes,
|
|
src=str(item.get("src") or ""),
|
|
self_ref=str(item.get("self_ref") or ""),
|
|
extras=extras,
|
|
**drawing_kwargs,
|
|
)
|
|
return drawing, asset
|
|
|
|
|
|
def _image_uri_of(item: dict) -> str:
|
|
image = item.get("image")
|
|
if not isinstance(image, dict):
|
|
return ""
|
|
return str(image.get("uri") or "")
|
|
|
|
|
|
def _has_usable_picture_image(item: dict, raw_dir: Path) -> bool:
|
|
uri = _image_uri_of(item)
|
|
if not uri:
|
|
return False
|
|
if uri.startswith("data:"):
|
|
return _decode_data_uri(uri) is not None
|
|
if uri.startswith(("http://", "https://")):
|
|
return True
|
|
return _resolve_local_image_path(raw_dir, uri) is not None
|
|
|
|
|
|
def _image_fmt_from_mimetype(mimetype: str) -> str:
|
|
if not mimetype:
|
|
return ""
|
|
if mimetype == "image/jpeg":
|
|
return "jpg"
|
|
if mimetype.startswith("image/"):
|
|
return mimetype[len("image/") :].lower()
|
|
return ""
|
|
|
|
|
|
def _decode_data_uri(uri: str) -> tuple[bytes, str] | None:
|
|
"""Decode ``data:image/png;base64,...`` style URIs.
|
|
|
|
Returns ``(bytes, extension)`` or ``None`` if the payload could not be
|
|
decoded. Non-base64 payloads (extremely rare for images) are not
|
|
supported and yield ``None``.
|
|
"""
|
|
try:
|
|
head, payload = uri.split(",", 1)
|
|
except ValueError:
|
|
return None
|
|
if ";base64" not in head:
|
|
return None
|
|
try:
|
|
data = base64.b64decode(payload, validate=False)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
ext = ""
|
|
if head.startswith("data:image/"):
|
|
ext = head[len("data:image/") :].split(";", 1)[0].lower()
|
|
if ext == "jpeg":
|
|
ext = "jpg"
|
|
return data, ext
|
|
|
|
|
|
def _resolve_local_image_path(raw_dir: Path, uri: str) -> Path | None:
|
|
"""Resolve a relative image URI against the bundle root and return it
|
|
only if the result is a file *inside* ``raw_dir``.
|
|
|
|
Returns ``None`` for: absolute URIs (``Path("foo") / "/etc/x"`` discards
|
|
the left side and would escape), refs that resolve outside the bundle
|
|
(``..``-traversal), and refs whose target does not exist. Symlinks are
|
|
followed by ``resolve()`` and the post-resolution path is what's checked,
|
|
so a symlink inside the bundle pointing outward is also refused.
|
|
"""
|
|
if not uri or os.path.isabs(uri):
|
|
return None
|
|
try:
|
|
base = raw_dir.resolve(strict=False)
|
|
candidate = (raw_dir / uri).resolve(strict=False)
|
|
except (OSError, RuntimeError):
|
|
return None
|
|
try:
|
|
candidate.relative_to(base)
|
|
except ValueError:
|
|
return None
|
|
return candidate if candidate.is_file() else None
|
|
|
|
|
|
def _sort_page_anchors(pages: set[str]) -> list[str]:
|
|
non_numeric = sorted(p for p in pages if not p.isdigit())
|
|
numeric = sorted((p for p in pages if p.isdigit()), key=int)
|
|
return non_numeric + numeric
|
|
|
|
|
|
__all__ = ["DoclingIRBuilder"]
|