diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py
index b91fa72fa..9d44a0b95 100644
--- a/src/pptx/oxml/__init__.py
+++ b/src/pptx/oxml/__init__.py
@@ -463,6 +463,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
CT_Background,
CT_BackgroundProperties,
CT_CommonSlideData,
+ CT_HandoutMaster,
+ CT_HeaderFooter,
CT_NotesMaster,
CT_NotesSlide,
CT_Slide,
@@ -479,6 +481,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
register_element_cls("p:bgPr", CT_BackgroundProperties)
register_element_cls("p:childTnLst", CT_TimeNodeList)
register_element_cls("p:cSld", CT_CommonSlideData)
+register_element_cls("p:handoutMaster", CT_HandoutMaster)
+register_element_cls("p:hf", CT_HeaderFooter)
register_element_cls("p:notes", CT_NotesSlide)
register_element_cls("p:notesMaster", CT_NotesMaster)
register_element_cls("p:sld", CT_Slide)
diff --git a/src/pptx/oxml/slide.py b/src/pptx/oxml/slide.py
index 87eec7754..c1054e32b 100644
--- a/src/pptx/oxml/slide.py
+++ b/src/pptx/oxml/slide.py
@@ -126,11 +126,51 @@ def _change_to_noFill_bg(self) -> CT_Background:
return bg
+class CT_HandoutMaster(_BaseSlideElement):
+ """`p:handoutMaster` element, root of a handout master part.
+
+ Content model per ECMA-376 §19.3.1.24: `(cSld, clrMap, hf?, extLst?)`.
+ """
+
+ _tag_seq = ("p:cSld", "p:clrMap", "p:hf", "p:extLst")
+ cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
+ hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
+ "p:hf", successors=_tag_seq[3:]
+ )
+ del _tag_seq
+
+
+class CT_HeaderFooter(BaseOxmlElement):
+ """`p:hf` element, configuring per-template visibility of slide-number, header, footer,
+ and date placeholders.
+
+ Appears as a child of `p:sldMaster`, `p:sldLayout`, `p:notesMaster`, and
+ `p:handoutMaster`. Each of the four boolean attributes defaults to True
+ when omitted (ECMA-376 §19.3.1.18).
+ """
+
+ sldNum: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
+ "sldNum", XsdBoolean, default=True
+ )
+ hdr: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
+ "hdr", XsdBoolean, default=True
+ )
+ ftr: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
+ "ftr", XsdBoolean, default=True
+ )
+ dt: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
+ "dt", XsdBoolean, default=True
+ )
+
+
class CT_NotesMaster(_BaseSlideElement):
"""`p:notesMaster` element, root of a notes master part."""
_tag_seq = ("p:cSld", "p:clrMap", "p:hf", "p:notesStyle", "p:extLst")
cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
+ hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
+ "p:hf", successors=_tag_seq[3:]
+ )
del _tag_seq
@classmethod
@@ -259,6 +299,9 @@ class CT_SlideLayout(_BaseSlideElement):
_tag_seq = ("p:cSld", "p:clrMapOvr", "p:transition", "p:timing", "p:hf", "p:extLst")
cSld: CT_CommonSlideData = OneAndOnlyOne("p:cSld") # pyright: ignore[reportAssignmentType]
+ hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
+ "p:hf", successors=_tag_seq[5:]
+ )
del _tag_seq
@@ -301,6 +344,9 @@ class CT_SlideMaster(_BaseSlideElement):
sldLayoutIdLst: CT_SlideLayoutIdList = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"p:sldLayoutIdLst", successors=_tag_seq[3:]
)
+ hf: CT_HeaderFooter | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
+ "p:hf", successors=_tag_seq[6:]
+ )
del _tag_seq
diff --git a/src/pptx/oxml/text.py b/src/pptx/oxml/text.py
index adc6ca9fb..51cadb331 100644
--- a/src/pptx/oxml/text.py
+++ b/src/pptx/oxml/text.py
@@ -26,6 +26,7 @@
ST_TextTypeface,
ST_TextWrappingType,
XsdBoolean,
+ XsdString,
)
from pptx.oxml.xmlchemy import (
BaseOxmlElement,
@@ -329,7 +330,13 @@ def add_hlinkClick(self, rId: str) -> CT_Hyperlink:
class CT_TextField(BaseOxmlElement):
- """`a:fld` field element, for either a slide number or date field."""
+ """`a:fld` field element, for either a slide number or date field.
+
+ `id` is a required GUID identifier per ECMA-376 §A.4.1. `type` names the
+ field semantics — `slidenum`, `datetime1`..`datetime13`, `title`, etc. —
+ and is optional in the OOXML schema, though PowerPoint emits it on
+ every field it authors.
+ """
get_or_add_rPr: Callable[[], CT_TextCharacterProperties]
@@ -339,6 +346,10 @@ class CT_TextField(BaseOxmlElement):
t: BaseOxmlElement | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
"a:t", successors=()
)
+ id: str = RequiredAttribute("id", XsdString) # pyright: ignore[reportAssignmentType]
+ type: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
+ "type", XsdString
+ )
@property
def text(self) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
diff --git a/tests/oxml/test_slide.py b/tests/oxml/test_slide.py
index 63b321da7..788d85ebb 100644
--- a/tests/oxml/test_slide.py
+++ b/tests/oxml/test_slide.py
@@ -2,8 +2,20 @@
from __future__ import annotations
-from pptx.oxml.slide import CT_NotesMaster, CT_NotesSlide
+from typing import cast
+import pytest
+
+from pptx.oxml.slide import (
+ CT_HandoutMaster,
+ CT_HeaderFooter,
+ CT_NotesMaster,
+ CT_NotesSlide,
+ CT_SlideLayout,
+ CT_SlideMaster,
+)
+
+from ..unitutil.cxml import element
from ..unitutil.file import snippet_text
@@ -21,3 +33,104 @@ class DescribeCT_NotesSlide(object):
def it_can_create_a_new_notes_element(self):
notes = CT_NotesSlide.new()
assert notes.xml == snippet_text("default-notes")
+
+
+class DescribeCT_HeaderFooter(object):
+ """Unit-test suite for `pptx.oxml.slide.CT_HeaderFooter` objects."""
+
+ @pytest.mark.parametrize(
+ ("hf_cxml", "expected"),
+ [
+ ("p:hf", (True, True, True, True)),
+ ("p:hf{sldNum=0,hdr=0,ftr=0,dt=0}", (False, False, False, False)),
+ ("p:hf{sldNum=1,hdr=1,ftr=1,dt=1}", (True, True, True, True)),
+ ("p:hf{sldNum=0}", (False, True, True, True)),
+ ("p:hf{hdr=0}", (True, False, True, True)),
+ ("p:hf{ftr=0}", (True, True, False, True)),
+ ("p:hf{dt=0}", (True, True, True, False)),
+ ],
+ )
+ def it_provides_boolean_access_to_its_four_visibility_attrs(
+ self, hf_cxml: str, expected: tuple[bool, bool, bool, bool]
+ ):
+ hf = cast(CT_HeaderFooter, element(hf_cxml))
+ assert (hf.sldNum, hf.hdr, hf.ftr, hf.dt) == expected
+
+ @pytest.mark.parametrize("attr_name", ["sldNum", "hdr", "ftr", "dt"])
+ def it_can_toggle_each_attribute_via_setter(self, attr_name: str):
+ hf = cast(CT_HeaderFooter, element("p:hf"))
+ # ---default value is True when attr is absent---
+ assert getattr(hf, attr_name) is True
+ # ---set False, read back False---
+ setattr(hf, attr_name, False)
+ assert getattr(hf, attr_name) is False
+ # ---set back to True---
+ setattr(hf, attr_name, True)
+ assert getattr(hf, attr_name) is True
+
+
+class DescribeCT_HandoutMaster(object):
+ """Unit-test suite for `pptx.oxml.slide.CT_HandoutMaster` objects."""
+
+ def it_provides_access_to_its_hf_child(self):
+ handoutMaster = cast(
+ CT_HandoutMaster,
+ element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap,p:hf{ftr=0})"),
+ )
+ assert handoutMaster.hf is not None
+ assert handoutMaster.hf.ftr is False
+
+ def it_returns_None_for_hf_when_absent(self):
+ handoutMaster = cast(
+ CT_HandoutMaster, element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap)")
+ )
+ assert handoutMaster.hf is None
+
+ def it_can_add_an_hf_child_via_get_or_add(self):
+ handoutMaster = cast(
+ CT_HandoutMaster, element("p:handoutMaster/(p:cSld/p:spTree,p:clrMap)")
+ )
+ hf = handoutMaster.get_or_add_hf()
+ assert hf is handoutMaster.hf
+ # ---defaults all True on a freshly-added ---
+ assert (hf.sldNum, hf.hdr, hf.ftr, hf.dt) == (True, True, True, True)
+
+
+class DescribeHFAccessOnTemplates(object):
+ """`hf` ZeroOrOne accessor on SlideMaster / SlideLayout / NotesMaster / HandoutMaster."""
+
+ def it_reads_hf_on_a_sldMaster(self):
+ sldMaster = cast(
+ CT_SlideMaster,
+ element("p:sldMaster/(p:cSld/p:spTree,p:hf{sldNum=0,ftr=0})"),
+ )
+ assert sldMaster.hf is not None
+ assert sldMaster.hf.sldNum is False
+ assert sldMaster.hf.ftr is False
+ assert sldMaster.hf.dt is True
+
+ def it_returns_None_for_hf_on_a_sldMaster_when_absent(self):
+ sldMaster = cast(CT_SlideMaster, element("p:sldMaster/p:cSld/p:spTree"))
+ assert sldMaster.hf is None
+
+ def it_reads_hf_on_a_sldLayout(self):
+ sldLayout = cast(CT_SlideLayout, element("p:sldLayout/(p:cSld/p:spTree,p:hf{dt=0})"))
+ assert sldLayout.hf is not None
+ assert sldLayout.hf.dt is False
+
+ def it_returns_None_for_hf_on_a_sldLayout_when_absent(self):
+ sldLayout = cast(CT_SlideLayout, element("p:sldLayout/p:cSld/p:spTree"))
+ assert sldLayout.hf is None
+
+ def it_reads_hf_on_a_notesMaster(self):
+ notesMaster = cast(
+ CT_NotesMaster,
+ element("p:notesMaster/(p:cSld/p:spTree,p:hf{hdr=0,ftr=0})"),
+ )
+ assert notesMaster.hf is not None
+ assert notesMaster.hf.hdr is False
+ assert notesMaster.hf.ftr is False
+
+ def it_returns_None_for_hf_on_a_notesMaster_when_absent(self):
+ notesMaster = cast(CT_NotesMaster, element("p:notesMaster/p:cSld/p:spTree"))
+ assert notesMaster.hf is None
diff --git a/tests/oxml/test_text.py b/tests/oxml/test_text.py
new file mode 100644
index 000000000..7937a9e7d
--- /dev/null
+++ b/tests/oxml/test_text.py
@@ -0,0 +1,53 @@
+"""Unit-test suite for `pptx.oxml.text` module."""
+
+from __future__ import annotations
+
+from typing import cast
+
+import pytest
+
+from pptx.exc import InvalidXmlError
+from pptx.oxml.text import CT_TextField
+
+from ..unitutil.cxml import element
+
+
+class DescribeCT_TextField(object):
+ """Unit-test suite for `pptx.oxml.text.CT_TextField` (the `a:fld` element)."""
+
+ def it_provides_read_access_to_its_id_attribute(self):
+ # ---cxml's grammar reserves `{` and `}` as attribute delimiters, so the
+ # ---literal `{GUID}` form cannot appear inline. The id attribute on
+ # --- is XsdString-typed so any token works for round-trip tests;
+ # ---real fields generate {uuid4()} values at author time (Phase 3).
+ fld = cast(CT_TextField, element("a:fld{id=fld-1,type=slidenum}"))
+ assert fld.id == "fld-1"
+
+ def it_raises_InvalidXmlError_when_id_is_missing(self):
+ fld = cast(CT_TextField, element("a:fld"))
+ with pytest.raises(InvalidXmlError):
+ _ = fld.id
+
+ @pytest.mark.parametrize(
+ ("fld_cxml", "expected_type"),
+ [
+ ("a:fld{id=foo,type=slidenum}", "slidenum"),
+ ("a:fld{id=foo,type=datetime1}", "datetime1"),
+ ("a:fld{id=foo,type=datetime13}", "datetime13"),
+ ("a:fld{id=foo,type=title}", "title"),
+ ("a:fld{id=foo}", None),
+ ],
+ )
+ def it_provides_read_access_to_its_type_attribute(
+ self, fld_cxml: str, expected_type: str | None
+ ):
+ fld = cast(CT_TextField, element(fld_cxml))
+ assert fld.type == expected_type
+
+ def it_returns_empty_string_for_text_when_a_t_is_absent(self):
+ fld = cast(CT_TextField, element("a:fld{id=foo}"))
+ assert fld.text == ""
+
+ def it_reads_the_text_of_its_a_t_child(self):
+ fld = cast(CT_TextField, element('a:fld{id=foo,type=slidenum}/a:t"42"'))
+ assert fld.text == "42"