From 0223199b78850feba19a4e6d68b35ddddb75a31d Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Tue, 12 May 2026 10:54:38 -0400 Subject: [PATCH] feat(oxml): CT_HeaderFooter + CT_HandoutMaster + CT_TextField attrs (headers-footers Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) — `` 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) — `` 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, `` 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. --- src/pptx/oxml/__init__.py | 4 ++ src/pptx/oxml/slide.py | 46 +++++++++++++++ src/pptx/oxml/text.py | 13 ++++- tests/oxml/test_slide.py | 115 +++++++++++++++++++++++++++++++++++++- tests/oxml/test_text.py | 53 ++++++++++++++++++ 5 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 tests/oxml/test_text.py 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"