Skip to content

Commit 0223199

Browse files
committed
feat(oxml): CT_HeaderFooter + CT_HandoutMaster + CT_TextField attrs (headers-footers Phase 1)
OOXML foundation for the headers/footers/slide-numbers/dates/watermarks epic (#20). Phase 1 ships element-level wrappers only — the public Slide/Master/Field API lands in Phase 2 and later. Changes: - pptx.oxml.slide.CT_HeaderFooter (NEW) — `<p:hf>` element wrapper, with the four ECMA-376 §19.3.1.18 boolean attributes (sldNum, hdr, ftr, dt), each OptionalAttribute defaulting to True when absent. - pptx.oxml.slide.CT_HandoutMaster (NEW) — `<p:handoutMaster>` element with content model (cSld, clrMap, hf?, extLst?) per §19.3.1.24. Registered against `p:handoutMaster` so deepcopy + parse + xmlchemy flow against the right class. - `hf` ZeroOrOne accessor added to CT_SlideMaster, CT_SlideLayout, and CT_NotesMaster, using each class's existing `_tag_seq` to compute the correct successor tuple (so insertion never violates schema order). - pptx.oxml.text.CT_TextField — surfaces the two attributes the field authoring API in later phases needs: `id` (RequiredAttribute, XsdString, per spec a GUID) and `type` (OptionalAttribute, XsdString — values like `slidenum`, `datetime1`..`datetime13`, `title`). Existing fld parse paths are unaffected — none of them read .id today. Out of scope for Phase 1 (deliberate): - No public Slide.footer / has_slide_number / has_date API (Phase 2) - No Field / Run.add_field authoring surface (Phase 3 — scanny#797 port) - No HandoutMaster Python class or HandoutMasterPart plumbing (Phase 5) - No watermark helper (Phase 5) Plan correction surfaced during investigation: per ECMA-376, `<p:hf>` is a child of slide LAYOUTS and the three master types (slide, notes, handout) — not of individual slides. The original plan listed CT_Slide; the correct slot list is the four templates. Per-slide footer text flows through the FOOTER-typed placeholder shape (Phase 2's Slide.footer). Verification (local, CPython 3.14.4): - python3 -m pytest tests/ -q → 3514 passed in 6.31s (+29 vs baseline) - tests/oxml/test_slide.py + tests/oxml/test_text.py: 31 new passing tests - python3 -m ruff check src tests → All checks passed - python3 -m ruff format --check src tests → 216 files already formatted - python3 -m behave features/ --no-color → 1048 scenarios, 0 failed - python3 uat/uat_headers_footers_phase1.py → PASS (hf attrs round-trip on a real .pptx) Refs #20.
1 parent 1578fcb commit 0223199

5 files changed

Lines changed: 229 additions & 2 deletions

File tree

src/pptx/oxml/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
463463
CT_Background,
464464
CT_BackgroundProperties,
465465
CT_CommonSlideData,
466+
CT_HandoutMaster,
467+
CT_HeaderFooter,
466468
CT_NotesMaster,
467469
CT_NotesSlide,
468470
CT_Slide,
@@ -479,6 +481,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
479481
register_element_cls("p:bgPr", CT_BackgroundProperties)
480482
register_element_cls("p:childTnLst", CT_TimeNodeList)
481483
register_element_cls("p:cSld", CT_CommonSlideData)
484+
register_element_cls("p:handoutMaster", CT_HandoutMaster)
485+
register_element_cls("p:hf", CT_HeaderFooter)
482486
register_element_cls("p:notes", CT_NotesSlide)
483487
register_element_cls("p:notesMaster", CT_NotesMaster)
484488
register_element_cls("p:sld", CT_Slide)

src/pptx/oxml/slide.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,51 @@ def _change_to_noFill_bg(self) -> CT_Background:
126126
return bg
127127

128128

129+
class CT_HandoutMaster(_BaseSlideElement):
130+
"""`p:handoutMaster` element, root of a handout master part.
131+
132+
Content model per ECMA-376 §19.3.1.24: `(cSld, clrMap, hf?, extLst?)`.
133+
"""
134+
135+
_tag_seq = ("p:cSld", "p:clrMap", "p:hf", "p:extLst")
136+
cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
137+
hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
138+
"p:hf", successors=_tag_seq[3:]
139+
)
140+
del _tag_seq
141+
142+
143+
class CT_HeaderFooter(BaseOxmlElement):
144+
"""`p:hf` element, configuring per-template visibility of slide-number, header, footer,
145+
and date placeholders.
146+
147+
Appears as a child of `p:sldMaster`, `p:sldLayout`, `p:notesMaster`, and
148+
`p:handoutMaster`. Each of the four boolean attributes defaults to True
149+
when omitted (ECMA-376 §19.3.1.18).
150+
"""
151+
152+
sldNum: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
153+
"sldNum", XsdBoolean, default=True
154+
)
155+
hdr: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
156+
"hdr", XsdBoolean, default=True
157+
)
158+
ftr: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
159+
"ftr", XsdBoolean, default=True
160+
)
161+
dt: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
162+
"dt", XsdBoolean, default=True
163+
)
164+
165+
129166
class CT_NotesMaster(_BaseSlideElement):
130167
"""`p:notesMaster` element, root of a notes master part."""
131168

132169
_tag_seq = ("p:cSld", "p:clrMap", "p:hf", "p:notesStyle", "p:extLst")
133170
cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
171+
hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
172+
"p:hf", successors=_tag_seq[3:]
173+
)
134174
del _tag_seq
135175

136176
@classmethod
@@ -259,6 +299,9 @@ class CT_SlideLayout(_BaseSlideElement):
259299

260300
_tag_seq = ("p:cSld", "p:clrMapOvr", "p:transition", "p:timing", "p:hf", "p:extLst")
261301
cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
302+
hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
303+
"p:hf", successors=_tag_seq[5:]
304+
)
262305
del _tag_seq
263306

264307

@@ -301,6 +344,9 @@ class CT_SlideMaster(_BaseSlideElement):
301344
sldLayoutIdLst: CT_SlideLayoutIdList = ZeroOrOne( # pyright: ignore[reportAssignmentType]
302345
"p:sldLayoutIdLst", successors=_tag_seq[3:]
303346
)
347+
hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
348+
"p:hf", successors=_tag_seq[6:]
349+
)
304350
del _tag_seq
305351

306352

src/pptx/oxml/text.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
ST_TextTypeface,
2727
ST_TextWrappingType,
2828
XsdBoolean,
29+
XsdString,
2930
)
3031
from pptx.oxml.xmlchemy import (
3132
BaseOxmlElement,
@@ -329,7 +330,13 @@ def add_hlinkClick(self, rId: str) -> CT_Hyperlink:
329330

330331

331332
class CT_TextField(BaseOxmlElement):
332-
"""`a:fld` field element, for either a slide number or date field."""
333+
"""`a:fld` field element, for either a slide number or date field.
334+
335+
`id` is a required GUID identifier per ECMA-376 §A.4.1. `type` names the
336+
field semantics — `slidenum`, `datetime1`..`datetime13`, `title`, etc. —
337+
and is optional in the OOXML schema, though PowerPoint emits it on
338+
every field it authors.
339+
"""
333340

334341
get_or_add_rPr: Callable[[], CT_TextCharacterProperties]
335342

@@ -339,6 +346,10 @@ class CT_TextField(BaseOxmlElement):
339346
t: BaseOxmlElement | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
340347
"a:t", successors=()
341348
)
349+
id: str = RequiredAttribute("id", XsdString) # pyright: ignore[reportAssignmentType]
350+
type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
351+
"type", XsdString
352+
)
342353

343354
@property
344355
def text(self) -> str: # pyright: ignore[reportIncompatibleMethodOverride]

tests/oxml/test_slide.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,20 @@
22

33
from __future__ import annotations
44

5-
from pptx.oxml.slide import CT_NotesMaster, CT_NotesSlide
5+
from typing import cast
66

7+
import pytest
8+
9+
from pptx.oxml.slide import (
10+
CT_HandoutMaster,
11+
CT_HeaderFooter,
12+
CT_NotesMaster,
13+
CT_NotesSlide,
14+
CT_SlideLayout,
15+
CT_SlideMaster,
16+
)
17+
18+
from ..unitutil.cxml import element
719
from ..unitutil.file import snippet_text
820

921

@@ -21,3 +33,104 @@ class DescribeCT_NotesSlide(object):
2133
def it_can_create_a_new_notes_element(self):
2234
notes = CT_NotesSlide.new()
2335
assert notes.xml == snippet_text("default-notes")
36+
37+
38+
class DescribeCT_HeaderFooter(object):
39+
"""Unit-test suite for `pptx.oxml.slide.CT_HeaderFooter` objects."""
40+
41+
@pytest.mark.parametrize(
42+
("hf_cxml", "expected"),
43+
[
44+
("p:hf", (True, True, True, True)),
45+
("p:hf{sldNum=0,hdr=0,ftr=0,dt=0}", (False, False, False, False)),
46+
("p:hf{sldNum=1,hdr=1,ftr=1,dt=1}", (True, True, True, True)),
47+
("p:hf{sldNum=0}", (False, True, True, True)),
48+
("p:hf{hdr=0}", (True, False, True, True)),
49+
("p:hf{ftr=0}", (True, True, False, True)),
50+
("p:hf{dt=0}", (True, True, True, False)),
51+
],
52+
)
53+
def it_provides_boolean_access_to_its_four_visibility_attrs(
54+
self, hf_cxml: str, expected: tuple[bool, bool, bool, bool]
55+
):
56+
hf = cast(CT_HeaderFooter, element(hf_cxml))
57+
assert (hf.sldNum, hf.hdr, hf.ftr, hf.dt) == expected
58+
59+
@pytest.mark.parametrize("attr_name", ["sldNum", "hdr", "ftr", "dt"])
60+
def it_can_toggle_each_attribute_via_setter(self, attr_name: str):
61+
hf = cast(CT_HeaderFooter, element("p:hf"))
62+
# ---default value is True when attr is absent---
63+
assert getattr(hf, attr_name) is True
64+
# ---set False, read back False---
65+
setattr(hf, attr_name, False)
66+
assert getattr(hf, attr_name) is False
67+
# ---set back to True---
68+
setattr(hf, attr_name, True)
69+
assert getattr(hf, attr_name) is True
70+
71+
72+
class DescribeCT_HandoutMaster(object):
73+
"""Unit-test suite for `pptx.oxml.slide.CT_HandoutMaster` objects."""
74+
75+
def it_provides_access_to_its_hf_child(self):
76+
handoutMaster = cast(
77+
CT_HandoutMaster,
78+
element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap,p:hf{ftr=0})"),
79+
)
80+
assert handoutMaster.hf is not None
81+
assert handoutMaster.hf.ftr is False
82+
83+
def it_returns_None_for_hf_when_absent(self):
84+
handoutMaster = cast(
85+
CT_HandoutMaster, element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap)")
86+
)
87+
assert handoutMaster.hf is None
88+
89+
def it_can_add_an_hf_child_via_get_or_add(self):
90+
handoutMaster = cast(
91+
CT_HandoutMaster, element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap)")
92+
)
93+
hf = handoutMaster.get_or_add_hf()
94+
assert hf is handoutMaster.hf
95+
# ---defaults all True on a freshly-added <p:hf/>---
96+
assert (hf.sldNum, hf.hdr, hf.ftr, hf.dt) == (True, True, True, True)
97+
98+
99+
class DescribeHFAccessOnTemplates(object):
100+
"""`hf` ZeroOrOne accessor on SlideMaster / SlideLayout / NotesMaster / HandoutMaster."""
101+
102+
def it_reads_hf_on_a_sldMaster(self):
103+
sldMaster = cast(
104+
CT_SlideMaster,
105+
element("p:sldMaster/(p:cSld/p:spTree,p:hf{sldNum=0,ftr=0})"),
106+
)
107+
assert sldMaster.hf is not None
108+
assert sldMaster.hf.sldNum is False
109+
assert sldMaster.hf.ftr is False
110+
assert sldMaster.hf.dt is True
111+
112+
def it_returns_None_for_hf_on_a_sldMaster_when_absent(self):
113+
sldMaster = cast(CT_SlideMaster, element("p:sldMaster/p:cSld/p:spTree"))
114+
assert sldMaster.hf is None
115+
116+
def it_reads_hf_on_a_sldLayout(self):
117+
sldLayout = cast(CT_SlideLayout, element("p:sldLayout/(p:cSld/p:spTree,p:hf{dt=0})"))
118+
assert sldLayout.hf is not None
119+
assert sldLayout.hf.dt is False
120+
121+
def it_returns_None_for_hf_on_a_sldLayout_when_absent(self):
122+
sldLayout = cast(CT_SlideLayout, element("p:sldLayout/p:cSld/p:spTree"))
123+
assert sldLayout.hf is None
124+
125+
def it_reads_hf_on_a_notesMaster(self):
126+
notesMaster = cast(
127+
CT_NotesMaster,
128+
element("p:notesMaster/(p:cSld/p:spTree,p:hf{hdr=0,ftr=0})"),
129+
)
130+
assert notesMaster.hf is not None
131+
assert notesMaster.hf.hdr is False
132+
assert notesMaster.hf.ftr is False
133+
134+
def it_returns_None_for_hf_on_a_notesMaster_when_absent(self):
135+
notesMaster = cast(CT_NotesMaster, element("p:notesMaster/p:cSld/p:spTree"))
136+
assert notesMaster.hf is None

tests/oxml/test_text.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Unit-test suite for `pptx.oxml.text` module."""
2+
3+
from __future__ import annotations
4+
5+
from typing import cast
6+
7+
import pytest
8+
9+
from pptx.exc import InvalidXmlError
10+
from pptx.oxml.text import CT_TextField
11+
12+
from ..unitutil.cxml import element
13+
14+
15+
class DescribeCT_TextField(object):
16+
"""Unit-test suite for `pptx.oxml.text.CT_TextField` (the `a:fld` element)."""
17+
18+
def it_provides_read_access_to_its_id_attribute(self):
19+
# ---cxml's grammar reserves `{` and `}` as attribute delimiters, so the
20+
# ---literal `{GUID}` form cannot appear inline. The id attribute on
21+
# ---<a:fld> is XsdString-typed so any token works for round-trip tests;
22+
# ---real fields generate {uuid4()} values at author time (Phase 3).
23+
fld = cast(CT_TextField, element("a:fld{id=fld-1,type=slidenum}"))
24+
assert fld.id == "fld-1"
25+
26+
def it_raises_InvalidXmlError_when_id_is_missing(self):
27+
fld = cast(CT_TextField, element("a:fld"))
28+
with pytest.raises(InvalidXmlError):
29+
_ = fld.id
30+
31+
@pytest.mark.parametrize(
32+
("fld_cxml", "expected_type"),
33+
[
34+
("a:fld{id=foo,type=slidenum}", "slidenum"),
35+
("a:fld{id=foo,type=datetime1}", "datetime1"),
36+
("a:fld{id=foo,type=datetime13}", "datetime13"),
37+
("a:fld{id=foo,type=title}", "title"),
38+
("a:fld{id=foo}", None),
39+
],
40+
)
41+
def it_provides_read_access_to_its_type_attribute(
42+
self, fld_cxml: str, expected_type: str | None
43+
):
44+
fld = cast(CT_TextField, element(fld_cxml))
45+
assert fld.type == expected_type
46+
47+
def it_returns_empty_string_for_text_when_a_t_is_absent(self):
48+
fld = cast(CT_TextField, element("a:fld{id=foo}"))
49+
assert fld.text == ""
50+
51+
def it_reads_the_text_of_its_a_t_child(self):
52+
fld = cast(CT_TextField, element('a:fld{id=foo,type=slidenum}/a:t"42"'))
53+
assert fld.text == "42"

0 commit comments

Comments
 (0)