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
47 changes: 37 additions & 10 deletions src/docx/oxml/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Callable, cast
from typing import TYPE_CHECKING, Callable, Iterator, cast

from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_DIRECTION
from docx.exceptions import InvalidSpanError
Expand Down Expand Up @@ -60,6 +60,23 @@ class CT_Row(BaseOxmlElement):
trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType]
tc = ZeroOrMore("w:tc")

def iter_tcs(self) -> Iterator[CT_Tc]:
"""Generate this row's cells, including those inside cell content controls.

Only `w:sdt/w:sdtContent` wrappers are transparent. Stop at each cell so
nested tables and block-level controls do not add cells to this row.
"""

def iter_child_tcs(parent: BaseOxmlElement) -> Iterator[CT_Tc]:
for child in parent:
if child.tag == qn("w:tc"):
yield cast(CT_Tc, child)
elif child.tag == qn("w:sdt"):
for content in child.findall(qn("w:sdtContent")):
yield from iter_child_tcs(cast(BaseOxmlElement, content))

yield from iter_child_tcs(self)

@property
def grid_after(self) -> int:
"""The number of unpopulated layout-grid cells at the end of this row."""
Expand All @@ -84,7 +101,7 @@ def tc_at_grid_offset(self, grid_offset: int) -> CT_Tc:
# -- account for omitted cells at the start of the row --
remaining_offset = grid_offset - self.grid_before

for tc in self.tc_lst:
for tc in self.iter_tcs():
# -- We've gone past grid_offset without finding a tc, no sense searching further. --
if remaining_offset < 0:
break
Expand Down Expand Up @@ -177,16 +194,15 @@ def col_count(self):
"""The number of grid columns in this table."""
return len(self.tblGrid.gridCol_lst)

def iter_tcs(self):
def iter_tcs(self) -> Iterator[CT_Tc]:
"""Generate each of the `w:tc` elements in this table, left to right and top to
bottom.

Each cell in the first row is generated, followed by each cell in the second
row, etc.
"""
for tr in self.tr_lst:
for tc in tr.tc_lst:
yield tc
yield from tr.iter_tcs()

@classmethod
def new_tbl(cls, rows: int, cols: int, width: Length) -> CT_Tbl:
Expand Down Expand Up @@ -463,11 +479,12 @@ def grid_offset(self) -> int:

A cell in the leftmost grid-column has offset 0.
"""
grid_before = self._tr.grid_before
preceding_tc_grid_spans = sum(
tc.grid_span for tc in self.xpath("./preceding-sibling::w:tc")
)
return grid_before + preceding_tc_grid_spans
offset = self._tr.grid_before
for tc in self._tr.iter_tcs():
if tc is self:
return offset
offset += tc.grid_span
raise ValueError("cell is not a supported cell of its row")

@property
def grid_span(self) -> int:
Expand Down Expand Up @@ -686,6 +703,16 @@ def raise_on_tee_shaped(a: CT_Tc, b: CT_Tc):
bottom = max(self.bottom, other_tc.bottom)
right = max(self.right, other_tc.right)

# -- The merge implementation removes sibling cells. Crossing a content
# -- control would skip cells or discard its metadata, so reject the
# -- entire operation before moving any content or modifying the XML.
for tr in self._tbl.tr_lst[top:bottom]:
offset = tr.grid_before
for tc in tr.iter_tcs():
if offset < right and offset + tc.grid_span > left and tc.getparent() is not tr:
raise InvalidSpanError("cannot merge cells inside content controls")
offset += tc.grid_span

return top, left, bottom - top, right - left

def _span_to_width(self, grid_width: int, top_tc: CT_Tc, vMerge: str | None):
Expand Down
8 changes: 6 additions & 2 deletions src/docx/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,9 @@ def merge(self, other_cell: _Cell):
"""Return a merged cell created by spanning the rectangular region having this
cell and `other_cell` as diagonal corners.

Raises |InvalidSpanError| if the cells do not define a rectangular region.
Raises |InvalidSpanError| if the cells do not define a rectangular region
or the region contains cells enclosed in content controls. Content-control
cells can be read, but merging across their wrappers is not supported.
"""
tc, tc_2 = self._tc, other_cell._tc
merged_tc = tc.merge(tc_2)
Expand Down Expand Up @@ -396,6 +398,8 @@ def __init__(self, tr: CT_Row, parent: TableParent):
def cells(self) -> tuple[_Cell, ...]:
"""Sequence of |_Cell| instances corresponding to cells in this row.

Cells enclosed in cell-level content controls are included in document order.

Note that Word allows table rows to start later than the first column and end before the
last column.

Expand Down Expand Up @@ -432,7 +436,7 @@ def iter_tc_cells(tc: CT_Tc) -> Iterator[_Cell]:

def _iter_row_cells() -> Iterator[_Cell]:
"""Generate `_Cell` instance for each populated layout-grid cell in this row."""
for tc in self._tr.tc_lst:
for tc in self._tr.iter_tcs():
yield from iter_tc_cells(tc)

return tuple(_iter_row_cells())
Expand Down
180 changes: 180 additions & 0 deletions tests/test_table_sdt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# pyright: reportPrivateUsage=false

"""Reading table cells enclosed in structured document tags (content controls)."""

from __future__ import annotations

from io import BytesIO

import pytest
from lxml import etree

from docx import Document
from docx.document import Document as DocumentObject
from docx.exceptions import InvalidSpanError
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.oxml.table import CT_Tc
from docx.table import Table


def _document(values: list[list[str]]) -> tuple[DocumentObject, Table]:
document = Document()
table = document.add_table(rows=len(values), cols=len(values[0]))
for row, cells in zip(table.rows, values):
for cell, text in zip(row.cells, cells):
cell.text = text
return document, table


def _wrap(tc: CT_Tc, depth: int = 1) -> None:
parent = tc.getparent()
assert parent is not None
position = parent.index(tc)
content: etree._Element = tc
for _ in range(depth):
sdt = OxmlElement("w:sdt")
properties = OxmlElement("w:sdtPr")
properties.append(OxmlElement("w:text"))
body = OxmlElement("w:sdtContent")
body.append(content)
sdt.extend([properties, body])
content = sdt
parent.insert(position, content)


def _reopen(document: DocumentObject) -> DocumentObject:
output = BytesIO()
document.save(output)
return Document(BytesIO(output.getvalue()))


def _assert_matrix(document: DocumentObject, expected: list[list[str]]) -> Table:
document = _reopen(document)
before = document._element.xml
table = document.tables[0]
assert [[cell.text for cell in row.cells] for row in table.rows] == expected
assert [
[table.cell(r, c).text for c in range(len(expected[0]))] for r in range(len(expected))
] == expected
assert [[cell.text for cell in column.cells] for column in table.columns] == [
list(column) for column in zip(*expected)
]
for tr in table._tbl.tr_lst:
offset = tr.grid_before
for tc in tr.iter_tcs():
assert tc.grid_offset == offset
assert tr.tc_at_grid_offset(offset) is tc
offset += tc.grid_span
assert document._element.xml == before
assert _reopen(document)._element.xml == before
return table


class DescribeTableCellContentControls:
@pytest.mark.parametrize("position", [0, 1, 2])
@pytest.mark.parametrize("depth", [1, 2])
def it_reads_wrapped_cells_in_grid_order_after_save(self, position: int, depth: int) -> None:
values = [["Left", "Apple", "Right"], ["Next", "Orange", "End"]]
document, table = _document(values)
_wrap(table.cell(0, position)._tc, depth)
_assert_matrix(document, values)

def it_reads_multiple_content_controls_without_exposing_nested_table_cells(self) -> None:
document, table = _document([["Left", "Apple", "Right"]])
middle = table.cell(0, 1)
nested = middle.add_table(rows=1, cols=2)
nested.cell(0, 0).text = "Nested A"
nested.cell(0, 1).text = "Nested B"
# Capture references before wrapping, including a nested control.
first, middle_tc, last = table._tbl.tr_lst[0].tc_lst
_wrap(first)
_wrap(middle_tc, 2)
_wrap(last)
reopened = _assert_matrix(document, [["Left", "Apple\n", "Right"]])
assert len(list(reopened._tbl.iter_tcs())) == 3
assert [cell.text for cell in reopened.cell(0, 1).tables[0].rows[0].cells] == [
"Nested A",
"Nested B",
]

@pytest.mark.parametrize("position", [0, 1, 2])
def it_preserves_horizontal_spans_before_inside_and_after_controls(self, position: int) -> None:
document, table = _document([["Left", "", "", "Right"]])
table.cell(0, 1).merge(table.cell(0, 2)).text = "Apple"
_wrap(table._tbl.tr_lst[0].tc_lst[position])
_assert_matrix(document, [["Left", "Apple", "Apple", "Right"]])

@pytest.mark.parametrize(
"wrapped_cells",
[
[(0, 1)],
[(1, 1)],
[(0, 1), (1, 1)],
[(1, 0)],
[(1, 2)],
],
)
def it_resolves_vertical_merges_at_the_original_grid_offset(
self, wrapped_cells: list[tuple[int, int]]
) -> None:
document, table = _document([["A0", "", "R0"], ["A1", "", "R1"]])
table.cell(0, 1).merge(table.cell(1, 1)).text = "Apple"
physical = [list(tr.tc_lst) for tr in table._tbl.tr_lst]
for row, column in wrapped_cells:
_wrap(physical[row][column])
_assert_matrix(document, [["A0", "Apple", "R0"], ["A1", "Apple", "R1"]])

def it_keeps_intentional_omitted_grid_positions_distinct_from_wrapped_cells(self) -> None:
document, table = _document([["", "Apple", "Right", ""]])
tr = table._tbl.tr_lst[0]
first, middle, _, last = tr.tc_lst
tr.remove(first)
tr.remove(last)
for tag in ("w:gridBefore", "w:gridAfter"):
marker = OxmlElement(tag)
marker.set(qn("w:val"), "1")
tr.get_or_add_trPr().append(marker)
_wrap(middle)
row = _reopen(document).tables[0].rows[0]
before = row._tr.xml
assert [cell.text for cell in row.cells] == ["Apple", "Right"]
assert row.grid_cols_before == row.grid_cols_after == 1
assert [tc.grid_offset for tc in row._tr.iter_tcs()] == [1, 2]
assert row._tr.tc_at_grid_offset(1) is row.cells[0]._tc
assert row._tr.xml == before

def it_does_not_include_deleted_cells_or_control_properties(self) -> None:
document, table = _document([["Left", "Apple", "Right"]])
middle = table.cell(0, 1)._tc
_wrap(middle)
tr = table._tbl.tr_lst[0]
deleted = OxmlElement("w:del")
hidden = OxmlElement("w:tc")
deleted.append(hidden)
tr.append(deleted)
_assert_matrix(document, [["Left", "Apple", "Right"]])
assert len(tr.tc_lst) == 2 # XML child access used by authoring stays unchanged.

@pytest.mark.parametrize(
"corners", [((0, 0), (0, 1)), ((0, 1), (0, 2)), ((0, 0), (0, 2)), ((0, 0), (1, 2))]
)
def it_rejects_merging_across_a_control_before_changing_any_xml(
self, corners: tuple[tuple[int, int], tuple[int, int]]
) -> None:
document, table = _document([["A0", "Apple", "C0"], ["A1", "B1", "C1"]])
_wrap(table.cell(0, 1)._tc)
table = _reopen(document).tables[0]
before = table._tbl.xml
with pytest.raises(InvalidSpanError, match="content controls"):
table.cell(*corners[0]).merge(table.cell(*corners[1]))
assert table._tbl.xml == before

def it_can_merge_direct_cells_when_a_control_is_outside_the_rectangle(self) -> None:
document, table = _document([["Apple", "Left", "Right"]])
_wrap(table.cell(0, 0)._tc)
control = table._tbl.xpath("./w:tr/w:sdt")[0]
before = etree.tostring(control)
table.cell(0, 1).merge(table.cell(0, 2))
assert etree.tostring(control) == before
_assert_matrix(document, [["Apple", "Left\nRight", "Left\nRight"]])