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.