diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py
index bfa7fcc9ea..d59bccde08 100644
--- a/docling/backend/msword_backend.py
+++ b/docling/backend/msword_backend.py
@@ -68,7 +68,7 @@
from docx.oxml.table import CT_Tc
from docx.oxml.xmlchemy import BaseOxmlElement
from docx.styles.style import BaseStyle, ParagraphStyle
- from docx.table import Table, _Cell
+ from docx.table import Table, _Cell, _Row
from docx.text.hyperlink import Hyperlink
from docx.text.paragraph import Paragraph
from docx.text.run import Run
@@ -2983,22 +2983,26 @@ def _row_cells(row_element: BaseOxmlElement) -> list[BaseOxmlElement]:
"""Return a row's ``w:tc`` cells in document order.
Word wraps a cell in a content control (``w:sdt``) for date pickers and
- for fields bound to document properties, so the cell then sits at
- ``w:tr/w:sdt/w:sdtContent/w:tc``. ``CT_Row.tc_lst`` only yields direct
- ``w:tc`` children, so such a cell would be skipped entirely and every
- later cell in the row would take its grid column.
+ for fields bound to document properties, and in ``w:customXml`` for
+ custom XML markup, so the cell then sits at
+ ``w:tr/w:sdt/w:sdtContent/w:tc`` or ``w:tr/w:customXml/w:tc``.
+ ``CT_Row.tc_lst`` only yields direct ``w:tc`` children, so such a cell
+ would be skipped entirely and every later cell in the row would take
+ its grid column.
Args:
- row_element: The ``w:tr`` element, or a ``w:sdtContent`` inside one.
+ row_element: The ``w:tr`` element, or a wrapper inside one.
Returns:
- The row's cells, with content-control wrappers unwrapped.
+ The row's cells, with the wrappers unwrapped.
"""
cells: list[BaseOxmlElement] = []
for child in row_element:
tag_name = etree.QName(child).localname
if tag_name == "tc":
cells.append(child)
+ elif tag_name == "customXml":
+ cells.extend(MsWordDocumentBackend._row_cells(child))
elif tag_name == "sdt":
sdt_content = child.find(
"./w:sdtContent",
@@ -3008,6 +3012,38 @@ def _row_cells(row_element: BaseOxmlElement) -> list[BaseOxmlElement]:
cells.extend(MsWordDocumentBackend._row_cells(sdt_content))
return cells
+ @staticmethod
+ def _table_rows(table_element: BaseOxmlElement) -> list[BaseOxmlElement]:
+ """Return a table's ``w:tr`` rows in document order.
+
+ A row may be wrapped in ``w:customXml`` or in a content control
+ (``w:sdt``), so it then sits at ``w:tbl/w:customXml/w:tr`` or
+ ``w:tbl/w:sdt/w:sdtContent/w:tr``. ``CT_Tbl.tr_lst`` only yields direct
+ ``w:tr`` children, so such a row would be dropped and the table would
+ come out with too few rows.
+
+ Args:
+ table_element: The ``w:tbl`` element, or a wrapper inside one.
+
+ Returns:
+ The table's rows, with the wrappers unwrapped.
+ """
+ rows: list[BaseOxmlElement] = []
+ for child in table_element:
+ tag_name = etree.QName(child).localname
+ if tag_name == "tr":
+ rows.append(child)
+ elif tag_name == "customXml":
+ rows.extend(MsWordDocumentBackend._table_rows(child))
+ elif tag_name == "sdt":
+ sdt_content = child.find(
+ "./w:sdtContent",
+ namespaces=_OOXML_NAMESPACES,
+ )
+ if sdt_content is not None:
+ rows.extend(MsWordDocumentBackend._table_rows(sdt_content))
+ return rows
+
def _handle_tables(
self,
element: BaseOxmlElement,
@@ -3031,12 +3067,13 @@ def _handle_tables(
"""
elem_ref: list[RefItem] = []
table: Table = Table(element, self.docx_obj)
- num_rows = len(table.rows)
+ rows = [_Row(tr, table) for tr in MsWordDocumentBackend._table_rows(element)]
+ num_rows = len(rows)
num_cols = len(table.columns)
_log.debug(f"Table grid with {num_rows} rows and {num_cols} columns")
if num_rows == 1 and num_cols == 1:
- single_row_cells = MsWordDocumentBackend._row_cells(table.rows[0]._tr)
+ single_row_cells = MsWordDocumentBackend._row_cells(rows[0]._tr)
if not single_row_cells:
return elem_ref
cell_element = _Cell(single_row_cells[0], table)
@@ -3058,7 +3095,7 @@ def _handle_tables(
elem_ref.append(docling_table.get_ref())
open_cells: dict[int, TableCell] = {}
- for row_idx, row in enumerate(table.rows):
+ for row_idx, row in enumerate(rows):
grid_col = row.grid_cols_before
for tc in MsWordDocumentBackend._row_cells(row._tr):
if grid_col >= num_cols:
diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py
index ad5c24c403..c050862b71 100644
--- a/tests/test_backend_msword.py
+++ b/tests/test_backend_msword.py
@@ -23,7 +23,7 @@
from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.opc.constants import RELATIONSHIP_TYPE as RT
-from docx.oxml import OxmlElement
+from docx.oxml import OxmlElement, parse_xml
from docx.oxml.ns import qn
from docx.shared import Inches
from lxml import etree
@@ -1078,6 +1078,106 @@ def build(wrapped_columns: tuple[int, ...]) -> list[str]:
assert build((0, 2)) == row
+def _table_with_wrapped_rows_and_cells(path: Path) -> None:
+ """Write a 2-column table whose rows and one cell sit inside wrappers.
+
+ Row order: a plain row, a row inside ``w:customXml``, a row inside a
+ content control (``w:sdt``), and a plain row whose second cell is inside
+ ``w:customXml``. Both wrappers are allowed there by the OOXML schema
+ (``EG_ContentRowContent`` and ``EG_ContentCellContent``).
+ """
+ w_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+
+ def cell(text: str) -> str:
+ return (
+ ''
+ f'{text}'
+ )
+
+ tbl = parse_xml(
+ f''
+ ''
+ ''
+ f"{cell('A1')}{cell('A2')}"
+ f'{cell("B1")}{cell("B2")}'
+ f"{cell('C1')}{cell('C2')}"
+ f'{cell("D1")}{cell("D2")}'
+ ""
+ )
+ doc = Document()
+ body = doc.element.body
+ body.insert(len(body) - 1, tbl)
+ doc.save(str(path))
+
+
+def test_table_rows_and_cells_wrapped_in_custom_xml_or_content_control(tmp_path):
+ """Rows under ``w:customXml``/``w:sdt`` and cells under ``w:customXml``.
+
+ ``Table.rows`` only yields direct ``w:tr`` children of ``w:tbl``, so a
+ wrapped row was silently dropped and the table came out with too few
+ rows. A cell wrapped in ``w:customXml`` was dropped in the same way; the
+ ``w:sdt`` cell case was already handled.
+ """
+ path = tmp_path / "wrapped_rows.docx"
+ _table_with_wrapped_rows_and_cells(path)
+
+ converter = DocumentConverter(allowed_formats=[InputFormat.DOCX])
+ data = converter.convert(path).document.tables[0].data
+
+ assert data.num_rows == 4
+ assert data.num_cols == 2
+ grid = [[""] * data.num_cols for _ in range(data.num_rows)]
+ for cell in data.table_cells or []:
+ grid[cell.start_row_offset_idx][cell.start_col_offset_idx] = cell.text or ""
+ assert grid == [["A1", "A2"], ["B1", "B2"], ["C1", "C2"], ["D1", "D2"]]
+
+
+def test_vertical_merge_continues_into_a_wrapped_row(tmp_path):
+ """A ``vMerge="continue"`` cell inside a ``w:customXml``-wrapped row.
+
+ The continuation row used to be invisible, so the merge that started in
+ the row above ended early (``row_span`` 1) and the wrapped row's other
+ cell was lost with it. Row order: restart row, wrapped continuation row,
+ plain row.
+ """
+ w_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+
+ def cell(text: str, vmerge: str | None = None) -> str:
+ merge = f'' if vmerge else ""
+ return (
+ f'{merge}'
+ f'{text}'
+ )
+
+ tbl = parse_xml(
+ f''
+ ''
+ ''
+ f"{cell('merged', 'restart')}{cell('A2')}"
+ ''
+ f"{cell('', 'continue')}{cell('B2')}"
+ ""
+ f"{cell('C1')}{cell('C2')}"
+ ""
+ )
+ doc = Document()
+ body = doc.element.body
+ body.insert(len(body) - 1, tbl)
+ path = tmp_path / "wrapped_vmerge.docx"
+ doc.save(str(path))
+
+ converter = DocumentConverter(allowed_formats=[InputFormat.DOCX])
+ data = converter.convert(path).document.tables[0].data
+
+ assert data.num_rows == 3
+ merged = next(c for c in data.table_cells if c.text == "merged")
+ assert merged.row_span == 2
+ assert merged.start_row_offset_idx == 0
+ assert merged.end_row_offset_idx == 2
+ texts = {c.text for c in data.table_cells}
+ assert {"A2", "B2", "C1", "C2"} <= texts
+
+
def test_single_cell_layout_table_wrapped_in_content_control(tmp_path):
"""A one-cell layout table whose only cell is content-control wrapped.