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 e5fdd728f..3c70dce61 100644 Binary files a/features/steps/test_files/tbl-props.docx and b/features/steps/test_files/tbl-props.docx differ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index e588c6e86..cd7568cd6 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -76,3 +76,28 @@ Feature: Get and set table properties | to inherit | RTL | RTL | | right-to-left | LTR | LTR | | left-to-right | None | None | + + + Scenario Outline: Get table preferred width + Given a table having a width of + 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