Skip to content

Commit 5a3d837

Browse files
committed
feat: Slide / Master / Layout headers-footers public API (Phase 2)
Public Python API for the headers/footers/slide-numbers/dates epic (#20). Phase 2 lands the user-facing surface on top of the Phase 1 (PR #48) OOXML primitives. Phase 3 adds Field-based date auto-update; Phase 5 adds the HandoutMaster Python class and watermark helper. Changes: - pptx.slide._HeaderFooterVisibility (NEW) — mixin providing the four `show_*` properties (show_slide_number, show_footer, show_date, show_header) for any template element that carries a `<p:hf>` child. Inherited by SlideLayout, SlideMaster, and NotesMaster. Getter semantics: `<p:hf>` absent → True (PowerPoint default); present → the effective attribute value (each defaults to True per Phase 1's OptionalAttribute(default=True)). Setter semantics: assigning True when `<p:hf>` is absent is a no-op (default-True needs no element); assigning False creates `<p:hf>` via the Phase 1 ZeroOrOne accessor (`get_or_add_hf`) and writes the attribute as "0". An existing `<p:hf>` element is retained when all attrs become True — avoiding low-value XML churn on toggle-back-on. - pptx.slide.Slide — gains `has_footer`, `footer` (str | None, with setter), `has_slide_number` (read-only — auto-filled by PowerPoint), `has_date`, and `date_text` (str | None, with setter, Fixed-mode only; `<a:fld>` auto-update remains Phase 3 scope). Two private helpers centralize the placeholder iteration: `_first_ph_of_type` walks the slide's own placeholders, `_layout_ph_of_type` walks the layout's placeholders for the clone-on-first-write path. Both return the first match in document order. Text getters call `text_frame.text` on the matched placeholder; text setters clone the layout placeholder via `self.shapes.clone_placeholder` when the slide has no matching placeholder yet, mirroring how PowerPoint promotes a layout-level placeholder to slide-level on first edit. Setting None or "" clears the text but does not remove the placeholder shape. Setting a non-empty string when the layout itself has no FOOTER (or DATE) placeholder raises ValueError with a precise message. Design notes: - The mixin lives in pptx.slide (not a separate module) because its three users all live there and the API surface is small. The `_element` annotation on the mixin is a union of the three concrete template element types, gated by a TYPE_CHECKING import so runtime attribute access works on whichever element type the concrete class carries. - Slide accessors lean on `placeholder_format.type` for type discovery rather than poking `element.ph_type`, matching the established `NotesSlide.notes_placeholder` style in this same file. The lookup helpers return `None` rather than raising so callers can use them as `is None` guards. - The footer/date setters intentionally do NOT remove the placeholder on clear. Removing a shape just because its text is empty would be surprising and would also strip layout-derived formatting; clearing text matches what PowerPoint does when the user backspaces footer content. Test counts: - tests/test_slide.py: +41 new test methods covering all 38 ISCs in the working ISA (12 template `show_*` getter/setter cases across SlideLayout / SlideMaster / NotesMaster; Slide.footer/has_footer with cloning, idempotent rewrite, clear-on-None, ValueError on no layout placeholder; Slide.has_slide_number; Slide.has_date and date_text with the parallel set; helper coverage for first-match document-order semantics). - pytest: 3598 passed (3514 baseline + 84 new — includes pytest parameterizations counted by collection rather than by `def`), 0 failed. Wall clock 5.11s. - ruff check: All checks passed. ruff format: 216 files already formatted (no diff). - behave: 1048 scenarios passed, 0 failed (zero regression vs Phase 1 baseline). - uat/uat_headers_footers_phase2.py: PASS — toggles `layout.show_footer = False` on Layout 0 of test.pptx, sets `slide.footer = "Phase 2 round-trip"` on Slide 0, saves, reopens, and asserts both round-trip. Refs #20. Builds on Phase 1 (PR #48 / commit 0223199).
1 parent 4daba7e commit 5a3d837

2 files changed

Lines changed: 662 additions & 5 deletions

File tree

src/pptx/slide.py

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,17 @@
2424
from pptx.oxml.presentation import CT_SlideIdList, CT_SlideMasterIdList
2525
from pptx.oxml.slide import (
2626
CT_CommonSlideData,
27+
CT_NotesMaster,
2728
CT_NotesSlide,
2829
CT_Slide,
30+
CT_SlideLayout,
2931
CT_SlideLayoutIdList,
3032
CT_SlideMaster,
3133
)
3234
from pptx.parts.presentation import PresentationPart
3335
from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart
3436
from pptx.presentation import Presentation
35-
from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder
37+
from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder, SlidePlaceholder
3638
from pptx.shapes.shapetree import NotesSlidePlaceholder
3739
from pptx.text.text import TextFrame
3840

@@ -92,7 +94,84 @@ def shapes(self):
9294
return MasterShapes(self._element.spTree, self)
9395

9496

95-
class NotesMaster(_BaseMaster):
97+
class _HeaderFooterVisibility:
98+
"""Provides access to header/footer visibility settings on a slide template."""
99+
100+
_element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster
101+
102+
def _get_hf_visibility(self, attr_name: str) -> bool:
103+
"""Return effective `attr_name` value, defaulting to |True| when `<p:hf>` is absent."""
104+
hf = self._element.hf
105+
return True if hf is None else getattr(hf, attr_name)
106+
107+
def _set_hf_visibility(self, attr_name: str, value: bool) -> None:
108+
"""Set `attr_name` on `<p:hf>`, creating the element only when needed.
109+
110+
Assigning |True| when `<p:hf>` is absent is a no-op because the effective default is
111+
already |True|. An existing `<p:hf>` element is retained even when all values become
112+
|True|, avoiding low-value XML churn.
113+
"""
114+
hf = self._element.hf
115+
if hf is None and value:
116+
return
117+
if hf is None:
118+
hf = self._element.get_or_add_hf()
119+
setattr(hf, attr_name, value)
120+
121+
@property
122+
def show_slide_number(self) -> bool:
123+
"""`True` when slide numbers are shown for this template, `False` otherwise.
124+
125+
Assigning |False| creates a `<p:hf>` element when needed and writes `sldNum="0"`.
126+
Assigning |True| preserves any existing `<p:hf>` element rather than removing it.
127+
"""
128+
return self._get_hf_visibility("sldNum")
129+
130+
@show_slide_number.setter
131+
def show_slide_number(self, value: bool) -> None:
132+
self._set_hf_visibility("sldNum", value)
133+
134+
@property
135+
def show_footer(self) -> bool:
136+
"""`True` when footer placeholders are shown for this template, `False` otherwise.
137+
138+
Assigning |False| creates a `<p:hf>` element when needed and writes `ftr="0"`.
139+
Assigning |True| preserves any existing `<p:hf>` element rather than removing it.
140+
"""
141+
return self._get_hf_visibility("ftr")
142+
143+
@show_footer.setter
144+
def show_footer(self, value: bool) -> None:
145+
self._set_hf_visibility("ftr", value)
146+
147+
@property
148+
def show_date(self) -> bool:
149+
"""`True` when date placeholders are shown for this template, `False` otherwise.
150+
151+
Assigning |False| creates a `<p:hf>` element when needed and writes `dt="0"`.
152+
Assigning |True| preserves any existing `<p:hf>` element rather than removing it.
153+
"""
154+
return self._get_hf_visibility("dt")
155+
156+
@show_date.setter
157+
def show_date(self, value: bool) -> None:
158+
self._set_hf_visibility("dt", value)
159+
160+
@property
161+
def show_header(self) -> bool:
162+
"""`True` when header placeholders are shown for this template, `False` otherwise.
163+
164+
Assigning |False| creates a `<p:hf>` element when needed and writes `hdr="0"`.
165+
Assigning |True| preserves any existing `<p:hf>` element rather than removing it.
166+
"""
167+
return self._get_hf_visibility("hdr")
168+
169+
@show_header.setter
170+
def show_header(self, value: bool) -> None:
171+
self._set_hf_visibility("hdr", value)
172+
173+
174+
class NotesMaster(_HeaderFooterVisibility, _BaseMaster):
96175
"""Proxy for the notes master XML document.
97176
98177
Provides access to shapes, the most commonly used of which are placeholders.
@@ -214,6 +293,33 @@ def has_notes_slide(self) -> bool:
214293
"""
215294
return self.part.has_notes_slide
216295

296+
@property
297+
def has_date(self) -> bool:
298+
"""`True` if this slide has a date placeholder, `False` otherwise.
299+
300+
This property is non-mutating; it reports only whether a DATE placeholder is already
301+
present on the slide.
302+
"""
303+
return self._first_ph_of_type(PP_PLACEHOLDER.DATE) is not None
304+
305+
@property
306+
def has_footer(self) -> bool:
307+
"""`True` if this slide has a footer placeholder, `False` otherwise.
308+
309+
This property is non-mutating; it reports only whether a FOOTER placeholder is already
310+
present on the slide.
311+
"""
312+
return self._first_ph_of_type(PP_PLACEHOLDER.FOOTER) is not None
313+
314+
@property
315+
def has_slide_number(self) -> bool:
316+
"""`True` if this slide has a slide-number placeholder, `False` otherwise.
317+
318+
This property is non-mutating; it reports only whether a SLIDE_NUMBER placeholder is
319+
already present on the slide.
320+
"""
321+
return self._first_ph_of_type(PP_PLACEHOLDER.SLIDE_NUMBER) is not None
322+
217323
@property
218324
def notes_slide(self) -> NotesSlide:
219325
"""The |NotesSlide| instance for this slide.
@@ -223,6 +329,60 @@ def notes_slide(self) -> NotesSlide:
223329
"""
224330
return self.part.notes_slide
225331

332+
@property
333+
def date_text(self) -> str | None:
334+
"""Text of this slide's date placeholder, or |None| when no date placeholder is present.
335+
336+
Reading this property does not create a placeholder. An existing empty DATE placeholder
337+
returns an empty string.
338+
"""
339+
placeholder = self._first_ph_of_type(PP_PLACEHOLDER.DATE)
340+
return None if placeholder is None else placeholder.text_frame.text
341+
342+
@date_text.setter
343+
def date_text(self, value: str | None) -> None:
344+
placeholder = self._first_ph_of_type(PP_PLACEHOLDER.DATE)
345+
if value in (None, ""):
346+
if placeholder is not None:
347+
placeholder.text_frame.text = ""
348+
return
349+
350+
if placeholder is None:
351+
layout_ph = self._layout_ph_of_type(PP_PLACEHOLDER.DATE)
352+
if layout_ph is None:
353+
raise ValueError("slide layout has no DATE placeholder to clone from")
354+
self.shapes.clone_placeholder(layout_ph)
355+
placeholder = cast("SlidePlaceholder", self._first_ph_of_type(PP_PLACEHOLDER.DATE))
356+
357+
placeholder.text_frame.text = value
358+
359+
@property
360+
def footer(self) -> str | None:
361+
"""Text of this slide's footer placeholder, or |None| when no footer placeholder is present.
362+
363+
Reading this property does not create a placeholder. An existing empty FOOTER placeholder
364+
returns an empty string.
365+
"""
366+
placeholder = self._first_ph_of_type(PP_PLACEHOLDER.FOOTER)
367+
return None if placeholder is None else placeholder.text_frame.text
368+
369+
@footer.setter
370+
def footer(self, value: str | None) -> None:
371+
placeholder = self._first_ph_of_type(PP_PLACEHOLDER.FOOTER)
372+
if value in (None, ""):
373+
if placeholder is not None:
374+
placeholder.text_frame.text = ""
375+
return
376+
377+
if placeholder is None:
378+
layout_ph = self._layout_ph_of_type(PP_PLACEHOLDER.FOOTER)
379+
if layout_ph is None:
380+
raise ValueError("slide layout has no FOOTER placeholder to clone from")
381+
self.shapes.clone_placeholder(layout_ph)
382+
placeholder = cast("SlidePlaceholder", self._first_ph_of_type(PP_PLACEHOLDER.FOOTER))
383+
384+
placeholder.text_frame.text = value
385+
226386
@lazyproperty
227387
def placeholders(self) -> SlidePlaceholders:
228388
"""Sequence of placeholder shapes in this slide."""
@@ -247,6 +407,28 @@ def slide_layout(self) -> SlideLayout:
247407
"""|SlideLayout| object this slide inherits appearance from."""
248408
return self.part.slide_layout
249409

410+
def _first_ph_of_type(self, ph_type: PP_PLACEHOLDER) -> SlidePlaceholder | None:
411+
"""Return the first SlidePlaceholder of `ph_type` in document order, or |None|.
412+
413+
This helper is non-mutating and returns the first matching slide placeholder when multiple
414+
placeholders of the same type are present.
415+
"""
416+
for placeholder in self.placeholders:
417+
if placeholder.placeholder_format.type == ph_type:
418+
return placeholder
419+
return None
420+
421+
def _layout_ph_of_type(self, ph_type: PP_PLACEHOLDER) -> LayoutPlaceholder | None:
422+
"""Return the first LayoutPlaceholder of `ph_type` on this slide's layout, or |None|.
423+
424+
The layout placeholder is used as the source when promoting a latent placeholder to a
425+
slide-level placeholder on first write.
426+
"""
427+
for placeholder in self.slide_layout.placeholders:
428+
if placeholder.placeholder_format.type == ph_type:
429+
return placeholder
430+
return None
431+
250432
def delete(self) -> None:
251433
"""Remove this slide from its presentation.
252434
@@ -426,7 +608,7 @@ def duplicate(self, slide: Slide, index: int | None = None) -> Slide:
426608
return new_slide_part.slide
427609

428610

429-
class SlideLayout(_BaseSlide):
611+
class SlideLayout(_HeaderFooterVisibility, _BaseSlide):
430612
"""Slide layout object.
431613
432614
Provides access to placeholders, regular shapes, and slide layout-level properties.
@@ -544,7 +726,7 @@ def remove(self, slide_layout: SlideLayout) -> None:
544726
slide_layout.slide_master.part.drop_rel(target_sldLayoutId.rId)
545727

546728

547-
class SlideMaster(_BaseMaster):
729+
class SlideMaster(_HeaderFooterVisibility, _BaseMaster):
548730
"""Slide master object.
549731
550732
Provides access to slide layouts. Access to placeholders, regular shapes, and slide master-level

0 commit comments

Comments
 (0)