Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions docling/backend/msword_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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:
Expand Down
102 changes: 101 additions & 1 deletion tests/test_backend_msword.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
'<w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr>'
f'<w:p><w:r><w:t xml:space="preserve">{text}</w:t></w:r></w:p></w:tc>'
)

tbl = parse_xml(
f'<w:tbl xmlns:w="{w_ns}">'
'<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>'
f"<w:tr>{cell('A1')}{cell('A2')}</w:tr>"
f'<w:customXml w:element="Tagged"><w:tr>{cell("B1")}{cell("B2")}</w:tr></w:customXml>'
f"<w:sdt><w:sdtPr/><w:sdtContent><w:tr>{cell('C1')}{cell('C2')}</w:tr></w:sdtContent></w:sdt>"
f'<w:tr>{cell("D1")}<w:customXml w:element="Tagged">{cell("D2")}</w:customXml></w:tr>'
"</w:tbl>"
)
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'<w:vMerge w:val="{vmerge}"/>' if vmerge else ""
return (
f'<w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/>{merge}</w:tcPr>'
f'<w:p><w:r><w:t xml:space="preserve">{text}</w:t></w:r></w:p></w:tc>'
)

tbl = parse_xml(
f'<w:tbl xmlns:w="{w_ns}">'
'<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>'
f"<w:tr>{cell('merged', 'restart')}{cell('A2')}</w:tr>"
'<w:customXml w:element="Tagged">'
f"<w:tr>{cell('', 'continue')}{cell('B2')}</w:tr>"
"</w:customXml>"
f"<w:tr>{cell('C1')}{cell('C2')}</w:tr>"
"</w:tbl>"
)
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.

Expand Down
Loading