Skip to content
Draft
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
18 changes: 18 additions & 0 deletions docs/user/tables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,21 @@ can itself include one or more tables.

These can be detected using ``_Cell.tables`` or ``_Cell.iter_inner_content()``. The latter preserves
the document order of the table with respect to paragraphs also in the cell.

Repeating table headers
-----------------------

Mark one or more contiguous leading rows to repeat when the table spans pages::

table.rows[0].repeat_as_header = True
table.rows[1].repeat_as_header = True

Assign ``False`` to explicitly disable repetition or ``None`` to remove the direct
setting. New rows have no direct setting. The library does not enable repetition
by default. This property does not change row height, splitting, or cell formatting.

Word only repeats marked rows that form a contiguous group starting with the first
row. A marked row after an unmarked row is stored but does not become a repeating
header. Repetition depends on Word-compatible pagination, and manual page breaks
inside a table can affect it. The property describes the stored setting rather
than a calculated page-layout result.
32 changes: 32 additions & 0 deletions features/steps/repeating_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Acceptance steps for configurable repeating headers."""

from io import BytesIO

from behave import given, then, when

from docx import Document


@given("a table with two leading header rows")
def given_headers(context):
context.document = Document()
table = context.document.add_table(rows=3, cols=1)
for row in table.rows[:2]:
row.repeat_as_header = True


@when("I save and reopen the repeating-header document")
def when_headers_roundtrip(context):
stream = BytesIO()
context.document.save(stream)
context.document = Document(stream)


@then("both leading rows remain headers and the body remains unmarked")
def then_headers_preserved(context):
table = context.document.tables[0]
assert [row.repeat_as_header for row in table.rows] == [True, True, None]
table.rows[0].repeat_as_header = False
assert table.rows[0].repeat_as_header is False
table.rows[0].repeat_as_header = None
assert table.rows[0].repeat_as_header is None
5 changes: 5 additions & 0 deletions features/tbl-repeat-header.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Feature: Configure repeating table header rows
Scenario: Save and clear the direct setting
Given a table with two leading header rows
When I save and reopen the repeating-header document
Then both leading rows remain headers and the body remains unmarked
1 change: 1 addition & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
CT_VerticalJc,
)

register_element_cls("w:tblHeader", CT_OnOff)
register_element_cls("w:bidiVisual", CT_OnOff)
register_element_cls("w:gridAfter", CT_DecimalNumber)
register_element_cls("w:gridBefore", CT_DecimalNumber)
Expand Down
5 changes: 5 additions & 0 deletions src/docx/oxml/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ class CT_TrPr(BaseOxmlElement):
"""``<w:trPr>`` element, defining table row properties."""

get_or_add_trHeight: Callable[[], CT_Height]
get_or_add_tblHeader: Callable[[], CT_OnOff]
_remove_tblHeader: Callable[[], None]

_tag_seq = (
"w:cnfStyle",
Expand All @@ -917,6 +919,9 @@ class CT_TrPr(BaseOxmlElement):
gridBefore: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"w:gridBefore", successors=_tag_seq[3:]
)
tblHeader: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"w:tblHeader", successors=_tag_seq[9:]
)
trHeight: CT_Height | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"w:trHeight", successors=_tag_seq[8:]
)
Expand Down
23 changes: 23 additions & 0 deletions src/docx/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,29 @@ def height_rule(self) -> WD_ROW_HEIGHT_RULE | None:
def height_rule(self, value: WD_ROW_HEIGHT_RULE | None):
self._tr.trHeight_hRule = value

@property
def repeat_as_header(self) -> bool | None:
"""Whether this row is explicitly marked as a repeating table header.

True enables repetition, False explicitly disables it, and None means
the direct setting is absent. Word repeats only a contiguous group of
header rows at the beginning of a table, subject to page layout.
"""
properties = self._tr.trPr
if properties is None or properties.tblHeader is None:
return None
return properties.tblHeader.val

@repeat_as_header.setter
def repeat_as_header(self, value: bool | None):
if value is not None and value is not True and value is not False:
raise TypeError("repeat_as_header must be True, False, or None")
if value is None:
if self._tr.trPr is not None:
self._tr.trPr._remove_tblHeader() # pyright: ignore[reportPrivateUsage]
return
self._tr.get_or_add_trPr().get_or_add_tblHeader().val = value

@property
def table(self) -> Table:
"""Reference to the |Table| object this row belongs to."""
Expand Down
Binary file added tests/test_files/repeating-headers-word.docx
Binary file not shown.
6 changes: 6 additions & 0 deletions tests/test_files/repeating-headers-word.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Repeating headers fixture
=========================

Created in Microsoft Word with two contiguous leading header rows and two body
rows. Disabling the third row in Word removes its direct header setting.
Explicit false is covered by XML fixtures in the tests. Author metadata is blank.
90 changes: 90 additions & 0 deletions tests/test_repeating_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Repeating header settings and preservation of other row properties."""

from io import BytesIO
from pathlib import Path

import pytest

from docx import Document
from docx.enum.table import WD_ROW_HEIGHT_RULE
from docx.oxml.ns import qn
from docx.oxml.parser import parse_xml
from docx.shared import Inches
from docx.table import _Row


@pytest.mark.parametrize(
("setting", "expected"),
[
("", None),
("<w:tblHeader/>", True),
('<w:tblHeader w:val="1"/>', True),
('<w:tblHeader w:val="true"/>', True),
('<w:tblHeader w:val="0"/>', False),
('<w:tblHeader w:val="false"/>', False),
],
)
def it_reads_present_absent_and_explicit_false_states(setting, expected):
xml = '<w:tr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
row = _Row(parse_xml(f"{xml}<w:trPr>{setting}</w:trPr></w:tr>"), None)
assert row.repeat_as_header is expected


def it_roundtrips_multiple_leading_rows_without_changing_unrelated_content():
document = Document()
table = document.add_table(rows=4, cols=2)
table.cell(0, 0).merge(table.cell(0, 1)).text = "Merged heading"
table.rows[0].height = Inches(0.4)
table.rows[0].height_rule = WD_ROW_HEIGHT_RULE.AT_LEAST
cells_before = [cell._tc.xml for cell in table.rows[0].cells]
for row in table.rows[:2]:
row.repeat_as_header = True
row.repeat_as_header = True
table.rows[2].repeat_as_header = False
assert [cell._tc.xml for cell in table.rows[0].cells] == cells_before
assert len(table.rows[0]._tr.xpath("./w:trPr/w:tblHeader")) == 1
assert [child.tag for child in table.rows[0]._tr.trPr] == [qn("w:trHeight"), qn("w:tblHeader")]
stream = BytesIO()
document.save(stream)
reopened = Document(stream).tables[0]
assert [row.repeat_as_header for row in reopened.rows] == [True, True, False, None]
assert reopened.rows[0].height == Inches(0.4)
assert reopened.rows[0].height_rule == WD_ROW_HEIGHT_RULE.AT_LEAST
assert reopened.cell(0, 0).text == "Merged heading"
reopened.rows[0].repeat_as_header = None
assert reopened.rows[0].repeat_as_header is None
assert reopened.rows[0].height == Inches(0.4)
assert not reopened._tbl.xpath(".//w:cantSplit")


def it_leaves_new_rows_unmarked_and_clearing_an_absent_setting_does_not_add_properties():
document = Document()
row = document.add_table(rows=1, cols=1).rows[0]
before = row._tr.xml
assert row.repeat_as_header is None
row.repeat_as_header = None
assert row._tr.xml == before


@pytest.mark.parametrize("value", ["true", 1, 0, []])
def it_rejects_non_boolean_settings_without_mutation(value):
row = Document().add_table(rows=1, cols=1).rows[0]
before = row._tr.xml
with pytest.raises(TypeError):
row.repeat_as_header = value
assert row._tr.xml == before


def it_preserves_a_non_leading_header_flag_without_promising_repetition():
document = Document()
table = document.add_table(rows=3, cols=1)
table.rows[2].repeat_as_header = True
stream = BytesIO()
document.save(stream)
assert [row.repeat_as_header for row in Document(stream).tables[0].rows] == [None, None, True]


def it_reads_word_authored_header_rows():
document = Document(Path(__file__).parent / "test_files" / "repeating-headers-word.docx")
assert [row.repeat_as_header for row in document.tables[0].rows[:2]] == [True, True]
assert document.tables[0].rows[3].repeat_as_header is None