DOCX Table Extraction#
DOCX table extraction is handled by _handle_tables() in MsWordDocumentBackend. It operates directly on lxml elements via python-docx, converting OOXML table structure into TableData / TableCell / RichTableCell objects in the DoclingDocument.
Distinct scope: This is unrelated to Table Structure Recognition (model-based detection from images/PDFs) and Table Cell Formatting (per-cell styling metadata). Both of those topics have their own articles.
Entry Point and Flow#
Parsing is triggered from _walk_linear() when a tbl element is encountered. Tables with exactly one cell are treated as inline prose (furniture) rather than a structured table .
For all other tables, a TableData is created with num_rows and num_cols read from table.rows and table.columns, and a TableItem is added to the document .
Merged Cell Detection: grid_span and the cell_set#
The outer loop iterates rows; the inner loop advances col_idx in grid-column steps using cell.grid_span . This handles horizontal merges: a cell spanning two columns has grid_span=2, so col_idx jumps by 2 after processing it.
A cell_set: set[CT_Tc] deduplicates the underlying XML element. python-docx resolves vertical merges (vMerge) by returning the same _tc object for every row that shares a merged cell — so a cell already in cell_set is simply skipped .
Vertical Span Scanning#
After deduplication, the backend scans downward to determine the true row_span for cells involved in vertical merges. It advances spanned_idx until table.rows[spanned_idx].cells[col_idx]._tc differs from the current cell's _tc (or the table ends).
The resulting row_span = spanned_idx - row_idx and col_span = cell.grid_span are written into the cell record .
Span Representation in the Data Model#
Each cell is stored as a TableCell (plain text) or RichTableCell (formatted/nested content) with six positional fields:
| Field | Meaning |
|---|---|
row_span / col_span | Number of rows/columns the cell occupies |
start_row_offset_idx / end_row_offset_idx | Grid row range [start, end) |
start_col_offset_idx / end_col_offset_idx | Grid column range [start, end) |
TableData.grid (a computed property) reconstructs the full 2-D matrix by fan-filling span positions with a reference to the originating cell .
column_header is set to True only for cells in grid row 0 . row_header is always False in the DOCX backend.
Rich vs. Plain Cell Classification#
_is_rich_table_cell() gates whether _walk_linear is invoked on the cell content. A cell is "rich" if it has:
- More than one paragraph
- Non-
p/non-tcPrblock elements (nested tables, images) - Any embedded BLIP (image)
- Runs with non-default
Formatting(bold, italic, etc.)
Plain cells (single unstyled paragraph) are stored as bare TableCell for efficiency .
Known Bug: gridBefore/gridAfter Causes Silent Cell Truncation#
Symptom: Tables where a row "starts late" — because leading cells were merged or deleted, causing Word to emit <w:gridBefore w:val="N"/> — produce silently truncated or completely missing table output. No warning appears at default log levels.
Root cause 1 — IndexError in the vertical scan: table.rows[spanned_idx].cells[col_idx] raises IndexError when the target row is shorter (a gridBefore/gridAfter row, or any row whose grid width is smaller than the table's). This exception is caught in _walk_linear and logged only at DEBUG , silently aborting parsing of the entire table.
Root cause 2 — grid_cols_before misused as a row offset: Even when parsing doesn't crash, row.grid_cols_before (a column count) is incorrectly added to row_idx to compute start_row_offset_idx, end_row_offset_idx, and the column_header flag . This shifts cells down into the wrong grid row instead of right into the correct grid column. The resulting output is structurally valid but semantically wrong — row→value bindings are corrupt.
Affected versions: Confirmed on docling 2.107.0 and 2.110.0 .
Proposed fix: Replace positional row.cells indexing with a grid-matrix keyed by real layout-grid coordinates. row.cells already expands grid_span and resolves vMerge to root _tc, so a coordinate-keyed matrix eliminates the fragile scan and makes gridBefore/gridAfter slots render as genuinely empty grid positions. See issue #3739 and the linked PR #3745 for the proposed implementation.
Key Files#
| File | Purpose |
|---|---|
docling/backend/msword_backend.py | _handle_tables, _is_rich_table_cell, _walk_linear |
docling_core/types/doc/document.py | TableCell, RichTableCell, TableData, TableData.grid |