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."""