Skip to content

Commit 14ee64c

Browse files
authored
Merge pull request #6 from MHoroszowski/feature/shadow-effects
feature: add full shadow effect API to ShadowFormat
2 parents 21353a1 + 72dc01a commit 14ee64c

4 files changed

Lines changed: 318 additions & 0 deletions

File tree

src/pptx/dml/effect.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
7+
from pptx.dml.color import ColorFormat
8+
from pptx.util import Emu, lazyproperty
9+
10+
if TYPE_CHECKING:
11+
from pptx.oxml.dml.effect import CT_OuterShadowEffect
12+
from pptx.util import Length
13+
514

615
class ShadowFormat(object):
716
"""Provides access to shadow effect on a shape."""
@@ -10,6 +19,71 @@ def __init__(self, spPr):
1019
# ---spPr may also be a grpSpPr; both have a:effectLst child---
1120
self._element = spPr
1221

22+
@property
23+
def angle(self) -> float | None:
24+
"""Direction of shadow in degrees (0 = right, 90 = below, etc.).
25+
26+
Read/write. Returns |None| if no shadow is explicitly defined. Setting this property
27+
creates an outer shadow if one doesn't exist.
28+
"""
29+
outerShdw = self._outerShdw
30+
if outerShdw is None:
31+
return None
32+
return outerShdw.dir
33+
34+
@angle.setter
35+
def angle(self, value: float | None) -> None:
36+
if value is None:
37+
return
38+
outerShdw = self._get_or_add_outerShdw()
39+
outerShdw.dir = value
40+
41+
@property
42+
def blur_radius(self) -> Length | None:
43+
"""Blur radius of shadow in EMU.
44+
45+
Read/write. Returns |None| if no shadow is explicitly defined.
46+
"""
47+
outerShdw = self._outerShdw
48+
if outerShdw is None:
49+
return None
50+
return Emu(outerShdw.blurRad)
51+
52+
@blur_radius.setter
53+
def blur_radius(self, value: Length | None) -> None:
54+
if value is None:
55+
return
56+
outerShdw = self._get_or_add_outerShdw()
57+
outerShdw.blurRad = int(value)
58+
59+
@lazyproperty
60+
def color(self) -> ColorFormat:
61+
"""Color of the shadow.
62+
63+
Returns a |ColorFormat| object. Setting color properties creates an outer shadow with a
64+
solid color fill if one doesn't exist.
65+
"""
66+
outerShdw = self._get_or_add_outerShdw()
67+
return ColorFormat.from_colorchoice_parent(outerShdw)
68+
69+
@property
70+
def distance(self) -> Length | None:
71+
"""Distance of shadow from shape in EMU.
72+
73+
Read/write. Returns |None| if no shadow is explicitly defined.
74+
"""
75+
outerShdw = self._outerShdw
76+
if outerShdw is None:
77+
return None
78+
return Emu(outerShdw.dist)
79+
80+
@distance.setter
81+
def distance(self, value: Length | None) -> None:
82+
if value is None:
83+
return
84+
outerShdw = self._get_or_add_outerShdw()
85+
outerShdw.dist = int(value)
86+
1387
@property
1488
def inherit(self):
1589
"""True if shape inherits shadow settings.
@@ -39,3 +113,52 @@ def inherit(self, value):
39113
else:
40114
# ---ensure at least the effectLst element is present
41115
self._element.get_or_add_effectLst()
116+
117+
@property
118+
def rotate_with_shape(self) -> bool | None:
119+
"""Whether the shadow rotates with the shape.
120+
121+
Read/write. Returns |None| if no shadow is explicitly defined.
122+
"""
123+
outerShdw = self._outerShdw
124+
if outerShdw is None:
125+
return None
126+
return outerShdw.rotWithShape
127+
128+
@rotate_with_shape.setter
129+
def rotate_with_shape(self, value: bool | None) -> None:
130+
if value is None:
131+
return
132+
outerShdw = self._get_or_add_outerShdw()
133+
outerShdw.rotWithShape = value
134+
135+
@property
136+
def visible(self) -> bool:
137+
"""Whether a shadow is visible on this shape.
138+
139+
Read/write. Returns |True| if an outer shadow element is present. Assigning |True| creates
140+
a default outer shadow. Assigning |False| removes any outer shadow.
141+
"""
142+
return self._outerShdw is not None
143+
144+
@visible.setter
145+
def visible(self, value: bool) -> None:
146+
if value:
147+
self._get_or_add_outerShdw()
148+
else:
149+
effectLst = self._element.effectLst
150+
if effectLst is not None:
151+
effectLst._remove_outerShdw()
152+
153+
def _get_or_add_outerShdw(self) -> CT_OuterShadowEffect:
154+
"""Return the `a:outerShdw` element, creating parent elements as needed."""
155+
effectLst = self._element.get_or_add_effectLst()
156+
return effectLst.get_or_add_outerShdw()
157+
158+
@property
159+
def _outerShdw(self) -> CT_OuterShadowEffect | None:
160+
"""Return `a:outerShdw` element or None if not present."""
161+
effectLst = self._element.effectLst
162+
if effectLst is None:
163+
return None
164+
return effectLst.outerShdw

src/pptx/oxml/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,17 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
267267
register_element_cls("a:srcRect", CT_RelativeRect)
268268

269269

270+
from pptx.oxml.dml.effect import ( # noqa: E402
271+
CT_EffectList,
272+
CT_InnerShadowEffect,
273+
CT_OuterShadowEffect,
274+
)
275+
276+
register_element_cls("a:effectLst", CT_EffectList)
277+
register_element_cls("a:innerShdw", CT_InnerShadowEffect)
278+
register_element_cls("a:outerShdw", CT_OuterShadowEffect)
279+
280+
270281
from pptx.oxml.dml.line import CT_PresetLineDashProperties # noqa: E402
271282

272283
register_element_cls("a:prstDash", CT_PresetLineDashProperties)

src/pptx/oxml/dml/effect.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""lxml custom element classes for DrawingML effect-related XML elements."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Callable, cast
6+
7+
from pptx.oxml import parse_xml
8+
from pptx.oxml.ns import nsdecls
9+
from pptx.oxml.simpletypes import ST_Angle, ST_PositiveCoordinate, XsdBoolean
10+
from pptx.oxml.xmlchemy import (
11+
BaseOxmlElement,
12+
Choice,
13+
OptionalAttribute,
14+
ZeroOrOne,
15+
ZeroOrOneChoice,
16+
)
17+
18+
19+
class CT_OuterShadowEffect(BaseOxmlElement):
20+
"""`a:outerShdw` custom element class."""
21+
22+
eg_colorChoice = ZeroOrOneChoice(
23+
(
24+
Choice("a:scrgbClr"),
25+
Choice("a:srgbClr"),
26+
Choice("a:hslClr"),
27+
Choice("a:sysClr"),
28+
Choice("a:schemeClr"),
29+
Choice("a:prstClr"),
30+
),
31+
successors=(),
32+
)
33+
blurRad = OptionalAttribute("blurRad", ST_PositiveCoordinate, default=0)
34+
dist = OptionalAttribute("dist", ST_PositiveCoordinate, default=0)
35+
dir = OptionalAttribute("dir", ST_Angle, default=0)
36+
rotWithShape: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType]
37+
"rotWithShape", XsdBoolean, default=True
38+
)
39+
40+
41+
class CT_InnerShadowEffect(BaseOxmlElement):
42+
"""`a:innerShdw` custom element class."""
43+
44+
blurRad = OptionalAttribute("blurRad", ST_PositiveCoordinate, default=0)
45+
dist = OptionalAttribute("dist", ST_PositiveCoordinate, default=0)
46+
dir = OptionalAttribute("dir", ST_Angle, default=0)
47+
48+
49+
class CT_EffectList(BaseOxmlElement):
50+
"""`a:effectLst` custom element class."""
51+
52+
get_or_add_outerShdw: Callable[[], CT_OuterShadowEffect]
53+
_remove_outerShdw: Callable[[], None]
54+
_remove_innerShdw: Callable[[], None]
55+
56+
_tag_seq = ("a:blur", "a:fillOverlay", "a:glow", "a:innerShdw", "a:outerShdw",
57+
"a:prstShdw", "a:reflection", "a:softEdge")
58+
innerShdw: CT_InnerShadowEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
59+
"a:innerShdw", successors=_tag_seq[4:]
60+
)
61+
outerShdw: CT_OuterShadowEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
62+
"a:outerShdw", successors=_tag_seq[5:]
63+
)
64+
del _tag_seq
65+
66+
def _new_outerShdw(self) -> CT_OuterShadowEffect:
67+
"""Return a new `a:outerShdw` element with default shadow properties.
68+
69+
PowerPoint requires a color child element on `a:outerShdw`, so this provides a reasonable
70+
default: 45-degree angle, 3pt distance, 4pt blur, 40% transparent black.
71+
"""
72+
return cast(
73+
CT_OuterShadowEffect,
74+
parse_xml(
75+
f'<a:outerShdw {nsdecls("a")} blurRad="50800" dist="38100"'
76+
f' dir="2700000" algn="tl" rotWithShape="0">'
77+
f' <a:srgbClr val="000000">'
78+
f' <a:alpha val="40000"/>'
79+
f" </a:srgbClr>"
80+
f"</a:outerShdw>"
81+
),
82+
)

tests/dml/test_effect.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,114 @@
44

55
import pytest
66

7+
from pptx.dml.color import ColorFormat
78
from pptx.dml.effect import ShadowFormat
9+
from pptx.util import Emu, Pt
810

911
from ..unitutil.cxml import element, xml
1012

1113

1214
class DescribeShadowFormat(object):
15+
@pytest.mark.parametrize(
16+
("spPr_cxml", "expected_value"),
17+
[
18+
("p:spPr", False),
19+
("p:spPr/a:effectLst", False),
20+
("p:spPr/a:effectLst/a:outerShdw", True),
21+
],
22+
)
23+
def it_knows_whether_a_shadow_is_visible(self, spPr_cxml: str, expected_value: bool):
24+
shadow = ShadowFormat(element(spPr_cxml))
25+
assert shadow.visible is expected_value
26+
27+
def it_can_make_a_shadow_visible(self):
28+
spPr = element("p:spPr")
29+
shadow = ShadowFormat(spPr)
30+
shadow.visible = True
31+
assert shadow.visible is True
32+
assert spPr.effectLst is not None
33+
assert spPr.effectLst.outerShdw is not None
34+
35+
def it_can_make_a_shadow_invisible(self):
36+
spPr = element("p:spPr/a:effectLst/a:outerShdw")
37+
shadow = ShadowFormat(spPr)
38+
shadow.visible = False
39+
assert shadow.visible is False
40+
41+
@pytest.mark.parametrize(
42+
("spPr_cxml", "expected_value"),
43+
[
44+
("p:spPr", None),
45+
("p:spPr/a:effectLst", None),
46+
("p:spPr/a:effectLst/a:outerShdw", 0.0),
47+
("p:spPr/a:effectLst/a:outerShdw{dir=2700000}", 45.0),
48+
("p:spPr/a:effectLst/a:outerShdw{dir=5400000}", 90.0),
49+
],
50+
)
51+
def it_knows_the_shadow_angle(self, spPr_cxml: str, expected_value: float | None):
52+
shadow = ShadowFormat(element(spPr_cxml))
53+
assert shadow.angle == expected_value
54+
55+
def it_can_set_the_shadow_angle(self):
56+
spPr = element("p:spPr")
57+
shadow = ShadowFormat(spPr)
58+
shadow.angle = 45.0
59+
assert shadow.angle == 45.0
60+
61+
@pytest.mark.parametrize(
62+
("spPr_cxml", "expected_value"),
63+
[
64+
("p:spPr", None),
65+
("p:spPr/a:effectLst/a:outerShdw", 0),
66+
("p:spPr/a:effectLst/a:outerShdw{blurRad=50800}", 50800),
67+
],
68+
)
69+
def it_knows_the_blur_radius(self, spPr_cxml: str, expected_value: int | None):
70+
shadow = ShadowFormat(element(spPr_cxml))
71+
assert shadow.blur_radius == expected_value
72+
73+
def it_can_set_the_blur_radius(self):
74+
spPr = element("p:spPr")
75+
shadow = ShadowFormat(spPr)
76+
shadow.blur_radius = Pt(4)
77+
assert shadow.blur_radius == Pt(4)
78+
79+
@pytest.mark.parametrize(
80+
("spPr_cxml", "expected_value"),
81+
[
82+
("p:spPr", None),
83+
("p:spPr/a:effectLst/a:outerShdw", 0),
84+
("p:spPr/a:effectLst/a:outerShdw{dist=38100}", 38100),
85+
],
86+
)
87+
def it_knows_the_shadow_distance(self, spPr_cxml: str, expected_value: int | None):
88+
shadow = ShadowFormat(element(spPr_cxml))
89+
assert shadow.distance == expected_value
90+
91+
def it_can_set_the_shadow_distance(self):
92+
spPr = element("p:spPr")
93+
shadow = ShadowFormat(spPr)
94+
shadow.distance = Pt(3)
95+
assert shadow.distance == Pt(3)
96+
97+
def it_provides_access_to_the_shadow_color(self):
98+
spPr = element("p:spPr")
99+
shadow = ShadowFormat(spPr)
100+
assert isinstance(shadow.color, ColorFormat)
101+
102+
@pytest.mark.parametrize(
103+
("spPr_cxml", "expected_value"),
104+
[
105+
("p:spPr", None),
106+
("p:spPr/a:effectLst/a:outerShdw", True),
107+
("p:spPr/a:effectLst/a:outerShdw{rotWithShape=0}", False),
108+
("p:spPr/a:effectLst/a:outerShdw{rotWithShape=1}", True),
109+
],
110+
)
111+
def it_knows_rotate_with_shape(self, spPr_cxml: str, expected_value: bool | None):
112+
shadow = ShadowFormat(element(spPr_cxml))
113+
assert shadow.rotate_with_shape == expected_value
114+
13115
def it_knows_whether_it_inherits(self, inherit_get_fixture):
14116
shadow, expected_value = inherit_get_fixture
15117
inherit = shadow.inherit

0 commit comments

Comments
 (0)