Coverage for src/qdrant_loader/core/conversion/postprocess.py: 95%
38 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""Post-processing of the docling ``DoclingDocument`` for spreadsheet inputs.
3docling's Excel backend has two defects that hurt every downstream consumer
4(markdown export *and* the HybridChunker, since both read the same structured
5document). We repair them once, immediately after parsing, at the single choke
6point where the :class:`~.outcome.ConvertedDocument` is built — so there is no
7serialize/reparse round-trip and both views see the fix.
9Defect 1 — merged-cell duplication
10 A merged cell is stored *once* in ``table.data.table_cells`` with correct
11 span info, but ``TableData.grid`` (a computed property) — and every
12 serializer that reads it — expands that one cell into *every* position it
13 spans. A title merged across N columns becomes the same text N times. We
14 keep the anchor cell (and its span metadata) intact and append explicit
15 empty 1x1 filler cells for the non-anchor positions; because ``grid``
16 derives itself by overwriting positions in ``table_cells`` order, the
17 later fillers blank the vacated positions while the anchor survives.
19 NOTE on object identity: ``grid`` is a ``@computed_field`` rebuilt on every
20 access, and within it all spanned positions are the *same* ``TableCell``
21 object as the anchor. Mutating a grid position is therefore both futile
22 (not persisted) and dangerous (would mutate the anchor). The repair has to
23 live in ``table_cells``, which is what this does.
25Defect 2 — sheet names dropped
26 Each sheet survives as a body group whose ``name`` is ``"sheet: <name>"``,
27 but neither markdown export nor chunk contextualization surfaces it
28 (markitdown emitted ``## Sheet: <name>`` headings). We insert a
29 ``SECTION_HEADER`` text item as the first child of each sheet group, so the
30 name becomes a markdown heading and rides into each chunk's heading path.
32Both repairs are strictly-better, default-on, and apply *only* to spreadsheet
33inputs — prose formats (PDF/DOCX) are untouched.
34"""
36from __future__ import annotations
38from typing import TYPE_CHECKING
40if TYPE_CHECKING:
41 from docling_core.types.doc import DoclingDocument
43# docling's InputFormat.value for spreadsheets, and the prefix its Excel backend
44# stamps onto each sheet group's name.
45_XLSX_FORMAT = "xlsx"
46_SHEET_PREFIX = "sheet:"
49def postprocess_spreadsheet(document: DoclingDocument, source_format: str) -> None:
50 """Repair the two XLSX defects in place. No-op for non-spreadsheet formats.
52 ``source_format`` is the ``ConvertedDocument.source_format`` (the docling
53 ``InputFormat.value``); we gate on it so PDFs/DOCX are never touched.
54 """
55 if source_format.lower() != _XLSX_FORMAT:
56 return
58 _dedupe_merged_cells(document)
59 _surface_sheet_headings(document)
62def _dedupe_merged_cells(document: DoclingDocument) -> None:
63 """Stop merged cells from being serialized once per spanned position.
65 For every multi-row/col cell, append empty 1x1 filler cells covering all
66 non-anchor positions. ``TableData.grid`` rebuilds itself by writing each
67 ``table_cells`` entry over its offset range in list order, so the fillers
68 (added last) overwrite the duplicate positions while leaving the anchor —
69 at ``(start_row_offset_idx, start_col_offset_idx)`` — and its span intact.
70 """
71 from docling_core.types.doc.document import TableCell
73 for table in document.tables:
74 data = table.data
75 fillers: list[TableCell] = []
76 for cell in data.table_cells:
77 if cell.col_span <= 1 and cell.row_span <= 1:
78 continue
79 anchor_row = cell.start_row_offset_idx
80 anchor_col = cell.start_col_offset_idx
81 for row in range(cell.start_row_offset_idx, cell.end_row_offset_idx):
82 for col in range(cell.start_col_offset_idx, cell.end_col_offset_idx):
83 if row == anchor_row and col == anchor_col:
84 continue # keep the anchor's text exactly once
85 fillers.append(
86 TableCell(
87 text="",
88 row_span=1,
89 col_span=1,
90 start_row_offset_idx=row,
91 end_row_offset_idx=row + 1,
92 start_col_offset_idx=col,
93 end_col_offset_idx=col + 1,
94 )
95 )
96 if fillers:
97 data.table_cells.extend(fillers)
100def _surface_sheet_headings(document: DoclingDocument) -> None:
101 """Insert a SECTION_HEADER as the first child of each sheet group.
103 docling naming changed across versions: older builds use
104 ``"sheet: <name>"``, newer builds use the plain sheet name. We support both.
106 Inserting before the group's current first child (with ``after=False``)
107 parents the header on the group and makes it lead the sheet.
108 """
109 from docling_core.types.doc import DocItemLabel
111 for group in document.groups:
112 name = group.name or ""
113 if not name.strip() or not group.children:
114 continue
116 lowered = name.lower()
117 sheet_name = (
118 name.split(":", 1)[1].strip()
119 if lowered.startswith(_SHEET_PREFIX)
120 else name.strip()
121 )
122 if not sheet_name:
123 continue
125 first_child = group.children[0].resolve(document)
126 document.insert_text(
127 sibling=first_child,
128 label=DocItemLabel.SECTION_HEADER,
129 text=f"Sheet: {sheet_name}",
130 after=False,
131 )