Skip to content

fix(docx): read table rows and cells wrapped in customXml or sdt - #4393

Open
ebrahimeolakey wants to merge 2 commits into
docling-project:mainfrom
ebrahimeolakey:fix/docx-wrapped-table-rows
Open

ebrahimeolakey wants to merge 2 commits into
docling-project:mainfrom
ebrahimeolakey:fix/docx-wrapped-table-rows

Conversation

@ebrahimeolakey

Copy link
Copy Markdown

Issue resolved by this Pull Request:
Resolves #4382

_handle_tables read rows through python-docx Table.rows, which only yields direct w:tr children of w:tbl. A row that Word wrapped in w:customXml or in a content control (w:sdt) was never visited, so its text was lost and num_rows came out short. Cells had the same gap for w:customXml; the w:sdt cell case was already handled by _row_cells (#3951).

Changes:

  • New _table_rows() returns a table's w:tr elements in document order, unwrapping w:customXml and w:sdt/w:sdtContent on the way down. _handle_tables builds its _Row objects from that list instead of Table.rows, so grid_cols_before and the rest of the row handling are unchanged.
  • _row_cells() now also unwraps w:customXml around a w:tc.
  • Regression test builds the four-row table from the issue (plain row, customXml row, sdt row, plain row with a customXml cell) and checks the 4×2 grid cell by cell.

Result on the issue's reproduction: 2 x 2 with the last row showing only plain D1 before, 4 x 2 with every cell in place after.

This is the "extension of _row_cells plus a row-level equivalent" option from the issue. It does not overlap with #4369: that change is in _walk_linear at body level, this one is in _row_cells / _handle_tables, and the two touch different parts of msword_backend.py.

tests/test_backend_msword.py: 66 passed, 1 skipped (LibreOffice not installed). No groundtruth files changed.

Checklist:

  • Documentation has been updated, if necessary. (docstrings only; no user-facing docs affected)
  • Examples have been added, if necessary. (not needed)
  • Tests have been added, if necessary.

Table.rows only yields direct w:tr children of w:tbl, so a row Word
wrapped in w:customXml or w:sdt was dropped and num_rows came out short.
Walk the table's children and unwrap both wrappers before building the
rows; do the same for w:customXml around a w:tc in _row_cells (w:sdt was
already handled there).

Resolves docling-project#4382

Signed-off-by: ebrahimeolakey <oiver.sham@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @ebrahimeolakey, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chrikrah chrikrah left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ebrahimeolakey This fixes #4382 as filed, and I would merge it. Walking w:tbl yourself instead of patching _Row is the right call: docling reads only grid_cols_before and _tr off a row, and both work on a wrapped one, while _Row._index raises AttributeError: 'lxml.etree._Element' object has no attribute 'tr_lst' because the parent is no longer the table.

non-blocking, and it is yours to take or leave: the interaction most likely to regress is a vertical merge whose continuation row is wrapped. It is right on 8bef881 and wrong on the base, so a case would pin what you already fixed. The XML and the two assertions are below if you want them.

$ PYTHONPATH=. ~/.venvs/docling/bin/python repro_4382.py      # my four-row case from the issue
2d5c590c   2 x 2   missing: customXml cell D2, customXml row B1, customXml row B2, sdt row C1, sdt row C2
8bef881    4 x 2   missing: none

$ PYTHONPATH=. ~/.venvs/docling/bin/python repro_4393_edges.py
2d5c590c   vMerge continuation in w:customXml   2 x 2, 'merged' row_span=1, B2 lost
8bef881    vMerge continuation in w:customXml   3 x 2, 'merged' row_span=2, spanning r0-r2

  <w:tr>            <w:tc><w:tcPr><w:vMerge w:val="restart"/> ... merged   <w:tc> ... A2
  <w:customXml w:element="Tagged"><w:tr>
                    <w:tc><w:tcPr><w:vMerge w:val="continue"/> ...         <w:tc> ... B2
  <w:tr>            <w:tc> ... C1                                          <w:tc> ... C2
  assert data.num_rows == 3
  assert next(c for c in data.table_cells if c.text == "merged").row_span == 2

$ PYTHONPATH=. ~/.venvs/docling/bin/python repro_trpr_ins.py
2d5c590c   w:tr/w:trPr/w:ins   2 x 2, both inserted cells read
8bef881    w:tr/w:trPr/w:ins   2 x 2, both inserted cells read

$ ~/.venvs/docling/bin/python -m pytest tests/test_backend_msword.py -q
2d5c590c   65 passed, 1 skipped in 66.36s
8bef881    66 passed, 1 skipped in 57.71s
# 32 of the 35 documents under tests/data/docx/sources are compared here: drawingml, textbox and
# test_emf_docx are skipped locally because LibreOffice is not installed.

$ git show 2d5c590c:docling/backend/msword_backend.py > docling/backend/msword_backend.py
$ ~/.venvs/docling/bin/python -m pytest tests/test_backend_msword.py -k wrapped -q   # your test kept
>       assert data.num_rows == 4
E       AssertionError: assert 2 == 4
1 failed, 1 passed, 65 deselected in 2.51s

$ ~/.venvs/docling/bin/ruff check docling/backend/msword_backend.py tests/test_backend_msword.py
All checks passed!   # ruff 0.15.12

Collated from two runs per commit on this machine, and no CI result is quoted. ty is a pre-commit hook on docling/*.py and was not run, since it is not installed here.

@dolfim-ibm this touches msword_backend.py, which you and @ceberam merge most often. @ebrahimeolakey, want that vMerge case as a patch against your branch, or would you rather write it?

Suggested in review on docling-project#4393: the continuation row used to be dropped, so the merge ended early and the wrapped row's other cell was lost. Fails on main, passes with the fix.

Signed-off-by: ebrahimeolakey <oiver.sham@gmail.com>
@ebrahimeolakey

Copy link
Copy Markdown
Author

Thanks for running it against both commits. Wrote the vMerge case myself and pushed it, with the same three-row shape as yours: restart row, continuation row inside w:customXml, plain row. It fails on main with 2 rows and passes here with row_span == 2.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ebrahimeolakey Still approve at 8a75684. The wrapped-row test fails on the base backend and passes here, so it guards the merge case from my last review.

$ PYTHONPATH=. ~/.venvs/docling/bin/python -m pytest tests/test_backend_msword.py -q -p no:cacheprovider
8a75684    67 passed, 1 skipped in 71.66s       # 8bef881 was 66 passed, 1 skipped

$ git show 2d5c590c:docling/backend/msword_backend.py > docling/backend/msword_backend.py   # tests kept
$ PYTHONPATH=. ~/.venvs/docling/bin/python -m pytest tests/test_backend_msword.py -q -p no:cacheprovider -k wrapped
E       AssertionError: assert 2 == 4
E       AssertionError: assert 2 == 3
2 failed, 1 passed, 65 deselected in 2.90s

$ git merge-tree --write-tree upstream/main 8a75684        # main at ed04377
CONFLICT (content): Merge conflict in docling/backend/msword_backend.py

Main moved under the docx imports, so GitHub shows a conflict. Could you rebase? Keeping both CharacterStyle and _Row resolves it. @PeterStaar-IBM once that is in, this is ready for a look.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DOCX: a table row wrapped in w:customXml or w:sdt is dropped, and a cell wrapped in w:customXml with it

2 participants