From e52e6313656b71a169a542c9ec0d18d110f8ee24 Mon Sep 17 00:00:00 2001 From: Alex Sadler Date: Mon, 10 Nov 2025 11:41:10 +0000 Subject: [PATCH] feat(table): add Table.width property Add support for setting table preferred width via Table.width property. This allows programmatic control of the "Preferred width" checkbox in Word's Table Properties dialog, enabling true fixed-width behavior. - Add Table.width property (read/write) - Add CT_TblPr.width property - Register w:tblW element - Add unit tests and BDD scenarios - Add documentation to table-props.rst Implements #1305 --- .../analysis/features/table/table-props.rst | 36 ++++++++++++++++ features/steps/table.py | 39 ++++++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 20419 -> 20290 bytes features/tbl-props.feature | 25 +++++++++++ src/docx/oxml/__init__.py | 1 + src/docx/oxml/table.py | 26 ++++++++++++ src/docx/table.py | 28 +++++++++++++ tests/oxml/test_table.py | 38 +++++++++++++++++ tests/test_table.py | 39 ++++++++++++++++++ 9 files changed, 232 insertions(+) diff --git a/docs/dev/analysis/features/table/table-props.rst b/docs/dev/analysis/features/table/table-props.rst index 73e97449e..f1e26f3cc 100644 --- a/docs/dev/analysis/features/table/table-props.rst +++ b/docs/dev/analysis/features/table/table-props.rst @@ -40,6 +40,42 @@ is used:: False +Preferred Width +--------------- + +Word allows a table to have a preferred width, which corresponds to checking +the "Preferred width" checkbox in the Table Properties dialog. When set, the +table maintains its width regardless of window size, providing true fixed-width +behavior. + +The read/write :attr:`Table.width` property specifies the preferred width for +a table:: + + >>> from docx.shared import Inches, Cm + >>> table = document.add_table(rows=2, cols=2) + >>> table.width + None + >>> table.width = Inches(6) + >>> table.width + 5486400 + >>> table.width = Cm(15) + >>> table.width + 5400040 + >>> table.width = None # Remove preferred width + >>> table.width + None + +When :attr:`Table.width` is set to a |Length| value, Word sets the table's +``w:tblW`` element with ``w:type="dxa"`` and the width in twips. When set to +|None|, the ``w:tblW`` element is removed (or remains with ``w:type="auto"``), +allowing the table to use automatic width. + +This is distinct from the :attr:`Table.allow_autofit` property, which controls +whether column widths adjust based on content. A table can have a fixed +preferred width (``table.width = Inches(6)``) while still allowing autofit +layout (``table.allow_autofit = True``), or vice versa. + + Specimen XML ------------ diff --git a/features/steps/table.py b/features/steps/table.py index 38d49ee0a..af1bf1846 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -145,6 +145,18 @@ def given_a_table_having_table_direction_setting(context: Context, setting: str) context.table_ = document.tables[table_idx] +@given("a table having a width of {width_desc}") +def given_a_table_having_a_width_of_width_desc(context: Context, width_desc: str): + table_idx = { + "no explicit width": 0, + "automatic width": 1, + "1 inch": 9, + "6 inches": 10, + }[width_desc] + document = Document(test_docx("tbl-props")) + context.table_ = document.tables[table_idx] + + @given("a table having two columns") def given_a_table_having_two_columns(context: Context): docx_path = test_docx("blk-containing-table") @@ -265,6 +277,16 @@ def when_assign_value_to_table_table_direction(context: Context, value: str): context.table_.table_direction = new_value +@when("I assign {new_value} to table.width") +def when_I_assign_new_value_to_table_width(context: Context, new_value: str): + from docx.shared import Cm + + if new_value == "None": + context.table_.width = None + else: + context.table_.width = eval(new_value) + + @when("I merge from cell {origin} to cell {other}") def when_I_merge_from_cell_origin_to_cell_other(context: Context, origin: str, other: str): def cell(table: Table, idx: int): @@ -443,6 +465,23 @@ def then_table_table_direction_is_value(context: Context, value: str): assert actual_value == expected_value, "got '%s'" % actual_value +@then("table.width is {value}") +def then_table_width_is_value(context: Context, value: str): + from docx.shared import Cm + + if value == "None": + expected = None + else: + expected = eval(value) + actual = context.table_.width + # Allow small tolerance for twips conversion rounding + if expected is None: + assert actual is None, f"expected None, got {actual}" + else: + tolerance = 50 # EMU + assert abs(actual - expected) < tolerance, f"expected {expected}, got {actual}" + + @then("the cell contains the string I assigned") def then_cell_contains_string_assigned(context: Context): cell, expected_text = context.cell, context.expected_text diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index e5fdd728f713d821b47cdee620b6eb1ea5460ade..3c70dce612c3837083a5e04af702e89b416c5cad 100644 GIT binary patch delta 6311 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 Z;}0@j4fJ~(007?ot$jcFMU7vr{{h@TW7hxx delta 6324 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 + Then table.width is + + Examples: table width settings + | width-desc | value | + | no explicit width | None | + | automatic width | None | + | 1 inch | 914400 | + | 6 inches | 5486400 | + + + Scenario Outline: Set table preferred width + Given a table having a width of + When I assign to table.width + Then table.width is + + Examples: results of assignment to table.width + | width-desc | new-value | reported-value | + | no explicit width | Inches(6) | Inches(6) | + | 1 inch | Inches(2) | Inches(2) | + | 6 inches | Cm(15) | Cm(15) | + | 6 inches | None | None | diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 37f608cef..3c3847580 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -176,6 +176,7 @@ register_element_cls("w:tblPr", CT_TblPr) register_element_cls("w:tblPrEx", CT_TblPrEx) register_element_cls("w:tblStyle", CT_String) +register_element_cls("w:tblW", CT_TblWidth) register_element_cls("w:tc", CT_Tc) register_element_cls("w:tcPr", CT_TcPr) register_element_cls("w:tcW", CT_TblWidth) diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 9457da207..f251040ce 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -301,10 +301,12 @@ class CT_TblPr(BaseOxmlElement): get_or_add_bidiVisual: Callable[[], CT_OnOff] get_or_add_jc: Callable[[], CT_Jc] get_or_add_tblLayout: Callable[[], CT_TblLayoutType] + get_or_add_tblW: Callable[[], CT_TblWidth] _add_tblStyle: Callable[[], CT_String] _remove_bidiVisual: Callable[[], None] _remove_jc: Callable[[], None] _remove_tblStyle: Callable[[], None] + _remove_tblW: Callable[[], None] _tag_seq = ( "w:tblStyle", @@ -332,6 +334,9 @@ class CT_TblPr(BaseOxmlElement): bidiVisual: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:bidiVisual", successors=_tag_seq[4:] ) + tblW: CT_TblWidth | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblW", successors=_tag_seq[7:] + ) jc: CT_Jc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:jc", successors=_tag_seq[8:] ) @@ -386,6 +391,27 @@ def style(self, value: str | None): return self._add_tblStyle().val = value + @property + def width(self) -> Length | None: + """EMU length in `./w:tblW` or |None| if not present or its type is not 'dxa'.""" + tblW = self.tblW + if tblW is None: + return None + return tblW.width + + @width.setter + def width(self, value: Length | None): + """Set the table width to a specific value. + + Setting a Length value sets the table to a fixed preferred width (w:type="dxa"). + Setting None removes the tblW element, causing the table to use automatic width. + """ + if value is None: + self._remove_tblW() + return + tblW = self.get_or_add_tblW() + tblW.width = value + class CT_TblPrEx(BaseOxmlElement): """`w:tblPrEx` element, exceptions to table-properties. diff --git a/src/docx/table.py b/src/docx/table.py index 545c46884..ec3ba82e2 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -160,6 +160,34 @@ def table_direction(self) -> WD_TABLE_DIRECTION | None: def table_direction(self, value: WD_TABLE_DIRECTION | None): self._element.bidiVisual_val = value + @property + def width(self) -> Length | None: + """The preferred width of this table in EMU, or |None| if no explicit width is set. + + Read/write. When set to a |Length| value, the table will have a fixed preferred + width that Word will respect (checking the "Preferred width" box in Table Properties). + This provides true fixed-width behavior where the table maintains its width regardless + of window size. + + Assigning |None| removes any explicit width setting, causing the table to use + automatic width (unchecking the "Preferred width" box). + + Example:: + + >>> from docx.shared import Inches + >>> table = document.add_table(rows=2, cols=2) + >>> table.width + None + >>> table.width = Inches(6.0) + >>> table.width + 5486400 # EMU equivalent of 6 inches + """ + return self._tblPr.width + + @width.setter + def width(self, value: Length | None): + self._tblPr.width = value + @property def _cells(self) -> list[_Cell]: """A sequence of |_Cell| objects, one for each cell of the layout grid. diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 2c9e05344..4c61c3596 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -386,3 +386,41 @@ def top_tc_(self, request: FixtureRequest): @pytest.fixture def tr_(self, request: FixtureRequest): return instance_mock(request, CT_Row) + + +class DescribeCT_TblPr: + """Unit-test suite for `docx.oxml.table.CT_TblPr` objects.""" + + @pytest.mark.parametrize( + ("tblPr_cxml", "expected_value"), + [ + ("w:tblPr", None), + ("w:tblPr/w:tblW{w:w=0,w:type=auto}", None), + ("w:tblPr/w:tblW{w:w=1440,w:type=dxa}", 914400), + ("w:tblPr/w:tblW{w:w=5000,w:type=dxa}", 3175000), + ], + ) + def it_knows_its_width(self, tblPr_cxml: str, expected_value: int | None): + from docx.oxml.table import CT_TblPr + + tblPr = cast(CT_TblPr, element(tblPr_cxml)) + assert tblPr.width == expected_value + + @pytest.mark.parametrize( + ("tblPr_cxml", "new_value", "expected_cxml"), + [ + ("w:tblPr", 914400, "w:tblPr/w:tblW{w:w=1440,w:type=dxa}"), + ("w:tblPr/w:tblW{w:w=0,w:type=auto}", 5486400, "w:tblPr/w:tblW{w:w=8640,w:type=dxa}"), + ("w:tblPr/w:tblW{w:w=1440,w:type=dxa}", 1828800, "w:tblPr/w:tblW{w:w=2880,w:type=dxa}"), + ("w:tblPr/w:tblW{w:w=1440,w:type=dxa}", None, "w:tblPr"), + ], + ) + def it_can_change_its_width( + self, tblPr_cxml: str, new_value: int | None, expected_cxml: str + ): + from docx.oxml.table import CT_TblPr + from docx.shared import Emu + + tblPr = cast(CT_TblPr, element(tblPr_cxml)) + tblPr.width = Emu(new_value) if new_value is not None else None + assert tblPr.xml == xml(expected_cxml) diff --git a/tests/test_table.py b/tests/test_table.py index 479d670c6..2018cd125 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -172,6 +172,45 @@ def it_can_change_its_autofit_setting( table.autofit = new_value assert table._tbl.xml == xml(expected_cxml) + @pytest.mark.parametrize( + ("tbl_cxml", "expected_value"), + [ + ("w:tbl/w:tblPr", None), + ("w:tbl/w:tblPr/w:tblW{w:w=0,w:type=auto}", None), + ("w:tbl/w:tblPr/w:tblW{w:w=1440,w:type=dxa}", 914400), + ("w:tbl/w:tblPr/w:tblW{w:w=5000,w:type=dxa}", 3175000), + ], + ) + def it_knows_its_width( + self, tbl_cxml: str, expected_value: int | None, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + assert table.width == expected_value + + @pytest.mark.parametrize( + ("tbl_cxml", "new_value", "expected_cxml"), + [ + ("w:tbl/w:tblPr", Inches(1), "w:tbl/w:tblPr/w:tblW{w:w=1440,w:type=dxa}"), + ( + "w:tbl/w:tblPr/w:tblW{w:w=0,w:type=auto}", + Inches(6), + "w:tbl/w:tblPr/w:tblW{w:w=8640,w:type=dxa}", + ), + ( + "w:tbl/w:tblPr/w:tblW{w:w=1440,w:type=dxa}", + Inches(2), + "w:tbl/w:tblPr/w:tblW{w:w=2880,w:type=dxa}", + ), + ("w:tbl/w:tblPr/w:tblW{w:w=1440,w:type=dxa}", None, "w:tbl/w:tblPr"), + ], + ) + def it_can_change_its_width( + self, tbl_cxml: str, new_value: Length | None, expected_cxml: str, document_: Mock + ): + table = Table(cast(CT_Tbl, element(tbl_cxml)), document_) + table.width = new_value + assert table._tbl.xml == xml(expected_cxml) + def it_knows_it_is_the_table_its_children_belong_to(self, table: Table): assert table.table is table