From 6f650190c88c3c22ba1de8998c2cac3c4cd241bd Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Wed, 13 May 2026 20:46:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20HandoutMaster=20+=20add=5Ftext=5Fwaterm?= =?UTF-8?q?ark=20helper=20=E2=80=94=20Phase=205,=20closing=20#20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the headers/footers epic (#20). Phase 1 (PR #48) shipped the `CT_HandoutMaster` OOXML wrapper; Phases 2-4 (PRs #49, #50, #51) built out the public headers/footers/fields surface. Phase 5 adds the two remaining pieces: a public `HandoutMaster` Python class so users can read a handout master's visibility settings without dropping into XML, and a `SlideMaster.add_text_watermark()` helper that wraps the common "DRAFT"-style watermark pattern into one call. Why this is small. `_HeaderFooterVisibility` (Phase 2) and `CT_HandoutMaster` (Phase 1) already do the heavy lifting; Phase 5 just wires a Python proxy + part + accessor onto them and adds a thin convenience method. Changes: - pptx.slide.HandoutMaster — new class, inherits `_HeaderFooterVisibility, _BaseMaster` in the same order as `NotesMaster`. Docstring names the deferred auto-create. - pptx.slide.SlideMaster.add_text_watermark — new method. Adds a centered 6in x 1.5in textbox to the master at standard 10in x 7.5in slide coordinates (left=2in, top=3in). Text gets `PP_ALIGN.CENTER`; the first run gets `font.name`, `font.size`, mid-gray solid fill, and the configured `transparency` (default 0.7). Returns the new `Shape` so callers can re-position or restyle. - pptx.slide._HeaderFooterVisibility — `_element` type union widened to include `CT_HandoutMaster` so the mixin types cleanly under `HandoutMaster`. - pptx.parts.slide.HandoutMasterPart — new `BaseSlidePart` subclass. Defines `handout_master` lazyproperty; deliberately omits `create_default` / `_new` / `_new_theme_part` because no `handoutMaster.xml` template ships in this fork yet. - pptx.parts.presentation.PresentationPart.handout_master_part — new lazyproperty. Returns `part_related_by(RT.HANDOUT_MASTER)` when present; raises `ValueError` with a "no handout master" message (chained `from KeyError`) when absent. Unlike `notes_master_part`, this does NOT auto-create. - pptx.parts.presentation.PresentationPart.handout_master — new lazyproperty pass-through to `.handout_master_part.handout_master`. - pptx.presentation.Presentation.handout_master — new property pass-through, matching the shape of `notes_master`. - pptx.shapes.shapetree.MasterShapes.add_textbox — new method, parallel to the existing `SlideShapes.add_textbox`. Needed so `SlideMaster.add_text_watermark` can drop a textbox directly on the master's shape tree (master shapes previously had no `add_textbox`, only the slide / layout collections did). - pptx/__init__.py — registers `HandoutMasterPart` against `CT.PML_HANDOUT_MASTER` in `content_type_to_part_class_map` and adds it to the cleanup `del (...)` tuple. Out of scope for Phase 5 (deliberate, see ISA Out of Scope): - Auto-create of handout master when absent. Synthesizing one from scratch needs a known-good `handoutMaster.xml` template, and shipping that template + the theme wiring is risky without a reference deck. Phase 6+ if real users ask. The error message surfaces the deferral explicitly. - `HandoutMaster.add_placeholder()` / placeholder cloning — read-only this phase; the inherited visibility props are write-enabled, but the placeholder set on the handout master is static. - Image-watermark helper — `master.shapes.add_picture(...)` already does this in one line; wrapping it adds little value. - Per-slide watermark — `add_text_watermark` lives on `SlideMaster` so every slide using that master gets the watermark by inheritance; per-slide watermark is just `slide.shapes.add_textbox(...)` today. - `MSO_WATERMARK_PRESET` enum or canned styles — string text + numeric transparency is the entire API surface. Anti-criteria upheld: - No `handoutMaster.xml` template file added (verified by `find src -name '*handoutMaster*'`). - `Presentation.handout_master` does NOT silently auto-create — the `but_it_raises_ValueError_when_no_handout_master_is_present` test in `tests/parts/test_presentation.py` pins this. - No `add_image_watermark` or `MSO_WATERMARK_PRESET` (verified by `grep -rn 'add_image_watermark|MSO_WATERMARK' src/ tests/`). - `NotesMaster` and `NotesMasterPart` bodies are unchanged; only import lines that previously named just `NotesMaster` now also name `HandoutMaster`. - `it_has_no_create_default_classmethod` in `tests/parts/test_slide.py` regression-pins that `HandoutMasterPart` does NOT acquire `create_default` over time. Test counts: 19 new test cases (16 new `it_/but_/and_` functions; the `it_round_trips_show_attrs_via_hf` parametrize fans out to 4 cases). Distribution: 6 in `DescribeHandoutMaster`, 4 in `DescribeSlideMasterAddTextWatermark`, 4 in `DescribeHandoutMasterPart`, 3 in `DescribePresentationPart`, 1 in `DescribePresentation`. Pytest runs at 3651 passed (baseline 3632 + 19). Verification (local, CPython 3.14.4): $ python3 -m pytest tests/ -q | tail -3 ........................................................................ [100%] ................................................... [100%] 3651 passed in 5.28s $ python3 -m ruff check src tests | tail -3 All checks passed! $ python3 -m ruff format --check src tests | tail -3 216 files already formatted $ python3 -m behave features/ --no-color 2>&1 | tail -3 1048 scenarios passed, 0 failed, 0 skipped 3151 steps passed, 0 failed, 0 skipped Took 0min 1.696s $ python3 uat/uat_headers_footers_phase5.py opening /Users/mhoroszowski/Projects/AI/python-pptx/tests/test_files/test.pptx OK — missing handout master raises ValueError: presentation has no handout master; auto-create is deferred because no handout master template ships in this fork yet adding watermark 'DRAFT' to slide_masters[0] watermark shape: left=1828800 top=2743200 width=5486400 height=1371600 saving to /Users/mhoroszowski/Projects/AI/python-pptx/uat/out_headers_footers_phase5.pptx re-opening /Users/mhoroszowski/Projects/AI/python-pptx/uat/out_headers_footers_phase5.pptx round-tripped watermark: text='DRAFT' font='Calibri' size=72.0 transparency=0.7 OK — watermark transparency round-tripped at 0.7 PASS — handout-master absent-path and watermark helper verified Closes #20. --- src/pptx/__init__.py | 3 + src/pptx/parts/presentation.py | 28 +++++++++- src/pptx/parts/slide.py | 16 +++++- src/pptx/presentation.py | 11 +++- src/pptx/shapes/shapetree.py | 7 +++ src/pptx/slide.py | 50 ++++++++++++++++- tests/parts/test_presentation.py | 42 +++++++++++++- tests/parts/test_slide.py | 31 ++++++++++- tests/test_presentation.py | 16 +++++- tests/test_slide.py | 96 ++++++++++++++++++++++++++++++++ 10 files changed, 290 insertions(+), 10 deletions(-) diff --git a/src/pptx/__init__.py b/src/pptx/__init__.py index 7b8c9f52c..dc53a39d3 100644 --- a/src/pptx/__init__.py +++ b/src/pptx/__init__.py @@ -17,6 +17,7 @@ from pptx.parts.media import MediaPart from pptx.parts.presentation import PresentationPart from pptx.parts.slide import ( + HandoutMasterPart, NotesMasterPart, NotesSlidePart, SlideLayoutPart, @@ -46,6 +47,7 @@ # `Plans/customxml-implementation-plan.md` §3.6. The Phase-3 # `CustomXmlParts` collection wraps loaded base `Part` instances # at enumeration time. + CT.PML_HANDOUT_MASTER: HandoutMasterPart, CT.PML_NOTES_MASTER: NotesMasterPart, CT.PML_NOTES_SLIDE: NotesSlidePart, CT.PML_SLIDE: SlidePart, @@ -81,6 +83,7 @@ CorePropertiesPart, CustomPropertiesPart, CustomXmlPropertiesPart, + HandoutMasterPart, ImagePart, MediaPart, SlidePart, diff --git a/src/pptx/parts/presentation.py b/src/pptx/parts/presentation.py index 36491d99d..f3c8bc2eb 100644 --- a/src/pptx/parts/presentation.py +++ b/src/pptx/parts/presentation.py @@ -7,7 +7,7 @@ from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.opc.package import XmlPart from pptx.opc.packuri import PackURI -from pptx.parts.slide import NotesMasterPart, SlidePart +from pptx.parts.slide import HandoutMasterPart, NotesMasterPart, SlidePart from pptx.presentation import Presentation from pptx.util import lazyproperty @@ -15,7 +15,7 @@ from pptx.custom_properties import CustomProperties from pptx.custom_xml import CustomXmlParts from pptx.parts.coreprops import CorePropertiesPart - from pptx.slide import NotesMaster, Slide, SlideLayout, SlideMaster + from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayout, SlideMaster class PresentationPart(XmlPart): @@ -101,6 +101,30 @@ def notes_master_part(self) -> NotesMasterPart: self.relate_to(notes_master_part, RT.NOTES_MASTER) return notes_master_part + @lazyproperty + def handout_master(self) -> HandoutMaster: + """Return the |HandoutMaster| object for this presentation. + + Raises |ValueError| when the presentation has no handout master because auto-create is + deliberately deferred until a built-in handout-master template ships in this fork. + """ + return self.handout_master_part.handout_master + + @lazyproperty + def handout_master_part(self) -> HandoutMasterPart: + """Return the |HandoutMasterPart| object for this presentation. + + Raises |ValueError| when the presentation has no handout master because auto-create is + deliberately deferred until a built-in handout-master template ships in this fork. + """ + try: + return self.part_related_by(RT.HANDOUT_MASTER) + except KeyError as e: + raise ValueError( + "presentation has no handout master; auto-create is deferred because no " + "handout master template ships in this fork yet" + ) from e + @lazyproperty def presentation(self): """ diff --git a/src/pptx/parts/slide.py b/src/pptx/parts/slide.py index 7311e516f..2b0910031 100644 --- a/src/pptx/parts/slide.py +++ b/src/pptx/parts/slide.py @@ -19,7 +19,7 @@ from pptx.parts.chart import ChartPart from pptx.parts.embeddedpackage import EmbeddedPackagePart from pptx.parts.image import Image, ImagePart -from pptx.slide import NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster +from pptx.slide import HandoutMaster, NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster from pptx.util import lazyproperty if TYPE_CHECKING: @@ -114,6 +114,20 @@ def _new_theme_part(cls, package): ) +class HandoutMasterPart(BaseSlidePart): + """Handout master part. + + Corresponds to package file `ppt/handoutMasters/handoutMaster1.xml` when present. + Auto-create is deliberately deferred until this fork ships a built-in handout-master + template and theme wiring. + """ + + @lazyproperty + def handout_master(self): + """Return the |HandoutMaster| object that proxies this handout master part.""" + return HandoutMaster(self._element, self) + + class NotesSlidePart(BaseSlidePart): """Notes slide part. diff --git a/src/pptx/presentation.py b/src/pptx/presentation.py index 44db95f9e..5d9404e03 100644 --- a/src/pptx/presentation.py +++ b/src/pptx/presentation.py @@ -15,7 +15,7 @@ from pptx.custom_xml import CustomXmlParts from pptx.oxml.presentation import CT_Presentation, CT_SlideId from pptx.parts.presentation import PresentationPart - from pptx.slide import NotesMaster, Slide, SlideLayouts + from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayouts from pptx.util import Length @@ -67,6 +67,15 @@ def notes_master(self) -> NotesMaster: """ return self.part.notes_master + @property + def handout_master(self) -> HandoutMaster: + """Instance of |HandoutMaster| for this presentation. + + Raises |ValueError| when the presentation has no handout master because auto-create is + deliberately deferred until a built-in handout-master template ships in this fork. + """ + return self.part.handout_master + def save(self, file: str | os.PathLike[str] | IO[bytes]): """Writes this presentation to `file`. diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 4a8bbb318..c8cacb3fd 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -831,6 +831,13 @@ class MasterShapes(_BaseShapes): Supports indexed access, len(), and iteration. """ + def add_textbox(self, left: Length, top: Length, width: Length, height: Length) -> Shape: + """Return newly added text box shape appended to this master shape tree.""" + shape_id = self._next_shape_id + name = "TextBox %d" % (shape_id - 1) + sp = self._spTree.add_textbox(shape_id, name, left, top, width, height) + return cast(Shape, self._shape_factory(sp)) + def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape: """Return an instance of the appropriate shape proxy class for `shape_elm`.""" return _MasterShapeFactory(shape_elm, self) diff --git a/src/pptx/slide.py b/src/pptx/slide.py index de6e0755f..4a5e48775 100644 --- a/src/pptx/slide.py +++ b/src/pptx/slide.py @@ -4,8 +4,10 @@ from typing import TYPE_CHECKING, Iterator, cast +from pptx.dml.color import RGBColor from pptx.dml.fill import FillFormat from pptx.enum.shapes import PP_PLACEHOLDER +from pptx.enum.text import PP_ALIGN from pptx.opc.constants import RELATIONSHIP_TYPE as RT from pptx.shapes.shapetree import ( LayoutPlaceholders, @@ -18,12 +20,13 @@ SlideShapes, ) from pptx.shared import ElementProxy, ParentedElementProxy, PartElementProxy -from pptx.util import lazyproperty +from pptx.util import Inches, Length, Pt, lazyproperty if TYPE_CHECKING: from pptx.oxml.presentation import CT_SlideIdList, CT_SlideMasterIdList from pptx.oxml.slide import ( CT_CommonSlideData, + CT_HandoutMaster, CT_NotesMaster, CT_NotesSlide, CT_Slide, @@ -34,6 +37,7 @@ from pptx.parts.presentation import PresentationPart from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart from pptx.presentation import Presentation + from pptx.shapes.autoshape import Shape from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder, SlidePlaceholder from pptx.shapes.shapetree import NotesSlidePlaceholder from pptx.text.text import TextFrame @@ -97,7 +101,7 @@ def shapes(self): class _HeaderFooterVisibility: """Provides access to header/footer visibility settings on a slide template.""" - _element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster + _element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster | CT_HandoutMaster def _get_hf_visibility(self, attr_name: str) -> bool: """Return effective `attr_name` value, defaulting to |True| when `` is absent.""" @@ -178,6 +182,15 @@ class NotesMaster(_HeaderFooterVisibility, _BaseMaster): """ +class HandoutMaster(_HeaderFooterVisibility, _BaseMaster): + """Proxy for the handout master XML document. + + Provides access to shapes and header/footer visibility settings when a deck already + contains a handout master. Auto-create is deliberately deferred until a built-in + `handoutMaster.xml` template ships in this fork. + """ + + class NotesSlide(_BaseSlide): """Notes slide object. @@ -735,6 +748,39 @@ class SlideMaster(_HeaderFooterVisibility, _BaseMaster): _element: CT_SlideMaster # pyright: ignore[reportIncompatibleVariableOverride] + def add_text_watermark( + self, + text: str, + *, + font_size: Length = Pt(72), + transparency: float = 0.7, + font_name: str = "Calibri", + ) -> Shape: + """Add and return a centered watermark textbox on this slide master. + + The watermark is a large mid-gray text box sized for the standard 10in x 7.5in + slide canvas. `transparency` is applied to the run's solid font fill. + """ + slide_width = Inches(10) + slide_height = Inches(7.5) + textbox_width = Inches(6) + textbox_height = Inches(1.5) + # ---center a 6in x 1.5in textbox on a standard 10in x 7.5in slide--- + left = (slide_width - textbox_width) // 2 + top = (slide_height - textbox_height) // 2 + + shape = self.shapes.add_textbox(left, top, textbox_width, textbox_height) + paragraph = shape.text_frame.paragraphs[0] + paragraph.text = text + paragraph.alignment = PP_ALIGN.CENTER + run = paragraph.runs[0] + run.font.name = font_name + run.font.size = font_size + run.font.fill.solid() + run.font.fill.fore_color.rgb = RGBColor(0x80, 0x80, 0x80) + run.font.fill.transparency = transparency + return shape + @lazyproperty def slide_layouts(self) -> SlideLayouts: """|SlideLayouts| object providing access to this slide-master's layouts.""" diff --git a/tests/parts/test_presentation.py b/tests/parts/test_presentation.py index 76ab8265c..472c2e68d 100644 --- a/tests/parts/test_presentation.py +++ b/tests/parts/test_presentation.py @@ -9,9 +9,9 @@ from pptx.package import Package from pptx.parts.coreprops import CorePropertiesPart from pptx.parts.presentation import PresentationPart -from pptx.parts.slide import NotesMasterPart, SlideMasterPart, SlidePart +from pptx.parts.slide import HandoutMasterPart, NotesMasterPart, SlideMasterPart, SlidePart from pptx.presentation import Presentation -from pptx.slide import NotesMaster, Slide, SlideLayout, SlideMaster +from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayout, SlideMaster from ..unitutil.cxml import element from ..unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock @@ -86,6 +86,40 @@ def it_provides_access_to_its_notes_master(self, request, notes_master_part_): assert prs_part.notes_master is notes_master_ + def it_provides_access_to_an_existing_handout_master_part( + self, handout_master_part_, part_related_by_ + ): + prs_part = PresentationPart(None, None, None, None) + part_related_by_.return_value = handout_master_part_ + + handout_master_part = prs_part.handout_master_part + + prs_part.part_related_by.assert_called_once_with(prs_part, RT.HANDOUT_MASTER) + assert handout_master_part is handout_master_part_ + + def but_it_raises_ValueError_when_no_handout_master_is_present(self, part_related_by_): + missing_rel = KeyError("rId99") + part_related_by_.side_effect = missing_rel + prs_part = PresentationPart(None, None, None, None) + + with pytest.raises(ValueError, match="no handout master") as exc_info: + prs_part.handout_master_part + + assert exc_info.value.__cause__ is missing_rel + + def it_provides_access_to_its_handout_master(self, request, handout_master_part_): + handout_master_ = instance_mock(request, HandoutMaster) + property_mock( + request, + PresentationPart, + "handout_master_part", + return_value=handout_master_part_, + ) + handout_master_part_.handout_master = handout_master_ + prs_part = PresentationPart(None, None, None, None) + + assert prs_part.handout_master is handout_master_ + def it_provides_access_to_a_related_slide(self, request, slide_, related_part_): slide_part_ = instance_mock(request, SlidePart, slide=slide_) related_part_.return_value = slide_part_ @@ -192,6 +226,10 @@ def it_knows_the_next_slide_partname_to_help(self): # fixture components --------------------------------------------- + @pytest.fixture + def handout_master_part_(self, request): + return instance_mock(request, HandoutMasterPart) + @pytest.fixture def notes_master_part_(self, request): return instance_mock(request, NotesMasterPart) diff --git a/tests/parts/test_slide.py b/tests/parts/test_slide.py index 5497a841e..4c159a885 100644 --- a/tests/parts/test_slide.py +++ b/tests/parts/test_slide.py @@ -22,13 +22,14 @@ from pptx.parts.presentation import PresentationPart from pptx.parts.slide import ( BaseSlidePart, + HandoutMasterPart, NotesMasterPart, NotesSlidePart, SlideLayoutPart, SlideMasterPart, SlidePart, ) -from pptx.slide import NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster +from pptx.slide import HandoutMaster, NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster from ..unitutil.cxml import element from ..unitutil.file import absjoin, test_file_dir @@ -179,6 +180,34 @@ def theme_part_(self, request): return instance_mock(request, Part) +class DescribeHandoutMasterPart(object): + """Unit-test suite for `pptx.parts.slide.HandoutMasterPart` objects.""" + + def it_is_a_BaseSlidePart_subclass(self): + assert issubclass(HandoutMasterPart, BaseSlidePart) + + def it_provides_access_to_its_handout_master(self, request): + handout_master_ = instance_mock(request, HandoutMaster) + HandoutMaster_ = class_mock( + request, "pptx.parts.slide.HandoutMaster", return_value=handout_master_ + ) + handoutMaster = element("p:handoutMaster") + handout_master_part = HandoutMasterPart(None, None, None, handoutMaster) + + handout_master = handout_master_part.handout_master + + HandoutMaster_.assert_called_once_with(handoutMaster, handout_master_part) + assert handout_master is handout_master_ + + def it_has_no_create_default_classmethod(self): + assert not hasattr(HandoutMasterPart, "create_default") + + def it_can_be_constructed_as_a_handout_master_part(self): + handout_master_part = HandoutMasterPart(None, None, None, element("p:handoutMaster")) + + assert isinstance(handout_master_part, HandoutMasterPart) + + class DescribeNotesSlidePart(object): """Unit-test suite for `pptx.parts.slide.NotesSlidePart` objects.""" diff --git a/tests/test_presentation.py b/tests/test_presentation.py index 7c5315143..0b163b715 100644 --- a/tests/test_presentation.py +++ b/tests/test_presentation.py @@ -8,7 +8,7 @@ from pptx.parts.presentation import PresentationPart from pptx.parts.slide import NotesMasterPart from pptx.presentation import Presentation -from pptx.slide import SlideLayouts, SlideMaster, SlideMasters, Slides +from pptx.slide import HandoutMaster, SlideLayouts, SlideMaster, SlideMasters, Slides from .unitutil.cxml import element, xml from .unitutil.mock import class_mock, instance_mock, property_mock @@ -45,6 +45,10 @@ def it_provides_access_to_its_notes_master(self, notes_master_fixture): prs, notes_master_ = notes_master_fixture assert prs.notes_master is notes_master_ + def it_provides_access_to_its_handout_master(self, handout_master_fixture): + prs, handout_master_ = handout_master_fixture + assert prs.handout_master is handout_master_ + def it_provides_access_to_its_slides(self, slides_fixture): prs, rename_slide_parts_, rIds = slides_fixture[:3] Slides_, slides_, expected_xml = slides_fixture[3:] @@ -90,6 +94,12 @@ def layouts_fixture(self, masters_prop_, slide_layouts_): masters_prop_.return_value.__getitem__.return_value.slide_layouts = slide_layouts_ return prs, slide_layouts_ + @pytest.fixture + def handout_master_fixture(self, prs_part_, handout_master_): + prs = Presentation(None, prs_part_) + prs_part_.handout_master = handout_master_ + return prs, handout_master_ + @pytest.fixture def master_fixture(self, masters_prop_, slide_master_): prs = Presentation(None, None) @@ -194,6 +204,10 @@ def core_properties_(self, request): def masters_prop_(self, request): return property_mock(request, Presentation, "slide_masters") + @pytest.fixture + def handout_master_(self, request): + return instance_mock(request, HandoutMaster) + @pytest.fixture def notes_master_(self, request): return instance_mock(request, NotesMasterPart) diff --git a/tests/test_slide.py b/tests/test_slide.py index 7a8676b91..48e0a37c9 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -9,12 +9,15 @@ import pytest from pptx import Presentation as PresentationFactory +from pptx.dml.color import RGBColor from pptx.dml.fill import FillFormat from pptx.enum.shapes import PP_PLACEHOLDER +from pptx.enum.text import PP_ALIGN from pptx.package import Package from pptx.parts.presentation import PresentationPart from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart from pptx.presentation import Presentation +from pptx.shapes.autoshape import Shape from pptx.shapes.base import BaseShape from pptx.shapes.placeholder import LayoutPlaceholder, NotesSlidePlaceholder, SlidePlaceholder from pptx.shapes.shapetree import ( @@ -28,6 +31,7 @@ SlideShapes, ) from pptx.slide import ( + HandoutMaster, NotesMaster, NotesSlide, Slide, @@ -39,8 +43,10 @@ _Background, _BaseMaster, _BaseSlide, + _HeaderFooterVisibility, ) from pptx.text.text import TextFrame +from pptx.util import Inches, Pt from .unitutil.cxml import element, xml from .unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock @@ -1440,6 +1446,96 @@ def it_setting_show_attr_True_when_hf_present_keeps_hf( assert notes_master._element.xml == xml("p:notesMaster/(p:cSld/p:spTree,p:hf)") +class DescribeHandoutMaster(object): + """Unit-test suite for `pptx.slide.HandoutMaster` objects.""" + + def it_has_the_expected_mro(self): + assert HandoutMaster.__mro__[:3] == (HandoutMaster, _HeaderFooterVisibility, _BaseMaster) + + def it_knows_show_slide_number_default_True_when_hf_absent(self): + handout_master = HandoutMaster(element("p:handoutMaster/p:cSld/p:spTree"), None) + assert handout_master.show_slide_number is True + + def it_can_set_show_footer_False_creates_hf(self): + handout_master = HandoutMaster(element("p:handoutMaster/p:cSld/p:spTree"), None) + + handout_master.show_footer = False + + assert handout_master._element.xml == xml("p:handoutMaster/(p:cSld/p:spTree,p:hf{ftr=0})") + + @pytest.mark.parametrize( + ("prop_name", "handoutMaster_cxml", "expected_value"), + [ + ("show_slide_number", "p:handoutMaster/(p:cSld/p:spTree,p:hf{sldNum=0})", False), + ("show_footer", "p:handoutMaster/(p:cSld/p:spTree,p:hf{ftr=0})", False), + ("show_date", "p:handoutMaster/(p:cSld/p:spTree,p:hf{dt=0})", False), + ("show_header", "p:handoutMaster/(p:cSld/p:spTree,p:hf{hdr=0})", False), + ], + ) + def it_round_trips_show_attrs_via_hf( + self, prop_name: str, handoutMaster_cxml: str, expected_value: bool + ): + handout_master = HandoutMaster(element(handoutMaster_cxml), None) + + assert getattr(handout_master, prop_name) is expected_value + + setattr(handout_master, prop_name, True) + + assert getattr(handout_master, prop_name) is True + + +class DescribeSlideMasterAddTextWatermark(object): + """Unit-test suite for `SlideMaster.add_text_watermark()`.""" + + def it_returns_the_new_textbox_shape(self): + prs = PresentationFactory() + slide_master = prs.slide_masters[0] + + shape = slide_master.add_text_watermark("DRAFT") + + assert isinstance(shape, Shape) + + def it_sets_the_watermark_text_and_paragraph_alignment(self): + prs = PresentationFactory() + slide_master = prs.slide_masters[0] + + shape = slide_master.add_text_watermark("DRAFT") + + paragraph = shape.text_frame.paragraphs[0] + assert shape.text_frame.text == "DRAFT" + assert paragraph.alignment == PP_ALIGN.CENTER + + def it_applies_font_name_size_color_and_transparency_to_the_first_run(self): + prs = PresentationFactory() + slide_master = prs.slide_masters[0] + + shape = slide_master.add_text_watermark( + "DRAFT", font_size=Pt(54), transparency=0.4, font_name="Aptos" + ) + + run = shape.text_frame.paragraphs[0].runs[0] + assert run.font.name == "Aptos" + assert run.font.size == Pt(54) + assert run.font.fill.fore_color.rgb == RGBColor(0x80, 0x80, 0x80) + assert run.font.fill.transparency == pytest.approx(0.4) + + def it_adds_a_centered_textbox_reachable_via_master_shapes(self): + prs = PresentationFactory() + slide_master = prs.slide_masters[0] + initial_count = len(slide_master.shapes) + + shape = slide_master.add_text_watermark("DRAFT") + + assert len(slide_master.shapes) == initial_count + 1 + assert slide_master.shapes[-1].element is shape.element + assert (shape.left, shape.top, shape.width, shape.height) == ( + Inches(2), + Inches(3), + Inches(6), + Inches(1.5), + ) + + class DescribeSlideMasters(object): """Unit-test suite for `pptx.slide.SlideMasters` objects."""