Skip to content

Commit 2c96f97

Browse files
authored
Merge pull request #52 from MHoroszowski/feature/headers-footers-phase5
feat: HandoutMaster + add_text_watermark helper — Phase 5, closing #20
2 parents 87b22c7 + 6f65019 commit 2c96f97

10 files changed

Lines changed: 290 additions & 10 deletions

File tree

src/pptx/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pptx.parts.media import MediaPart
1818
from pptx.parts.presentation import PresentationPart
1919
from pptx.parts.slide import (
20+
HandoutMasterPart,
2021
NotesMasterPart,
2122
NotesSlidePart,
2223
SlideLayoutPart,
@@ -46,6 +47,7 @@
4647
# `Plans/customxml-implementation-plan.md` §3.6. The Phase-3
4748
# `CustomXmlParts` collection wraps loaded base `Part` instances
4849
# at enumeration time.
50+
CT.PML_HANDOUT_MASTER: HandoutMasterPart,
4951
CT.PML_NOTES_MASTER: NotesMasterPart,
5052
CT.PML_NOTES_SLIDE: NotesSlidePart,
5153
CT.PML_SLIDE: SlidePart,
@@ -81,6 +83,7 @@
8183
CorePropertiesPart,
8284
CustomPropertiesPart,
8385
CustomXmlPropertiesPart,
86+
HandoutMasterPart,
8487
ImagePart,
8588
MediaPart,
8689
SlidePart,

src/pptx/parts/presentation.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
88
from pptx.opc.package import XmlPart
99
from pptx.opc.packuri import PackURI
10-
from pptx.parts.slide import NotesMasterPart, SlidePart
10+
from pptx.parts.slide import HandoutMasterPart, NotesMasterPart, SlidePart
1111
from pptx.presentation import Presentation
1212
from pptx.util import lazyproperty
1313

1414
if TYPE_CHECKING:
1515
from pptx.custom_properties import CustomProperties
1616
from pptx.custom_xml import CustomXmlParts
1717
from pptx.parts.coreprops import CorePropertiesPart
18-
from pptx.slide import NotesMaster, Slide, SlideLayout, SlideMaster
18+
from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayout, SlideMaster
1919

2020

2121
class PresentationPart(XmlPart):
@@ -101,6 +101,30 @@ def notes_master_part(self) -> NotesMasterPart:
101101
self.relate_to(notes_master_part, RT.NOTES_MASTER)
102102
return notes_master_part
103103

104+
@lazyproperty
105+
def handout_master(self) -> HandoutMaster:
106+
"""Return the |HandoutMaster| object for this presentation.
107+
108+
Raises |ValueError| when the presentation has no handout master because auto-create is
109+
deliberately deferred until a built-in handout-master template ships in this fork.
110+
"""
111+
return self.handout_master_part.handout_master
112+
113+
@lazyproperty
114+
def handout_master_part(self) -> HandoutMasterPart:
115+
"""Return the |HandoutMasterPart| object for this presentation.
116+
117+
Raises |ValueError| when the presentation has no handout master because auto-create is
118+
deliberately deferred until a built-in handout-master template ships in this fork.
119+
"""
120+
try:
121+
return self.part_related_by(RT.HANDOUT_MASTER)
122+
except KeyError as e:
123+
raise ValueError(
124+
"presentation has no handout master; auto-create is deferred because no "
125+
"handout master template ships in this fork yet"
126+
) from e
127+
104128
@lazyproperty
105129
def presentation(self):
106130
"""

src/pptx/parts/slide.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pptx.parts.chart import ChartPart
2020
from pptx.parts.embeddedpackage import EmbeddedPackagePart
2121
from pptx.parts.image import Image, ImagePart
22-
from pptx.slide import NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster
22+
from pptx.slide import HandoutMaster, NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster
2323
from pptx.util import lazyproperty
2424

2525
if TYPE_CHECKING:
@@ -114,6 +114,20 @@ def _new_theme_part(cls, package):
114114
)
115115

116116

117+
class HandoutMasterPart(BaseSlidePart):
118+
"""Handout master part.
119+
120+
Corresponds to package file `ppt/handoutMasters/handoutMaster1.xml` when present.
121+
Auto-create is deliberately deferred until this fork ships a built-in handout-master
122+
template and theme wiring.
123+
"""
124+
125+
@lazyproperty
126+
def handout_master(self):
127+
"""Return the |HandoutMaster| object that proxies this handout master part."""
128+
return HandoutMaster(self._element, self)
129+
130+
117131
class NotesSlidePart(BaseSlidePart):
118132
"""Notes slide part.
119133

src/pptx/presentation.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from pptx.custom_xml import CustomXmlParts
1616
from pptx.oxml.presentation import CT_Presentation, CT_SlideId
1717
from pptx.parts.presentation import PresentationPart
18-
from pptx.slide import NotesMaster, Slide, SlideLayouts
18+
from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayouts
1919
from pptx.util import Length
2020

2121

@@ -67,6 +67,15 @@ def notes_master(self) -> NotesMaster:
6767
"""
6868
return self.part.notes_master
6969

70+
@property
71+
def handout_master(self) -> HandoutMaster:
72+
"""Instance of |HandoutMaster| for this presentation.
73+
74+
Raises |ValueError| when the presentation has no handout master because auto-create is
75+
deliberately deferred until a built-in handout-master template ships in this fork.
76+
"""
77+
return self.part.handout_master
78+
7079
def save(self, file: str | os.PathLike[str] | IO[bytes]):
7180
"""Writes this presentation to `file`.
7281

src/pptx/shapes/shapetree.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,13 @@ class MasterShapes(_BaseShapes):
831831
Supports indexed access, len(), and iteration.
832832
"""
833833

834+
def add_textbox(self, left: Length, top: Length, width: Length, height: Length) -> Shape:
835+
"""Return newly added text box shape appended to this master shape tree."""
836+
shape_id = self._next_shape_id
837+
name = "TextBox %d" % (shape_id - 1)
838+
sp = self._spTree.add_textbox(shape_id, name, left, top, width, height)
839+
return cast(Shape, self._shape_factory(sp))
840+
834841
def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape:
835842
"""Return an instance of the appropriate shape proxy class for `shape_elm`."""
836843
return _MasterShapeFactory(shape_elm, self)

src/pptx/slide.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
from typing import TYPE_CHECKING, Iterator, cast
66

7+
from pptx.dml.color import RGBColor
78
from pptx.dml.fill import FillFormat
89
from pptx.enum.shapes import PP_PLACEHOLDER
10+
from pptx.enum.text import PP_ALIGN
911
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
1012
from pptx.shapes.shapetree import (
1113
LayoutPlaceholders,
@@ -18,12 +20,13 @@
1820
SlideShapes,
1921
)
2022
from pptx.shared import ElementProxy, ParentedElementProxy, PartElementProxy
21-
from pptx.util import lazyproperty
23+
from pptx.util import Inches, Length, Pt, lazyproperty
2224

2325
if TYPE_CHECKING:
2426
from pptx.oxml.presentation import CT_SlideIdList, CT_SlideMasterIdList
2527
from pptx.oxml.slide import (
2628
CT_CommonSlideData,
29+
CT_HandoutMaster,
2730
CT_NotesMaster,
2831
CT_NotesSlide,
2932
CT_Slide,
@@ -34,6 +37,7 @@
3437
from pptx.parts.presentation import PresentationPart
3538
from pptx.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart
3639
from pptx.presentation import Presentation
40+
from pptx.shapes.autoshape import Shape
3741
from pptx.shapes.placeholder import LayoutPlaceholder, MasterPlaceholder, SlidePlaceholder
3842
from pptx.shapes.shapetree import NotesSlidePlaceholder
3943
from pptx.text.text import TextFrame
@@ -97,7 +101,7 @@ def shapes(self):
97101
class _HeaderFooterVisibility:
98102
"""Provides access to header/footer visibility settings on a slide template."""
99103

100-
_element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster
104+
_element: CT_SlideLayout | CT_SlideMaster | CT_NotesMaster | CT_HandoutMaster
101105

102106
def _get_hf_visibility(self, attr_name: str) -> bool:
103107
"""Return effective `attr_name` value, defaulting to |True| when `<p:hf>` is absent."""
@@ -178,6 +182,15 @@ class NotesMaster(_HeaderFooterVisibility, _BaseMaster):
178182
"""
179183

180184

185+
class HandoutMaster(_HeaderFooterVisibility, _BaseMaster):
186+
"""Proxy for the handout master XML document.
187+
188+
Provides access to shapes and header/footer visibility settings when a deck already
189+
contains a handout master. Auto-create is deliberately deferred until a built-in
190+
`handoutMaster.xml` template ships in this fork.
191+
"""
192+
193+
181194
class NotesSlide(_BaseSlide):
182195
"""Notes slide object.
183196
@@ -735,6 +748,39 @@ class SlideMaster(_HeaderFooterVisibility, _BaseMaster):
735748

736749
_element: CT_SlideMaster # pyright: ignore[reportIncompatibleVariableOverride]
737750

751+
def add_text_watermark(
752+
self,
753+
text: str,
754+
*,
755+
font_size: Length = Pt(72),
756+
transparency: float = 0.7,
757+
font_name: str = "Calibri",
758+
) -> Shape:
759+
"""Add and return a centered watermark textbox on this slide master.
760+
761+
The watermark is a large mid-gray text box sized for the standard 10in x 7.5in
762+
slide canvas. `transparency` is applied to the run's solid font fill.
763+
"""
764+
slide_width = Inches(10)
765+
slide_height = Inches(7.5)
766+
textbox_width = Inches(6)
767+
textbox_height = Inches(1.5)
768+
# ---center a 6in x 1.5in textbox on a standard 10in x 7.5in slide---
769+
left = (slide_width - textbox_width) // 2
770+
top = (slide_height - textbox_height) // 2
771+
772+
shape = self.shapes.add_textbox(left, top, textbox_width, textbox_height)
773+
paragraph = shape.text_frame.paragraphs[0]
774+
paragraph.text = text
775+
paragraph.alignment = PP_ALIGN.CENTER
776+
run = paragraph.runs[0]
777+
run.font.name = font_name
778+
run.font.size = font_size
779+
run.font.fill.solid()
780+
run.font.fill.fore_color.rgb = RGBColor(0x80, 0x80, 0x80)
781+
run.font.fill.transparency = transparency
782+
return shape
783+
738784
@lazyproperty
739785
def slide_layouts(self) -> SlideLayouts:
740786
"""|SlideLayouts| object providing access to this slide-master's layouts."""

tests/parts/test_presentation.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
from pptx.package import Package
1010
from pptx.parts.coreprops import CorePropertiesPart
1111
from pptx.parts.presentation import PresentationPart
12-
from pptx.parts.slide import NotesMasterPart, SlideMasterPart, SlidePart
12+
from pptx.parts.slide import HandoutMasterPart, NotesMasterPart, SlideMasterPart, SlidePart
1313
from pptx.presentation import Presentation
14-
from pptx.slide import NotesMaster, Slide, SlideLayout, SlideMaster
14+
from pptx.slide import HandoutMaster, NotesMaster, Slide, SlideLayout, SlideMaster
1515

1616
from ..unitutil.cxml import element
1717
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_):
8686

8787
assert prs_part.notes_master is notes_master_
8888

89+
def it_provides_access_to_an_existing_handout_master_part(
90+
self, handout_master_part_, part_related_by_
91+
):
92+
prs_part = PresentationPart(None, None, None, None)
93+
part_related_by_.return_value = handout_master_part_
94+
95+
handout_master_part = prs_part.handout_master_part
96+
97+
prs_part.part_related_by.assert_called_once_with(prs_part, RT.HANDOUT_MASTER)
98+
assert handout_master_part is handout_master_part_
99+
100+
def but_it_raises_ValueError_when_no_handout_master_is_present(self, part_related_by_):
101+
missing_rel = KeyError("rId99")
102+
part_related_by_.side_effect = missing_rel
103+
prs_part = PresentationPart(None, None, None, None)
104+
105+
with pytest.raises(ValueError, match="no handout master") as exc_info:
106+
prs_part.handout_master_part
107+
108+
assert exc_info.value.__cause__ is missing_rel
109+
110+
def it_provides_access_to_its_handout_master(self, request, handout_master_part_):
111+
handout_master_ = instance_mock(request, HandoutMaster)
112+
property_mock(
113+
request,
114+
PresentationPart,
115+
"handout_master_part",
116+
return_value=handout_master_part_,
117+
)
118+
handout_master_part_.handout_master = handout_master_
119+
prs_part = PresentationPart(None, None, None, None)
120+
121+
assert prs_part.handout_master is handout_master_
122+
89123
def it_provides_access_to_a_related_slide(self, request, slide_, related_part_):
90124
slide_part_ = instance_mock(request, SlidePart, slide=slide_)
91125
related_part_.return_value = slide_part_
@@ -192,6 +226,10 @@ def it_knows_the_next_slide_partname_to_help(self):
192226

193227
# fixture components ---------------------------------------------
194228

229+
@pytest.fixture
230+
def handout_master_part_(self, request):
231+
return instance_mock(request, HandoutMasterPart)
232+
195233
@pytest.fixture
196234
def notes_master_part_(self, request):
197235
return instance_mock(request, NotesMasterPart)

tests/parts/test_slide.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@
2222
from pptx.parts.presentation import PresentationPart
2323
from pptx.parts.slide import (
2424
BaseSlidePart,
25+
HandoutMasterPart,
2526
NotesMasterPart,
2627
NotesSlidePart,
2728
SlideLayoutPart,
2829
SlideMasterPart,
2930
SlidePart,
3031
)
31-
from pptx.slide import NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster
32+
from pptx.slide import HandoutMaster, NotesMaster, NotesSlide, Slide, SlideLayout, SlideMaster
3233

3334
from ..unitutil.cxml import element
3435
from ..unitutil.file import absjoin, test_file_dir
@@ -179,6 +180,34 @@ def theme_part_(self, request):
179180
return instance_mock(request, Part)
180181

181182

183+
class DescribeHandoutMasterPart(object):
184+
"""Unit-test suite for `pptx.parts.slide.HandoutMasterPart` objects."""
185+
186+
def it_is_a_BaseSlidePart_subclass(self):
187+
assert issubclass(HandoutMasterPart, BaseSlidePart)
188+
189+
def it_provides_access_to_its_handout_master(self, request):
190+
handout_master_ = instance_mock(request, HandoutMaster)
191+
HandoutMaster_ = class_mock(
192+
request, "pptx.parts.slide.HandoutMaster", return_value=handout_master_
193+
)
194+
handoutMaster = element("p:handoutMaster")
195+
handout_master_part = HandoutMasterPart(None, None, None, handoutMaster)
196+
197+
handout_master = handout_master_part.handout_master
198+
199+
HandoutMaster_.assert_called_once_with(handoutMaster, handout_master_part)
200+
assert handout_master is handout_master_
201+
202+
def it_has_no_create_default_classmethod(self):
203+
assert not hasattr(HandoutMasterPart, "create_default")
204+
205+
def it_can_be_constructed_as_a_handout_master_part(self):
206+
handout_master_part = HandoutMasterPart(None, None, None, element("p:handoutMaster"))
207+
208+
assert isinstance(handout_master_part, HandoutMasterPart)
209+
210+
182211
class DescribeNotesSlidePart(object):
183212
"""Unit-test suite for `pptx.parts.slide.NotesSlidePart` objects."""
184213

tests/test_presentation.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pptx.parts.presentation import PresentationPart
99
from pptx.parts.slide import NotesMasterPart
1010
from pptx.presentation import Presentation
11-
from pptx.slide import SlideLayouts, SlideMaster, SlideMasters, Slides
11+
from pptx.slide import HandoutMaster, SlideLayouts, SlideMaster, SlideMasters, Slides
1212

1313
from .unitutil.cxml import element, xml
1414
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):
4545
prs, notes_master_ = notes_master_fixture
4646
assert prs.notes_master is notes_master_
4747

48+
def it_provides_access_to_its_handout_master(self, handout_master_fixture):
49+
prs, handout_master_ = handout_master_fixture
50+
assert prs.handout_master is handout_master_
51+
4852
def it_provides_access_to_its_slides(self, slides_fixture):
4953
prs, rename_slide_parts_, rIds = slides_fixture[:3]
5054
Slides_, slides_, expected_xml = slides_fixture[3:]
@@ -90,6 +94,12 @@ def layouts_fixture(self, masters_prop_, slide_layouts_):
9094
masters_prop_.return_value.__getitem__.return_value.slide_layouts = slide_layouts_
9195
return prs, slide_layouts_
9296

97+
@pytest.fixture
98+
def handout_master_fixture(self, prs_part_, handout_master_):
99+
prs = Presentation(None, prs_part_)
100+
prs_part_.handout_master = handout_master_
101+
return prs, handout_master_
102+
93103
@pytest.fixture
94104
def master_fixture(self, masters_prop_, slide_master_):
95105
prs = Presentation(None, None)
@@ -194,6 +204,10 @@ def core_properties_(self, request):
194204
def masters_prop_(self, request):
195205
return property_mock(request, Presentation, "slide_masters")
196206

207+
@pytest.fixture
208+
def handout_master_(self, request):
209+
return instance_mock(request, HandoutMaster)
210+
197211
@pytest.fixture
198212
def notes_master_(self, request):
199213
return instance_mock(request, NotesMasterPart)

0 commit comments

Comments
 (0)