Skip to content

Commit 72830b1

Browse files
authored
feat(shapes): Shape Effects, 3-D, Arrowheads & Connectors — issue #18 epic (#58)
Resolves the genuinely-missing surface of the issue #18 epic and adds issue-named convenience API over the sub-features this fork already shipped. One branch; nine sub-features. Investigation finding (recorded so the delta is auditable): issue #18 is written against upstream scanny/python-pptx, which is behind this fork. Arrowhead ends (full MSO_LINE_END_TYPE enum + LineFormat.begin/end_ arrowhead_* + oxml + 42 tests) and the experimental connector connect API were ALREADY shipped here; for those, scope was verify-no-regression plus thin issue-named aliases, not a rebuild. New (genuinely missing): - ShadowFormat.glow_effect / .reflection_effect / .soft_edge_effect — CT_GlowEffect / CT_ReflectionEffect / CT_SoftEdgesEffect mirroring the proven a:outerShdw pattern; wired into CT_EffectList in ECMA-376 schema order (blur,fillOverlay,glow,innerShdw,outerShdw,prstShdw, reflection,softEdge). softEdge/@RaD is RequiredAttribute per schema; glow always emits its mandatory EG_ColorChoice child. - Shape.scene_3d / Shape.shape_3d — CT_Scene3D / CT_Camera / CT_LightRig / CT_Shape3D. scene3d default emits BOTH the schema-required camera AND lightRig (camera-only → PowerPoint repair). Wired into spPr after effectLst, before extLst. - Shape.flip_horizontal / Shape.flip_vertical — delegate to the existing oxml flipH/flipV (creates a:xfrm in schema-safe order). - Shape.duplicate(insert_at_z=None) — deep-copy with sequential unique cNvPr id reassignment (max_shape_id+1+i, so a duplicated *group*'s children don't collide → no repair) and unique name; z-clamped so the clone never lands before grpSpPr. Pure-XML copy; relationship-backed shapes share the part (documented limitation). - Shape.slide_left/_top/_width/_height — additive, read-only world-space coordinates composing every enclosing p:grpSp affine outward (one recursion handles arbitrary nesting); identity fallback on degenerate chExt; never mutates stored xfrm; existing left/top semantics unchanged. Group rotation/flip intentionally not folded (matches COM Shape.Left). Issue-named aliases over shipped API (additive, no signature change): - LineFormat.head_end / .tail_end (.type/.width/.length) - Connector.start_connection / .end_connection Tests: 38 new unit tests in tests/test_issue18_shape_effects.py (glow 4, reflection 4, soft-edge 4, 3-D 4, head/tail 3, start/end 2, group-coords 5, flip 4, duplicate 8) + 10 behave scenarios in features/iss-18-shape-effects.feature. Per-class save→reopen round-trip coverage included. Trinity (verbatim): python3 -m pytest tests/ -q -> 3925 passed (3887 + 38) ruff check src tests -> All checks passed! python3 -m behave features/ --no-color -> 1119 scenarios, 0 failed (1109 + 10) UAT: uat/uat_issue18_shape_effects.py runs clean, 16/16 round-trip checks, exit 0. Interceptor screenshots of slides 1/2/4 in real PowerPoint show glow/reflection/soft-edge, a 3-D extruded box, and a flipped+duplicated arrow rendering with NO repair dialog (sheets_on_w1=0). Visual sign-off is the maintainer's call per §6a — not claimed here. Closes #18
1 parent d4a821c commit 72830b1

13 files changed

Lines changed: 1552 additions & 3 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
Feature: Issue #18 — shape effects, 3-D, arrowheads, flip, duplicate
2+
In order to author rich PowerPoint shapes that open without repair
3+
As a developer using python-pptx-extended
4+
I need glow / reflection / soft-edge effects, preset 3-D, flip,
5+
shape duplication, and the issue-named arrowhead / connector API
6+
7+
Scenario: Author and read back a glow color and radius
8+
Given a blank slide with one rectangle
9+
When I set the glow color to FF0000 and radius to 20pt
10+
Then the rectangle reports glow radius 20pt and color FF0000
11+
12+
Scenario: Author and read back a reflection
13+
Given a blank slide with one rectangle
14+
When I set the reflection blur radius to 3pt and distance to 7pt
15+
Then the rectangle reports reflection blur radius 3pt
16+
17+
Scenario: Author and read back a soft edge
18+
Given a blank slide with one rectangle
19+
When I set the soft edge radius to 5pt
20+
Then the rectangle reports soft edge radius 5pt
21+
22+
Scenario: Author and read back a preset 3-D camera
23+
Given a blank slide with one rectangle
24+
When I set the 3-D camera preset to orthographicFront
25+
Then the rectangle reports camera preset orthographicFront
26+
And the scene has a light rig
27+
28+
Scenario: Author and read back a 3-D extrusion
29+
Given a blank slide with one rectangle
30+
When I set the extrusion height to 18pt
31+
Then the rectangle reports extrusion height 18pt
32+
33+
Scenario: Flip a shape vertically and read it back after round-trip
34+
Given a blank slide with one rectangle
35+
When I flip the rectangle vertically and round-trip the file
36+
Then the reopened rectangle is flipped vertically
37+
38+
Scenario: Flip a shape horizontally
39+
Given a blank slide with one rectangle
40+
When I flip the rectangle horizontally
41+
Then the rectangle is flipped horizontally
42+
43+
Scenario: Duplicate a shape produces two distinct shapes
44+
Given a blank slide with one rectangle
45+
When I duplicate the rectangle
46+
Then the slide has two rectangles with distinct shape ids
47+
48+
Scenario: Arrow-ended connector via the head_end API round-trips
49+
Given a blank slide with one connector
50+
When I set the tail end arrowhead to a triangle and round-trip the file
51+
Then the reopened connector tail end is a triangle
52+
53+
Scenario: Group child reports correct world-space coordinates
54+
Given a group scaled two-to-one containing one rectangle
55+
Then the rectangle slide_width is double its local width

features/steps/iss18.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Step implementations for features/iss-18-shape-effects.feature (issue #18).
2+
3+
Self-contained: every scenario builds an in-memory blank presentation, so no
4+
fixture .pptx files are needed.
5+
"""
6+
7+
import io
8+
9+
from behave import given, then, when
10+
11+
from pptx import Presentation
12+
from pptx.dml.color import RGBColor
13+
from pptx.enum.dml import MSO_LINE_END_TYPE
14+
from pptx.enum.shapes import MSO_SHAPE
15+
from pptx.util import Emu, Inches, Pt
16+
17+
18+
def _blank():
19+
prs = Presentation()
20+
slide = prs.slides.add_slide(prs.slide_layouts[6])
21+
return prs, slide
22+
23+
24+
@given("a blank slide with one rectangle")
25+
def given_blank_slide_one_rectangle(context):
26+
context.prs, slide = _blank()
27+
context.shape = slide.shapes.add_shape(
28+
MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
29+
)
30+
context.slide = slide
31+
32+
33+
@given("a blank slide with one connector")
34+
def given_blank_slide_one_connector(context):
35+
context.prs, slide = _blank()
36+
context.shape = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(4), Inches(1))
37+
context.slide = slide
38+
39+
40+
@given("a group scaled two-to-one containing one rectangle")
41+
def given_group_scaled_two_to_one(context):
42+
context.prs, slide = _blank()
43+
group = slide.shapes.add_group_shape()
44+
context.shape = group.shapes.add_shape(
45+
MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(1), Inches(1)
46+
)
47+
x = group._element.grpSpPr.get_or_add_xfrm()
48+
x.get_or_add_off().x = Emu(0)
49+
x.get_or_add_off().y = Emu(0)
50+
x.get_or_add_ext().cx = Emu(Inches(4))
51+
x.get_or_add_ext().cy = Emu(Inches(4))
52+
x.get_or_add_chOff().x = Emu(0)
53+
x.get_or_add_chOff().y = Emu(0)
54+
x.get_or_add_chExt().cx = Emu(Inches(2))
55+
x.get_or_add_chExt().cy = Emu(Inches(2))
56+
57+
58+
@when("I set the glow color to FF0000 and radius to 20pt")
59+
def when_set_glow(context):
60+
context.shape.shadow.glow_effect.color.rgb = RGBColor(0xFF, 0, 0)
61+
context.shape.shadow.glow_effect.radius = Pt(20)
62+
63+
64+
@then("the rectangle reports glow radius 20pt and color FF0000")
65+
def then_glow(context):
66+
assert context.shape.shadow.glow_effect.radius == Emu(Pt(20))
67+
assert context.shape.shadow.glow_effect.color.rgb == RGBColor(0xFF, 0, 0)
68+
69+
70+
@when("I set the reflection blur radius to 3pt and distance to 7pt")
71+
def when_set_reflection(context):
72+
context.shape.shadow.reflection_effect.blur_radius = Pt(3)
73+
context.shape.shadow.reflection_effect.distance = Pt(7)
74+
75+
76+
@then("the rectangle reports reflection blur radius 3pt")
77+
def then_reflection(context):
78+
assert context.shape.shadow.reflection_effect.blur_radius == Emu(Pt(3))
79+
80+
81+
@when("I set the soft edge radius to 5pt")
82+
def when_set_soft_edge(context):
83+
context.shape.shadow.soft_edge_effect.radius = Pt(5)
84+
85+
86+
@then("the rectangle reports soft edge radius 5pt")
87+
def then_soft_edge(context):
88+
assert context.shape.shadow.soft_edge_effect.radius == Emu(Pt(5))
89+
90+
91+
@when("I set the 3-D camera preset to orthographicFront")
92+
def when_set_camera(context):
93+
context.shape.scene_3d.camera_preset = "orthographicFront"
94+
95+
96+
@then("the rectangle reports camera preset orthographicFront")
97+
def then_camera(context):
98+
assert context.shape.scene_3d.camera_preset == "orthographicFront"
99+
100+
101+
@then("the scene has a light rig")
102+
def then_lightrig(context):
103+
a = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
104+
s3d = context.shape._element.spPr.find(f"{a}scene3d")
105+
assert s3d.find(f"{a}lightRig") is not None
106+
107+
108+
@when("I set the extrusion height to 18pt")
109+
def when_set_extrusion(context):
110+
context.shape.shape_3d.extrusion_height = Pt(18)
111+
112+
113+
@then("the rectangle reports extrusion height 18pt")
114+
def then_extrusion(context):
115+
assert context.shape.shape_3d.extrusion_height == Emu(Pt(18))
116+
117+
118+
@when("I flip the rectangle vertically and round-trip the file")
119+
def when_flip_v_roundtrip(context):
120+
context.shape.flip_vertical = True
121+
buf = io.BytesIO()
122+
context.prs.save(buf)
123+
buf.seek(0)
124+
context.prs2 = Presentation(buf)
125+
126+
127+
@then("the reopened rectangle is flipped vertically")
128+
def then_flipped_v(context):
129+
shp = [
130+
s
131+
for s in context.prs2.slides[0].shapes
132+
if s.shape_type is not None and "AUTO_SHAPE" in str(s.shape_type)
133+
][0]
134+
assert shp.flip_vertical is True
135+
136+
137+
@when("I flip the rectangle horizontally")
138+
def when_flip_h(context):
139+
context.shape.flip_horizontal = True
140+
141+
142+
@then("the rectangle is flipped horizontally")
143+
def then_flipped_h(context):
144+
assert context.shape.flip_horizontal is True
145+
146+
147+
@when("I duplicate the rectangle")
148+
def when_duplicate(context):
149+
context.dup = context.shape.duplicate()
150+
151+
152+
@then("the slide has two rectangles with distinct shape ids")
153+
def then_two_rectangles(context):
154+
ids = [s.shape_id for s in context.slide.shapes]
155+
assert len(ids) == 2
156+
assert len(set(ids)) == 2
157+
158+
159+
@when("I set the tail end arrowhead to a triangle and round-trip the file")
160+
def when_tail_triangle_roundtrip(context):
161+
context.shape.line.tail_end.type = MSO_LINE_END_TYPE.TRIANGLE
162+
buf = io.BytesIO()
163+
context.prs.save(buf)
164+
buf.seek(0)
165+
context.prs2 = Presentation(buf)
166+
167+
168+
@then("the reopened connector tail end is a triangle")
169+
def then_tail_triangle(context):
170+
conn = list(context.prs2.slides[0].shapes)[0]
171+
assert conn.line.tail_end.type == MSO_LINE_END_TYPE.TRIANGLE
172+
173+
174+
@then("the rectangle slide_width is double its local width")
175+
def then_world_width_double(context):
176+
assert context.shape.slide_width == Emu(context.shape.width * 2)

0 commit comments

Comments
 (0)