From 5a3d837328decaa63d548d8bfd90d372ea17fdcc Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Wed, 13 May 2026 18:43:54 -0400 Subject: [PATCH] feat: Slide / Master / Layout headers-footers public API (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` child. Inherited by SlideLayout, SlideMaster, and NotesMaster. Getter semantics: `` 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 `` is absent is a no-op (default-True needs no element); assigning False creates `` via the Phase 1 ZeroOrOne accessor (`get_or_add_hf`) and writes the attribute as "0". An existing `` 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; `` 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 0223199b). --- src/pptx/slide.py | 190 +++++++++++++++++- tests/test_slide.py | 477 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 662 insertions(+), 5 deletions(-) diff --git a/src/pptx/slide.py b/src/pptx/slide.py index a66abf6cc..de6e0755f 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -24,15 +24,17 @@ from pptx.oxml.presentation import CT_SlideIdList, CT_SlideMasterIdList from pptx.oxml.slide import ( CT_CommonSlideData, + CT_NotesMaster, CT_NotesSlide, CT_Slide, + CT_SlideLayout, CT_SlideLayoutIdList, CT_SlideMaster, ) from pptx.parts.presentation import PresentationPart from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart from pptx.presentation import Presentation - from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder + from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder, SlidePlaceholder from pptx.shapes.shapetree import NotesSlidePlaceholder from pptx.text.text import TextFrame @@ -92,7 +94,84 @@ def shapes(self): return MasterShapes(self._element.spTree, self) -class NotesMaster(_BaseMaster): +class _HeaderFooterVisibility: + """Provides access to header/footer visibility settings on a slide template.""" + + _element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster + + def _get_hf_visibility(self, attr_name: str) -> bool: + """Return effective `attr_name` value, defaulting to |True| when `` is absent.""" + hf = self._element.hf + return True if hf is None else getattr(hf, attr_name) + + def _set_hf_visibility(self, attr_name: str, value: bool) -> None: + """Set `attr_name` on ``, creating the element only when needed. + + Assigning |True| when `` is absent is a no-op because the effective default is + already |True|. An existing `` element is retained even when all values become + |True|, avoiding low-value XML churn. + """ + hf = self._element.hf + if hf is None and value: + return + if hf is None: + hf = self._element.get_or_add_hf() + setattr(hf, attr_name, value) + + @property + def show_slide_number(self) -> bool: + """`True` when slide numbers are shown for this template, `False` otherwise. + + Assigning |False| creates a `` element when needed and writes `sldNum="0"`. + Assigning |True| preserves any existing `` element rather than removing it. + """ + return self._get_hf_visibility("sldNum") + + @show_slide_number.setter + def show_slide_number(self, value: bool) -> None: + self._set_hf_visibility("sldNum", value) + + @property + def show_footer(self) -> bool: + """`True` when footer placeholders are shown for this template, `False` otherwise. + + Assigning |False| creates a `` element when needed and writes `ftr="0"`. + Assigning |True| preserves any existing `` element rather than removing it. + """ + return self._get_hf_visibility("ftr") + + @show_footer.setter + def show_footer(self, value: bool) -> None: + self._set_hf_visibility("ftr", value) + + @property + def show_date(self) -> bool: + """`True` when date placeholders are shown for this template, `False` otherwise. + + Assigning |False| creates a `` element when needed and writes `dt="0"`. + Assigning |True| preserves any existing `` element rather than removing it. + """ + return self._get_hf_visibility("dt") + + @show_date.setter + def show_date(self, value: bool) -> None: + self._set_hf_visibility("dt", value) + + @property + def show_header(self) -> bool: + """`True` when header placeholders are shown for this template, `False` otherwise. + + Assigning |False| creates a `` element when needed and writes `hdr="0"`. + Assigning |True| preserves any existing `` element rather than removing it. + """ + return self._get_hf_visibility("hdr") + + @show_header.setter + def show_header(self, value: bool) -> None: + self._set_hf_visibility("hdr", value) + + +class NotesMaster(_HeaderFooterVisibility, _BaseMaster): """Proxy for the notes master XML document. Provides access to shapes, the most commonly used of which are placeholders. @@ -214,6 +293,33 @@ def has_notes_slide(self) -> bool: """ return self.part.has_notes_slide + @property + def has_date(self) -> bool: + """`True` if this slide has a date placeholder, `False` otherwise. + + This property is non-mutating; it reports only whether a DATE placeholder is already + present on the slide. + """ + return self._first_ph_of_type(PP_PLACEHOLDER.DATE) is not None + + @property + def has_footer(self) -> bool: + """`True` if this slide has a footer placeholder, `False` otherwise. + + This property is non-mutating; it reports only whether a FOOTER placeholder is already + present on the slide. + """ + return self._first_ph_of_type(PP_PLACEHOLDER.FOOTER) is not None + + @property + def has_slide_number(self) -> bool: + """`True` if this slide has a slide-number placeholder, `False` otherwise. + + This property is non-mutating; it reports only whether a SLIDE_NUMBER placeholder is + already present on the slide. + """ + return self._first_ph_of_type(PP_PLACEHOLDER.SLIDE_NUMBER) is not None + @property def notes_slide(self) -> NotesSlide: """The |NotesSlide| instance for this slide. @@ -223,6 +329,60 @@ def notes_slide(self) -> NotesSlide: """ return self.part.notes_slide + @property + def date_text(self) -> str | None: + """Text of this slide's date placeholder, or |None| when no date placeholder is present. + + Reading this property does not create a placeholder. An existing empty DATE placeholder + returns an empty string. + """ + placeholder = self._first_ph_of_type(PP_PLACEHOLDER.DATE) + return None if placeholder is None else placeholder.text_frame.text + + @date_text.setter + def date_text(self, value: str | None) -> None: + placeholder = self._first_ph_of_type(PP_PLACEHOLDER.DATE) + if value in (None, ""): + if placeholder is not None: + placeholder.text_frame.text = "" + return + + if placeholder is None: + layout_ph = self._layout_ph_of_type(PP_PLACEHOLDER.DATE) + if layout_ph is None: + raise ValueError("slide layout has no DATE placeholder to clone from") + self.shapes.clone_placeholder(layout_ph) + placeholder = cast("SlidePlaceholder", self._first_ph_of_type(PP_PLACEHOLDER.DATE)) + + placeholder.text_frame.text = value + + @property + def footer(self) -> str | None: + """Text of this slide's footer placeholder, or |None| when no footer placeholder is present. + + Reading this property does not create a placeholder. An existing empty FOOTER placeholder + returns an empty string. + """ + placeholder = self._first_ph_of_type(PP_PLACEHOLDER.FOOTER) + return None if placeholder is None else placeholder.text_frame.text + + @footer.setter + def footer(self, value: str | None) -> None: + placeholder = self._first_ph_of_type(PP_PLACEHOLDER.FOOTER) + if value in (None, ""): + if placeholder is not None: + placeholder.text_frame.text = "" + return + + if placeholder is None: + layout_ph = self._layout_ph_of_type(PP_PLACEHOLDER.FOOTER) + if layout_ph is None: + raise ValueError("slide layout has no FOOTER placeholder to clone from") + self.shapes.clone_placeholder(layout_ph) + placeholder = cast("SlidePlaceholder", self._first_ph_of_type(PP_PLACEHOLDER.FOOTER)) + + placeholder.text_frame.text = value + @lazyproperty def placeholders(self) -> SlidePlaceholders: """Sequence of placeholder shapes in this slide.""" @@ -247,6 +407,28 @@ def slide_layout(self) -> SlideLayout: """|SlideLayout| object this slide inherits appearance from.""" return self.part.slide_layout + def _first_ph_of_type(self, ph_type: PP_PLACEHOLDER) -> SlidePlaceholder | None: + """Return the first SlidePlaceholder of `ph_type` in document order, or |None|. + + This helper is non-mutating and returns the first matching slide placeholder when multiple + placeholders of the same type are present. + """ + for placeholder in self.placeholders: + if placeholder.placeholder_format.type == ph_type: + return placeholder + return None + + def _layout_ph_of_type(self, ph_type: PP_PLACEHOLDER) -> LayoutPlaceholder | None: + """Return the first LayoutPlaceholder of `ph_type` on this slide's layout, or |None|. + + The layout placeholder is used as the source when promoting a latent placeholder to a + slide-level placeholder on first write. + """ + for placeholder in self.slide_layout.placeholders: + if placeholder.placeholder_format.type == ph_type: + return placeholder + return None + def delete(self) -> None: """Remove this slide from its presentation. @@ -426,7 +608,7 @@ def duplicate(self, slide: Slide, index: int | None = None) -> Slide: return new_slide_part.slide -class SlideLayout(_BaseSlide): +class SlideLayout(_HeaderFooterVisibility, _BaseSlide): """Slide layout object. Provides access to placeholders, regular shapes, and slide layout-level properties. @@ -544,7 +726,7 @@ def remove(self, slide_layout: SlideLayout) -> None: slide_layout.slide_master.part.drop_rel(target_sldLayoutId.rId) -class SlideMaster(_BaseMaster): +class SlideMaster(_HeaderFooterVisibility, _BaseMaster): """Slide master object. Provides access to slide layouts. Access to placeholders, regular shapes, and slide master-level diff --git a/tests/test_slide.py b/tests/test_slide.py index 7a94b5ec2..7a8676b91 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -4,8 +4,11 @@ from __future__ import annotations +from io import BytesIO + import pytest +from pptx import Presentation as PresentationFactory from pptx.dml.fill import FillFormat from pptx.enum.shapes import PP_PLACEHOLDER from pptx.package import Package @@ -13,7 +16,7 @@ from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart from pptx.presentation import Presentation from pptx.shapes.base import BaseShape -from pptx.shapes.placeholder import LayoutPlaceholder, NotesSlidePlaceholder +from pptx.shapes.placeholder import LayoutPlaceholder, NotesSlidePlaceholder, SlidePlaceholder from pptx.shapes.shapetree import ( LayoutPlaceholders, LayoutShapes, @@ -43,6 +46,20 @@ from .unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock +def _placeholder_with_type( + request, + placeholder_cls: type[LayoutPlaceholder | NotesSlidePlaceholder | SlidePlaceholder], + ph_type: PP_PLACEHOLDER, + text: str = "", +): + """Return placeholder mock configured with `ph_type` and `text`.""" + placeholder_ = instance_mock(request, placeholder_cls) + placeholder_.placeholder_format.type = ph_type + placeholder_.text_frame.text = text + placeholder_.element.ph_type = ph_type + return placeholder_ + + class Describe_BaseSlide(object): """Unit-test suite for `pptx.slide._BaseSlide` objects.""" @@ -372,6 +389,217 @@ def it_provides_access_to_its_notes_slide(self, notes_slide_fixture): slide, notes_slide_ = notes_slide_fixture assert slide.notes_slide is notes_slide_ + def it_knows_when_it_has_no_footer(self, request): + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=()) + + assert slide.has_footer is False + assert slide.footer is None + + def it_knows_when_it_has_a_footer_with_empty_text(self, request): + footer_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(footer_placeholder,)) + + assert slide.has_footer is True + assert slide.footer == "" + + def it_knows_its_footer_text(self, request): + footer_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER, "Quarter Close" + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(footer_placeholder,)) + + assert slide.footer == "Quarter Close" + + def it_returns_the_first_footer_in_document_order(self, request): + first_footer = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER, "First footer" + ) + date_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.DATE, "2026-05-13" + ) + second_footer = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER, "Second footer" + ) + slide = Slide(None, None) + property_mock( + request, + Slide, + "placeholders", + return_value=(first_footer, date_placeholder, second_footer), + ) + + assert slide.footer == "First footer" + + def it_can_set_footer_text_when_already_present(self, request): + footer_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(footer_placeholder,)) + + slide.footer = "Phase 2 footer" + + assert slide.has_footer is True + assert footer_placeholder.text_frame.text == "Phase 2 footer" + + def it_can_set_footer_text_when_absent_by_cloning_from_layout(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + + assert slide.has_footer is False + assert slide.footer is None + + slide.footer = "Phase 2 footer" + + assert slide.has_footer is True + assert slide.footer == "Phase 2 footer" + + out = BytesIO() + prs.save(out) + out.seek(0) + reopened = PresentationFactory(out) + assert reopened.slides[0].footer == "Phase 2 footer" + + def it_can_clear_footer_text_by_setting_None(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.footer = "Phase 2 footer" + + slide.footer = None + + assert slide.has_footer is True + assert slide.footer == "" + + def it_can_clear_footer_text_by_setting_empty_string(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.footer = "Phase 2 footer" + + slide.footer = "" + + assert slide.has_footer is True + assert slide.footer == "" + + def it_leaves_footer_absent_when_clearing_nonexistent_footer(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + + slide.footer = None + + assert slide.has_footer is False + assert slide.footer is None + + def it_raises_when_setting_footer_with_no_layout_FOOTER_placeholder(self, request): + object_placeholder = _placeholder_with_type( + request, LayoutPlaceholder, PP_PLACEHOLDER.OBJECT + ) + slide_layout_ = instance_mock(request, SlideLayout) + slide_layout_.placeholders = (object_placeholder,) + property_mock(request, Slide, "slide_layout", return_value=slide_layout_) + property_mock(request, Slide, "placeholders", return_value=()) + slide = Slide(None, None) + + with pytest.raises(ValueError) as excinfo: + slide.footer = "Phase 2 footer" + + assert str(excinfo.value) == "slide layout has no FOOTER placeholder to clone from" + + def it_knows_when_it_has_a_slide_number_placeholder(self, request): + slide_number_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.SLIDE_NUMBER + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(slide_number_placeholder,)) + + assert slide.has_slide_number is True + + def it_knows_when_it_has_no_slide_number_placeholder(self, request): + footer_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.FOOTER + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(footer_placeholder,)) + + assert slide.has_slide_number is False + + def it_knows_when_it_has_no_date(self, request): + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=()) + + assert slide.has_date is False + assert slide.date_text is None + + def it_knows_when_it_has_a_date_placeholder_with_text(self, request): + date_placeholder = _placeholder_with_type( + request, SlidePlaceholder, PP_PLACEHOLDER.DATE, "2026-05-13" + ) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(date_placeholder,)) + + assert slide.has_date is True + assert slide.date_text == "2026-05-13" + + def it_can_set_date_text_when_present(self, request): + date_placeholder = _placeholder_with_type(request, SlidePlaceholder, PP_PLACEHOLDER.DATE) + slide = Slide(None, None) + property_mock(request, Slide, "placeholders", return_value=(date_placeholder,)) + + slide.date_text = "2026-05-13" + + assert slide.has_date is True + assert date_placeholder.text_frame.text == "2026-05-13" + + def it_can_clear_date_text_by_setting_None(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.date_text = "2026-05-13" + + slide.date_text = None + + assert slide.has_date is True + assert slide.date_text == "" + + def it_can_clone_date_from_layout_on_set(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + + assert slide.has_date is False + assert slide.date_text is None + + slide.date_text = "2026-05-13" + + assert slide.has_date is True + assert slide.date_text == "2026-05-13" + + def it_leaves_date_absent_when_clearing_nonexistent_date(self): + prs = PresentationFactory() + slide = prs.slides.add_slide(prs.slide_layouts[0]) + + slide.date_text = "" + + assert slide.has_date is False + assert slide.date_text is None + + def it_raises_when_setting_date_with_no_layout_DATE_placeholder(self, request): + object_placeholder = _placeholder_with_type( + request, LayoutPlaceholder, PP_PLACEHOLDER.OBJECT + ) + slide_layout_ = instance_mock(request, SlideLayout) + slide_layout_.placeholders = (object_placeholder,) + property_mock(request, Slide, "slide_layout", return_value=slide_layout_) + property_mock(request, Slide, "placeholders", return_value=()) + slide = Slide(None, None) + + with pytest.raises(ValueError) as excinfo: + slide.date_text = "2026-05-13" + + assert str(excinfo.value) == "slide layout has no DATE placeholder to clone from" + # fixtures ------------------------------------------------------- @pytest.fixture @@ -679,6 +907,113 @@ def it_knows_which_slides_are_based_on_it( assert used_by_slides == expected_value + def it_knows_show_slide_number_defaults_True_when_hf_absent(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + assert slide_layout.show_slide_number is True + + def it_knows_show_footer_defaults_True_when_hf_absent(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + assert slide_layout.show_footer is True + + def it_knows_show_date_defaults_True_when_hf_absent(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + assert slide_layout.show_date is True + + def it_knows_show_header_defaults_True_when_hf_absent(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + assert slide_layout.show_header is True + + @pytest.mark.parametrize( + ("prop_name", "sldLayout_cxml", "expected_value"), + [ + ("show_slide_number", "p:sldLayout/(p:cSld/p:spTree,p:hf{sldNum=0})", False), + ("show_footer", "p:sldLayout/(p:cSld/p:spTree,p:hf{ftr=0})", False), + ("show_date", "p:sldLayout/(p:cSld/p:spTree,p:hf{dt=0})", False), + ("show_header", "p:sldLayout/(p:cSld/p:spTree,p:hf{hdr=0})", False), + ("show_slide_number", "p:sldLayout/(p:cSld/p:spTree,p:hf{sldNum=1})", True), + ("show_footer", "p:sldLayout/(p:cSld/p:spTree,p:hf{ftr=1})", True), + ("show_date", "p:sldLayout/(p:cSld/p:spTree,p:hf{dt=1})", True), + ("show_header", "p:sldLayout/(p:cSld/p:spTree,p:hf{hdr=1})", True), + ], + ) + def it_knows_show_attrs_when_hf_present( + self, prop_name: str, sldLayout_cxml: str, expected_value: bool + ): + slide_layout = SlideLayout(element(sldLayout_cxml), None) + assert getattr(slide_layout, prop_name) is expected_value + + def it_can_set_show_slide_number_False_creates_hf(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + + slide_layout.show_slide_number = False + + assert slide_layout._element.xml == xml("p:sldLayout/(p:cSld/p:spTree,p:hf{sldNum=0})") + + def it_can_set_show_footer_False_creates_hf(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + + slide_layout.show_footer = False + + assert slide_layout._element.xml == xml("p:sldLayout/(p:cSld/p:spTree,p:hf{ftr=0})") + + def it_can_set_show_date_False_creates_hf(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + + slide_layout.show_date = False + + assert slide_layout._element.xml == xml("p:sldLayout/(p:cSld/p:spTree,p:hf{dt=0})") + + def it_can_set_show_header_False_creates_hf(self): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + + slide_layout.show_header = False + + assert slide_layout._element.xml == xml("p:sldLayout/(p:cSld/p:spTree,p:hf{hdr=0})") + + @pytest.mark.parametrize( + "prop_name", ["show_slide_number", "show_footer", "show_date", "show_header"] + ) + def it_setting_show_attr_True_when_hf_absent_is_no_op(self, prop_name: str): + slide_layout = SlideLayout(element("p:sldLayout/p:cSld/p:spTree"), None) + + setattr(slide_layout, prop_name, True) + + assert slide_layout._element.xml == xml("p:sldLayout/p:cSld/p:spTree") + + @pytest.mark.parametrize( + ("prop_name", "sldLayout_cxml", "expected_cxml"), + [ + ( + "show_slide_number", + "p:sldLayout/(p:cSld/p:spTree,p:hf{sldNum=0})", + "p:sldLayout/(p:cSld/p:spTree,p:hf)", + ), + ( + "show_footer", + "p:sldLayout/(p:cSld/p:spTree,p:hf{ftr=0})", + "p:sldLayout/(p:cSld/p:spTree,p:hf)", + ), + ( + "show_date", + "p:sldLayout/(p:cSld/p:spTree,p:hf{dt=0})", + "p:sldLayout/(p:cSld/p:spTree,p:hf)", + ), + ( + "show_header", + "p:sldLayout/(p:cSld/p:spTree,p:hf{hdr=0})", + "p:sldLayout/(p:cSld/p:spTree,p:hf)", + ), + ], + ) + def it_setting_show_attr_True_when_hf_present_keeps_hf( + self, prop_name: str, sldLayout_cxml: str, expected_cxml: str + ): + slide_layout = SlideLayout(element(sldLayout_cxml), None) + + setattr(slide_layout, prop_name, True) + + assert slide_layout._element.xml == xml(expected_cxml) + # fixtures ------------------------------------------------------- @pytest.fixture( @@ -941,6 +1276,72 @@ def it_provides_access_to_its_slide_layouts(self, layouts_fixture): SlideLayouts_.assert_called_once_with(sldLayoutIdLst, slide_master) assert slide_layouts is slide_layouts_ + @pytest.mark.parametrize( + "prop_name", ["show_slide_number", "show_footer", "show_date", "show_header"] + ) + def it_knows_show_attrs_default_True_when_hf_absent(self, prop_name: str): + slide_master = SlideMaster(element("p:sldMaster/p:cSld/p:spTree"), None) + assert getattr(slide_master, prop_name) is True + + @pytest.mark.parametrize( + ("prop_name", "sldMaster_cxml", "expected_value"), + [ + ("show_slide_number", "p:sldMaster/(p:cSld/p:spTree,p:hf{sldNum=0})", False), + ("show_footer", "p:sldMaster/(p:cSld/p:spTree,p:hf{ftr=0})", False), + ("show_date", "p:sldMaster/(p:cSld/p:spTree,p:hf{dt=0})", False), + ("show_header", "p:sldMaster/(p:cSld/p:spTree,p:hf{hdr=0})", False), + ], + ) + def it_knows_show_attrs_when_hf_present( + self, prop_name: str, sldMaster_cxml: str, expected_value: bool + ): + slide_master = SlideMaster(element(sldMaster_cxml), None) + assert getattr(slide_master, prop_name) is expected_value + + @pytest.mark.parametrize( + ("prop_name", "expected_cxml"), + [ + ("show_slide_number", "p:sldMaster/(p:cSld/p:spTree,p:hf{sldNum=0})"), + ("show_footer", "p:sldMaster/(p:cSld/p:spTree,p:hf{ftr=0})"), + ("show_date", "p:sldMaster/(p:cSld/p:spTree,p:hf{dt=0})"), + ("show_header", "p:sldMaster/(p:cSld/p:spTree,p:hf{hdr=0})"), + ], + ) + def it_can_set_show_attr_False_creates_hf(self, prop_name: str, expected_cxml: str): + slide_master = SlideMaster(element("p:sldMaster/p:cSld/p:spTree"), None) + + setattr(slide_master, prop_name, False) + + assert slide_master._element.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + "prop_name", ["show_slide_number", "show_footer", "show_date", "show_header"] + ) + def it_setting_show_attr_True_when_hf_absent_is_no_op(self, prop_name: str): + slide_master = SlideMaster(element("p:sldMaster/p:cSld/p:spTree"), None) + + setattr(slide_master, prop_name, True) + + assert slide_master._element.xml == xml("p:sldMaster/p:cSld/p:spTree") + + @pytest.mark.parametrize( + ("prop_name", "sldMaster_cxml"), + [ + ("show_slide_number", "p:sldMaster/(p:cSld/p:spTree,p:hf{sldNum=0})"), + ("show_footer", "p:sldMaster/(p:cSld/p:spTree,p:hf{ftr=0})"), + ("show_date", "p:sldMaster/(p:cSld/p:spTree,p:hf{dt=0})"), + ("show_header", "p:sldMaster/(p:cSld/p:spTree,p:hf{hdr=0})"), + ], + ) + def it_setting_show_attr_True_when_hf_present_keeps_hf( + self, prop_name: str, sldMaster_cxml: str + ): + slide_master = SlideMaster(element(sldMaster_cxml), None) + + setattr(slide_master, prop_name, True) + + assert slide_master._element.xml == xml("p:sldMaster/(p:cSld/p:spTree,p:hf)") + # fixtures ------------------------------------------------------- @pytest.fixture @@ -965,6 +1366,80 @@ def slide_layouts_(self, request): return instance_mock(request, SlideLayouts) +class DescribeNotesMaster(object): + """Unit-test suite for `pptx.slide.NotesMaster` objects.""" + + def it_is_a_BaseMaster_subclass(self): + notes_master = NotesMaster(None, None) + assert isinstance(notes_master, _BaseMaster) + + @pytest.mark.parametrize( + "prop_name", ["show_slide_number", "show_footer", "show_date", "show_header"] + ) + def it_knows_show_attrs_default_True_when_hf_absent(self, prop_name: str): + notes_master = NotesMaster(element("p:notesMaster/p:cSld/p:spTree"), None) + assert getattr(notes_master, prop_name) is True + + @pytest.mark.parametrize( + ("prop_name", "notesMaster_cxml", "expected_value"), + [ + ("show_slide_number", "p:notesMaster/(p:cSld/p:spTree,p:hf{sldNum=0})", False), + ("show_footer", "p:notesMaster/(p:cSld/p:spTree,p:hf{ftr=0})", False), + ("show_date", "p:notesMaster/(p:cSld/p:spTree,p:hf{dt=0})", False), + ("show_header", "p:notesMaster/(p:cSld/p:spTree,p:hf{hdr=0})", False), + ], + ) + def it_knows_show_attrs_when_hf_present( + self, prop_name: str, notesMaster_cxml: str, expected_value: bool + ): + notes_master = NotesMaster(element(notesMaster_cxml), None) + assert getattr(notes_master, prop_name) is expected_value + + @pytest.mark.parametrize( + ("prop_name", "expected_cxml"), + [ + ("show_slide_number", "p:notesMaster/(p:cSld/p:spTree,p:hf{sldNum=0})"), + ("show_footer", "p:notesMaster/(p:cSld/p:spTree,p:hf{ftr=0})"), + ("show_date", "p:notesMaster/(p:cSld/p:spTree,p:hf{dt=0})"), + ("show_header", "p:notesMaster/(p:cSld/p:spTree,p:hf{hdr=0})"), + ], + ) + def it_can_set_show_attr_False_creates_hf(self, prop_name: str, expected_cxml: str): + notes_master = NotesMaster(element("p:notesMaster/p:cSld/p:spTree"), None) + + setattr(notes_master, prop_name, False) + + assert notes_master._element.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + "prop_name", ["show_slide_number", "show_footer", "show_date", "show_header"] + ) + def it_setting_show_attr_True_when_hf_absent_is_no_op(self, prop_name: str): + notes_master = NotesMaster(element("p:notesMaster/p:cSld/p:spTree"), None) + + setattr(notes_master, prop_name, True) + + assert notes_master._element.xml == xml("p:notesMaster/p:cSld/p:spTree") + + @pytest.mark.parametrize( + ("prop_name", "notesMaster_cxml"), + [ + ("show_slide_number", "p:notesMaster/(p:cSld/p:spTree,p:hf{sldNum=0})"), + ("show_footer", "p:notesMaster/(p:cSld/p:spTree,p:hf{ftr=0})"), + ("show_date", "p:notesMaster/(p:cSld/p:spTree,p:hf{dt=0})"), + ("show_header", "p:notesMaster/(p:cSld/p:spTree,p:hf{hdr=0})"), + ], + ) + def it_setting_show_attr_True_when_hf_present_keeps_hf( + self, prop_name: str, notesMaster_cxml: str + ): + notes_master = NotesMaster(element(notesMaster_cxml), None) + + setattr(notes_master, prop_name, True) + + assert notes_master._element.xml == xml("p:notesMaster/(p:cSld/p:spTree,p:hf)") + + class DescribeSlideMasters(object): """Unit-test suite for `pptx.slide.SlideMasters` objects."""