Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/pptx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -81,6 +83,7 @@
CorePropertiesPart,
CustomPropertiesPart,
CustomXmlPropertiesPart,
HandoutMasterPart,
ImagePart,
MediaPart,
SlidePart,
Expand Down
28 changes: 26 additions & 2 deletions src/pptx/parts/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
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

if TYPE_CHECKING:
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):
Expand Down Expand Up @@ -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):
"""
Expand Down
16 changes: 15 additions & 1 deletion src/pptx/parts/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 10 additions & 1 deletion src/pptx/presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions src/pptx/shapes/shapetree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 48 additions & 2 deletions src/pptx/slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 `<p:hf>` is absent."""
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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."""
Expand Down
42 changes: 40 additions & 2 deletions tests/parts/test_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 30 additions & 1 deletion tests/parts/test_slide.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
16 changes: 15 additions & 1 deletion tests/test_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading