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
36 changes: 36 additions & 0 deletions docs/dev/analysis/features/table/table-props.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------

Expand Down
39 changes: 39 additions & 0 deletions features/steps/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Binary file modified features/steps/test_files/tbl-props.docx
Binary file not shown.
25 changes: 25 additions & 0 deletions features/tbl-props.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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 <width-desc>
Then table.width is <value>

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 <width-desc>
When I assign <new-value> to table.width
Then table.width is <reported-value>

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 |
1 change: 1 addition & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/docx/oxml/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:]
)
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions src/docx/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions tests/oxml/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
39 changes: 39 additions & 0 deletions tests/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down