Repository navigation
DOCX: a table row wrapped in w:customXml or w:sdt is dropped, and a cell wrapped in w:customXml with it #4382
Description
Bug
_handle_tables reads rows through python-docx Table.rows, which yields only direct w:tr children of w:tbl (docx/oxml/table.py declares tr = ZeroOrMore("w:tr")). A row Word wrapped in w:customXml or w:sdt is invisible: its text never reaches the document and num_rows comes out short, so the grid is wrong.
_row_cells already solves the cell half for w:sdt; #3951 added it for #3946. It does not handle w:customXml, and no row-level equivalent exists. Both wrappers are legal there: w:customXml of type CT_CustomXmlRow is a child of w:tbl through w:EG_ContentRowContent.
| markup | read? |
|---|---|
w:customXml around a w:tr |
no, row lost and num_rows short |
w:sdt around a w:tr |
no, row lost and num_rows short |
w:customXml around a w:tc |
no, cell empty |
w:sdt around a w:tc |
yes, since #3951 |
Steps to reproduce
Self-contained: it builds its own input and goes through DocumentConverter.convert().
from docx import Document
from docx.oxml import parse_xml
from docling.document_converter import DocumentConverter
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
cell = lambda t: (f'<w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr>'
f'<w:p><w:r><w:t xml:space="preserve">{t}</w:t></w:r></w:p></w:tc>')
doc = Document()
body = doc.element.body
body.insert(len(body) - 1, parse_xml(f'''<w:tbl {W}>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr>{cell("plain A1")}{cell("plain A2")}</w:tr>
<w:customXml w:element="Tagged"><w:tr>{cell("customXml row B1")}{cell("customXml row B2")}</w:tr></w:customXml>
<w:sdt><w:sdtPr/><w:sdtContent><w:tr>{cell("sdt row C1")}{cell("sdt row C2")}</w:tr></w:sdtContent></w:sdt>
<w:tr>{cell("plain D1")}<w:customXml w:element="Tagged">{cell("customXml cell D2")}</w:customXml></w:tr>
</w:tbl>'''))
doc.save("wrapped_rows.docx")
res = DocumentConverter().convert("wrapped_rows.docx")
print("rows x cols:", res.document.tables[0].data.num_rows, "x", res.document.tables[0].data.num_cols)
print(res.document.export_to_markdown())$ python repro.py
rows x cols: 2 x 2
| plain A1 | plain A2 |
|------------|------------|
| plain D1 | |
Expected four rows, and customXml cell D2 in the last one.
#4369 does not cover this. I applied its msword_backend.py hunk with git apply and re-ran the script. The output is byte-identical.
#4369 teaches _walk_linear to descend into customXml at the body level. _handle_tables iterates w:tr and w:tc itself, so it never reaches that code. One control separates the two paths. At 2d5c590 a customXml paragraph at body level is dropped, and #4369 recovers it. Every row and cell case above stays unchanged.
This follows up the note I left on #4369, so the finding survives that pull request resolving.
Docling version
2.128.0 at 2d5c590. docling --version needs torch, absent here; the reproduction uses the Python API.
Python version
3.12.3, Ubuntu 24.04.5 x86_64, python-docx 1.2.0.
Duplicate check: of 127 open pull requests, 14 touch msword_backend.py and none touches _handle_tables. I scanned all 808 open issue titles and searched bodies for customXml, content control, sdt, docx table and missing row; the two hits are false positives. The nearest real neighbour is #3946, closed by #3951, which is this defect for w:sdt cells.
I have no copy of Word here, so the fixture is hand-built markup matching the schema. I did not run tests/test_backend_msword.py.
Send this as an extension of _row_cells plus a row-level equivalent, or wait until #4369 lands so the two do not collide?
Activity
ebrahimeolakey commentedon Sep 27, 2026
Went with the first option, row-level equivalent of
_row_cellsplus thecustomXmlcell case: #4393. Your reproduction gives 4 x 2 with every cell in place; #4369 is untouched since it only changes_walk_linear.