fix(docx): stop long vertical merges from dropping table rows from text_as_html

IN
6h ago
+68-4

Fixes #4692

Problem

partition_docx(..., infer_table_structure=True) (the default) silently dropped cells from metadata.text_as_html when a DOCX table has a vertically merged cell spanning about 1000 or more rows. _convert_table_to_html's iter_row_cells walked row.cells, and python-docx resolves each vMerge="continue" cell by recursing to the cell above it (iter_tc_cells → tc._tc_above), one stack frame per merged row — so a long merge raised RecursionError. The except Exception guard caught it, emitted empty cells for the rest of the row, and every deeper row lost all of its cells. No error reached the caller. The plain Table.text was never affected, because _iter_table_texts walks row._tr.tc_lst and skips vMerge="continue" without recursing.

Fix

Replace the row.cells walk with a hand-rolled grid walk over the row's own tc elements (row._tr.tc_lst), expanding each gridSpan and tracking the origin tc of every vertical span in a per-table dict as rows are visited top-down. A vMerge="continue" tc resolves to the tracked origin for its grid column — the same underlying tc python-docx would find by recursing upward — so the merge keys feeding collapse_matrix_of_keyed_cells_to_spans() (and the resulting colspan/rowspan geometry) are unchanged, but no recursion runs however long the merge is.

  • Malformed rows still skip their remainder via the same guard, exactly as before.
  • A vMerge="continue" with no tracked origin (also malformed — nothing above it) now yields its own cell instead of losing the grid position.
  • cell_text memoization by tc is untouched, so a merged cell's text is still computed once.

Testing

New regression test test_partition_docx_very_long_vertical_merge_keeps_every_row builds a 1500-row table with column 0 merged top-to-bottom (raw w:vMerge XML, since python-docx's own merge helpers walk the span with the same recursion):

  • on main (293d235): fails — the rows past ~1000 come back as <td/>, rowspan is gone
  • with this change: passes — <td rowspan="1500">MERGEDHEAD</td> plus all 1500 side-i cells

All 37 existing merge/table/text_as_html tests in test_docx.py pass unchanged (spans, nested-table dedup, rowspan-chunking geometry).

AI-assisted change.

Follow-up: CI lint fix (style only)

The repository-pinned ruff format --check . (ruff 0.15.10, the lint (3.12) job) failed on the new regression test because one f-string used nested double quotes. This follow-up commit changes only that string literal's quoting, so the formatter is satisfied.

Verification on the final commit:

  • ruff format --check .: before this commit, exit 1 with 1 file would be reformatted; after, exit 0 with 357 files already formatted. ruff check . also passes.
  • An ast.dump() comparison of the whole test file before and after is identical, so this change has no semantic effect (only 4 source characters differ).
  • 9 related regression tests in test_docx.py, including the long-vertical-merge case, pass on the final commit.

Limitation: the full 219-test file was not re-run locally and verification was done on Windows; the upstream CI matrix covers Linux and the full suite.