diff --git a/features/iss-18-shape-effects.feature b/features/iss-18-shape-effects.feature new file mode 100644 index 000000000..d0fdfd30e --- /dev/null +++ b/features/iss-18-shape-effects.feature @@ -0,0 +1,55 @@ +Feature: Issue #18 — shape effects, 3-D, arrowheads, flip, duplicate + In order to author rich PowerPoint shapes that open without repair + As a developer using python-pptx-extended + I need glow / reflection / soft-edge effects, preset 3-D, flip, + shape duplication, and the issue-named arrowhead / connector API + + Scenario: Author and read back a glow color and radius + Given a blank slide with one rectangle + When I set the glow color to FF0000 and radius to 20pt + Then the rectangle reports glow radius 20pt and color FF0000 + + Scenario: Author and read back a reflection + Given a blank slide with one rectangle + When I set the reflection blur radius to 3pt and distance to 7pt + Then the rectangle reports reflection blur radius 3pt + + Scenario: Author and read back a soft edge + Given a blank slide with one rectangle + When I set the soft edge radius to 5pt + Then the rectangle reports soft edge radius 5pt + + Scenario: Author and read back a preset 3-D camera + Given a blank slide with one rectangle + When I set the 3-D camera preset to orthographicFront + Then the rectangle reports camera preset orthographicFront + And the scene has a light rig + + Scenario: Author and read back a 3-D extrusion + Given a blank slide with one rectangle + When I set the extrusion height to 18pt + Then the rectangle reports extrusion height 18pt + + Scenario: Flip a shape vertically and read it back after round-trip + Given a blank slide with one rectangle + When I flip the rectangle vertically and round-trip the file + Then the reopened rectangle is flipped vertically + + Scenario: Flip a shape horizontally + Given a blank slide with one rectangle + When I flip the rectangle horizontally + Then the rectangle is flipped horizontally + + Scenario: Duplicate a shape produces two distinct shapes + Given a blank slide with one rectangle + When I duplicate the rectangle + Then the slide has two rectangles with distinct shape ids + + Scenario: Arrow-ended connector via the head_end API round-trips + Given a blank slide with one connector + When I set the tail end arrowhead to a triangle and round-trip the file + Then the reopened connector tail end is a triangle + + Scenario: Group child reports correct world-space coordinates + Given a group scaled two-to-one containing one rectangle + Then the rectangle slide_width is double its local width diff --git a/features/steps/iss18.py b/features/steps/iss18.py new file mode 100644 index 000000000..1e4efb0a3 --- /dev/null +++ b/features/steps/iss18.py @@ -0,0 +1,176 @@ +"""Step implementations for features/iss-18-shape-effects.feature (issue #18). + +Self-contained: every scenario builds an in-memory blank presentation, so no +fixture .pptx files are needed. +""" + +import io + +from behave import given, then, when + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.dml import MSO_LINE_END_TYPE +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Emu, Inches, Pt + + +def _blank(): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + return prs, slide + + +@given("a blank slide with one rectangle") +def given_blank_slide_one_rectangle(context): + context.prs, slide = _blank() + context.shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1) + ) + context.slide = slide + + +@given("a blank slide with one connector") +def given_blank_slide_one_connector(context): + context.prs, slide = _blank() + context.shape = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(4), Inches(1)) + context.slide = slide + + +@given("a group scaled two-to-one containing one rectangle") +def given_group_scaled_two_to_one(context): + context.prs, slide = _blank() + group = slide.shapes.add_group_shape() + context.shape = group.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(1), Inches(1) + ) + x = group._element.grpSpPr.get_or_add_xfrm() + x.get_or_add_off().x = Emu(0) + x.get_or_add_off().y = Emu(0) + x.get_or_add_ext().cx = Emu(Inches(4)) + x.get_or_add_ext().cy = Emu(Inches(4)) + x.get_or_add_chOff().x = Emu(0) + x.get_or_add_chOff().y = Emu(0) + x.get_or_add_chExt().cx = Emu(Inches(2)) + x.get_or_add_chExt().cy = Emu(Inches(2)) + + +@when("I set the glow color to FF0000 and radius to 20pt") +def when_set_glow(context): + context.shape.shadow.glow_effect.color.rgb = RGBColor(0xFF, 0, 0) + context.shape.shadow.glow_effect.radius = Pt(20) + + +@then("the rectangle reports glow radius 20pt and color FF0000") +def then_glow(context): + assert context.shape.shadow.glow_effect.radius == Emu(Pt(20)) + assert context.shape.shadow.glow_effect.color.rgb == RGBColor(0xFF, 0, 0) + + +@when("I set the reflection blur radius to 3pt and distance to 7pt") +def when_set_reflection(context): + context.shape.shadow.reflection_effect.blur_radius = Pt(3) + context.shape.shadow.reflection_effect.distance = Pt(7) + + +@then("the rectangle reports reflection blur radius 3pt") +def then_reflection(context): + assert context.shape.shadow.reflection_effect.blur_radius == Emu(Pt(3)) + + +@when("I set the soft edge radius to 5pt") +def when_set_soft_edge(context): + context.shape.shadow.soft_edge_effect.radius = Pt(5) + + +@then("the rectangle reports soft edge radius 5pt") +def then_soft_edge(context): + assert context.shape.shadow.soft_edge_effect.radius == Emu(Pt(5)) + + +@when("I set the 3-D camera preset to orthographicFront") +def when_set_camera(context): + context.shape.scene_3d.camera_preset = "orthographicFront" + + +@then("the rectangle reports camera preset orthographicFront") +def then_camera(context): + assert context.shape.scene_3d.camera_preset == "orthographicFront" + + +@then("the scene has a light rig") +def then_lightrig(context): + a = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + s3d = context.shape._element.spPr.find(f"{a}scene3d") + assert s3d.find(f"{a}lightRig") is not None + + +@when("I set the extrusion height to 18pt") +def when_set_extrusion(context): + context.shape.shape_3d.extrusion_height = Pt(18) + + +@then("the rectangle reports extrusion height 18pt") +def then_extrusion(context): + assert context.shape.shape_3d.extrusion_height == Emu(Pt(18)) + + +@when("I flip the rectangle vertically and round-trip the file") +def when_flip_v_roundtrip(context): + context.shape.flip_vertical = True + buf = io.BytesIO() + context.prs.save(buf) + buf.seek(0) + context.prs2 = Presentation(buf) + + +@then("the reopened rectangle is flipped vertically") +def then_flipped_v(context): + shp = [ + s + for s in context.prs2.slides[0].shapes + if s.shape_type is not None and "AUTO_SHAPE" in str(s.shape_type) + ][0] + assert shp.flip_vertical is True + + +@when("I flip the rectangle horizontally") +def when_flip_h(context): + context.shape.flip_horizontal = True + + +@then("the rectangle is flipped horizontally") +def then_flipped_h(context): + assert context.shape.flip_horizontal is True + + +@when("I duplicate the rectangle") +def when_duplicate(context): + context.dup = context.shape.duplicate() + + +@then("the slide has two rectangles with distinct shape ids") +def then_two_rectangles(context): + ids = [s.shape_id for s in context.slide.shapes] + assert len(ids) == 2 + assert len(set(ids)) == 2 + + +@when("I set the tail end arrowhead to a triangle and round-trip the file") +def when_tail_triangle_roundtrip(context): + context.shape.line.tail_end.type = MSO_LINE_END_TYPE.TRIANGLE + buf = io.BytesIO() + context.prs.save(buf) + buf.seek(0) + context.prs2 = Presentation(buf) + + +@then("the reopened connector tail end is a triangle") +def then_tail_triangle(context): + conn = list(context.prs2.slides[0].shapes)[0] + assert conn.line.tail_end.type == MSO_LINE_END_TYPE.TRIANGLE + + +@then("the rectangle slide_width is double its local width") +def then_world_width_double(context): + assert context.shape.slide_width == Emu(context.shape.width * 2) diff --git a/src/pptx/dml/effect.py b/src/pptx/dml/effect.py index 980abd3fe..b0e5c1ef8 100644 --- a/src/pptx/dml/effect.py +++ b/src/pptx/dml/effect.py @@ -8,7 +8,12 @@ from pptx.util import Emu, lazyproperty if TYPE_CHECKING: - from pptx.oxml.dml.effect import CT_OuterShadowEffect + from pptx.oxml.dml.effect import ( + CT_GlowEffect, + CT_OuterShadowEffect, + CT_ReflectionEffect, + CT_SoftEdgesEffect, + ) from pptx.util import Length @@ -160,3 +165,212 @@ def _outerShdw(self) -> CT_OuterShadowEffect | None: if effectLst is None: return None return effectLst.outerShdw + + @lazyproperty + def glow_effect(self) -> GlowEffect: + """|GlowEffect| object providing access to the shape's glow effect. + + A |GlowEffect| object is always returned, even when no glow is + explicitly defined on this shape. Setting a glow property (color or + radius) creates the `a:glow` element on demand, mirroring the way + :attr:`color` creates an outer shadow. Separate from the + already-shipped outer-shadow API (issue #18 SF1). + """ + return GlowEffect(self._element) + + @lazyproperty + def reflection_effect(self) -> ReflectionEffect: + """|ReflectionEffect| object providing access to the reflection effect. + + Always returned; setting a property creates `a:reflection` on demand + (issue #18 SF2). + """ + return ReflectionEffect(self._element) + + @lazyproperty + def soft_edge_effect(self) -> SoftEdgeEffect: + """|SoftEdgeEffect| object providing access to the soft-edge effect. + + Always returned; setting the radius creates `a:softEdge` on demand + (issue #18 SF3). + """ + return SoftEdgeEffect(self._element) + + +class GlowEffect(object): + """Provides access to the glow effect (`a:glow`) on a shape. + + Accessed via :attr:`ShadowFormat.glow_effect`. Mirrors the lazy-create + semantics of |ShadowFormat|: the `a:glow` element (and its mandatory + color child + enclosing `a:effectLst`) is created only when a property + is assigned. + """ + + def __init__(self, spPr): + # ---spPr may also be a grpSpPr; both have an a:effectLst child--- + self._element = spPr + + @property + def visible(self) -> bool: + """|True| if an `a:glow` element is present on this shape.""" + return self._glow is not None + + @lazyproperty + def color(self) -> ColorFormat: + """Color of the glow as a |ColorFormat|. + + Accessing (or setting) this creates an `a:glow` with a default color + child if one doesn't exist, just as :attr:`ShadowFormat.color` does + for the outer shadow. + """ + return ColorFormat.from_colorchoice_parent(self._get_or_add_glow()) + + @property + def radius(self) -> Length | None: + """Glow radius as a |Length|, or |None| if no glow is defined.""" + glow = self._glow + if glow is None: + return None + return Emu(glow.rad) + + @radius.setter + def radius(self, value: Length | None) -> None: + if value is None: + return + self._get_or_add_glow().rad = int(value) + + def _get_or_add_glow(self) -> CT_GlowEffect: + return self._element.get_or_add_effectLst().get_or_add_glow() + + @property + def _glow(self) -> CT_GlowEffect | None: + effectLst = self._element.effectLst + if effectLst is None: + return None + return effectLst.glow + + +class ReflectionEffect(object): + """Provides access to the reflection effect (`a:reflection`) on a shape. + + Accessed via :attr:`ShadowFormat.reflection_effect`. + """ + + def __init__(self, spPr): + self._element = spPr + + @property + def visible(self) -> bool: + """|True| if an `a:reflection` element is present.""" + return self._reflection is not None + + @visible.setter + def visible(self, value: bool) -> None: + if value: + self._get_or_add_reflection() + else: + effectLst = self._element.effectLst + if effectLst is not None: + effectLst._remove_reflection() + + @property + def blur_radius(self) -> Length | None: + """Reflection blur radius as a |Length|, or |None| if not defined.""" + reflection = self._reflection + if reflection is None: + return None + return Emu(reflection.blurRad) + + @blur_radius.setter + def blur_radius(self, value: Length | None) -> None: + if value is None: + return + self._get_or_add_reflection().blurRad = int(value) + + @property + def distance(self) -> Length | None: + """Distance of the reflection from the shape, |None| if not defined.""" + reflection = self._reflection + if reflection is None: + return None + return Emu(reflection.dist) + + @distance.setter + def distance(self, value: Length | None) -> None: + if value is None: + return + self._get_or_add_reflection().dist = int(value) + + @property + def direction(self) -> float | None: + """Direction of the reflection in degrees, |None| if not defined.""" + reflection = self._reflection + if reflection is None: + return None + return reflection.dir + + @direction.setter + def direction(self, value: float | None) -> None: + if value is None: + return + self._get_or_add_reflection().dir = value + + def _get_or_add_reflection(self) -> CT_ReflectionEffect: + return self._element.get_or_add_effectLst().get_or_add_reflection() + + @property + def _reflection(self) -> CT_ReflectionEffect | None: + effectLst = self._element.effectLst + if effectLst is None: + return None + return effectLst.reflection + + +class SoftEdgeEffect(object): + """Provides access to the soft-edge effect (`a:softEdge`) on a shape. + + Accessed via :attr:`ShadowFormat.soft_edge_effect`. The schema makes + `rad` required, so creating a soft edge always sets a radius (a sensible + 2.5pt default until overwritten). + """ + + def __init__(self, spPr): + self._element = spPr + + @property + def visible(self) -> bool: + """|True| if an `a:softEdge` element is present.""" + return self._softEdge is not None + + @visible.setter + def visible(self, value: bool) -> None: + if value: + self._get_or_add_softEdge() + else: + effectLst = self._element.effectLst + if effectLst is not None: + effectLst._remove_softEdge() + + @property + def radius(self) -> Length | None: + """Soft-edge feather radius as a |Length|, |None| if not defined.""" + softEdge = self._softEdge + if softEdge is None: + return None + return Emu(softEdge.rad) + + @radius.setter + def radius(self, value: Length | None) -> None: + if value is None: + return + self._get_or_add_softEdge().rad = int(value) + + def _get_or_add_softEdge(self) -> CT_SoftEdgesEffect: + return self._element.get_or_add_effectLst().get_or_add_softEdge() + + @property + def _softEdge(self) -> CT_SoftEdgesEffect | None: + effectLst = self._element.effectLst + if effectLst is None: + return None + return effectLst.softEdge diff --git a/src/pptx/dml/line.py b/src/pptx/dml/line.py index 119224865..6a66b6468 100644 --- a/src/pptx/dml/line.py +++ b/src/pptx/dml/line.py @@ -309,3 +309,79 @@ def _tailEnd(self) -> CT_LineEndProperties | None: if ln is None: return None return ln.tailEnd + + @lazyproperty + def head_end(self) -> "_LineEndFormat": + """Arrowhead at the **begin** point of the line (issue #18 SF5). + + Returns a |_LineEndFormat| exposing ``type``, ``width`` and + ``length`` as a single object. This is the issue-#18-named + convenience surface over the already-shipped + ``begin_arrowhead_style``/``_width``/``_length`` properties — those + remain unchanged. + """ + return _LineEndFormat(self, "head") + + @lazyproperty + def tail_end(self) -> "_LineEndFormat": + """Arrowhead at the **end** point of the line (issue #18 SF5). + + Issue-named convenience surface over the shipped + ``end_arrowhead_*`` properties; those remain unchanged. + """ + return _LineEndFormat(self, "tail") + + +class _LineEndFormat(object): + """Grouped `type` / `width` / `length` view of one line arrowhead end. + + Backed entirely by the existing `LineFormat` arrowhead properties — this + adds the issue-#18 `head_end`/`tail_end` shape without changing or + duplicating the proven `begin/end_arrowhead_*` implementation. + """ + + def __init__(self, line: "LineFormat", which: str): + self._line = line + self._which = which # "head" or "tail" + + @property + def type(self) -> MSO_LINE_END_TYPE | None: + """Arrowhead style — a member of :ref:`MsoArrowheadStyle` or |None|.""" + if self._which == "head": + return self._line.begin_arrowhead_style + return self._line.end_arrowhead_style + + @type.setter + def type(self, value: MSO_LINE_END_TYPE | None) -> None: + if self._which == "head": + self._line.begin_arrowhead_style = value + else: + self._line.end_arrowhead_style = value + + @property + def width(self) -> MSO_LINE_END_SIZE | None: + """Arrowhead width — a member of :ref:`MsoArrowheadSize` or |None|.""" + if self._which == "head": + return self._line.begin_arrowhead_width + return self._line.end_arrowhead_width + + @width.setter + def width(self, value: MSO_LINE_END_SIZE | None) -> None: + if self._which == "head": + self._line.begin_arrowhead_width = value + else: + self._line.end_arrowhead_width = value + + @property + def length(self) -> MSO_LINE_END_SIZE | None: + """Arrowhead length — a member of :ref:`MsoArrowheadSize` or |None|.""" + if self._which == "head": + return self._line.begin_arrowhead_length + return self._line.end_arrowhead_length + + @length.setter + def length(self, value: MSO_LINE_END_SIZE | None) -> None: + if self._which == "head": + self._line.begin_arrowhead_length = value + else: + self._line.end_arrowhead_length = value diff --git a/src/pptx/dml/threed.py b/src/pptx/dml/threed.py new file mode 100644 index 000000000..110256515 --- /dev/null +++ b/src/pptx/dml/threed.py @@ -0,0 +1,97 @@ +"""DrawingML objects related to 3-D scene and shape formatting. + +`Scene3DFormat` wraps `a:scene3d` (preset camera) and `Shape3DFormat` wraps +`a:sp3d` (extrusion / contour). Accessed via :attr:`BaseShape.scene_3d` and +:attr:`BaseShape.shape_3d`. Issue #18 SF4. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pptx.util import Emu + +if TYPE_CHECKING: + from pptx.util import Length + + +class Scene3DFormat(object): + """Provides access to the 3-D scene (`a:scene3d`) on a shape. + + A preset camera (e.g. ``"perspectiveRelaxedModerately"``) is the primary + knob. Assigning :attr:`camera_preset` creates the `a:scene3d` element — + together with its schema-mandatory `a:camera` and `a:lightRig` children + — so the resulting file opens in PowerPoint without a repair dialog. + """ + + def __init__(self, spPr): + self._spPr = spPr + + @property + def visible(self) -> bool: + """|True| if an `a:scene3d` element is present on this shape.""" + return self._spPr.scene3d is not None + + @property + def camera_preset(self) -> str | None: + """Preset camera type, e.g. ``"orthographicFront"``. + + |None| if no 3-D scene is defined. Assigning a value creates the + scene (with camera + light rig) if necessary. + """ + scene3d = self._spPr.scene3d + if scene3d is None or scene3d.camera is None: + return None + return scene3d.camera.prst + + @camera_preset.setter + def camera_preset(self, value: str | None) -> None: + if value is None: + return + scene3d = self._spPr.get_or_add_scene3d() + scene3d.get_or_add_camera().prst = value + + +class Shape3DFormat(object): + """Provides access to the 3-D shape format (`a:sp3d`) on a shape. + + Extrusion height and contour width are the primary knobs. A bare + `` is schema-valid, so assigning either property simply creates + the element with that attribute set. + """ + + def __init__(self, spPr): + self._spPr = spPr + + @property + def visible(self) -> bool: + """|True| if an `a:sp3d` element is present on this shape.""" + return self._spPr.sp3d is not None + + @property + def extrusion_height(self) -> Length | None: + """Extrusion (depth) height as a |Length|, |None| if not defined.""" + sp3d = self._spPr.sp3d + if sp3d is None: + return None + return Emu(sp3d.extrusionH) + + @extrusion_height.setter + def extrusion_height(self, value: Length | None) -> None: + if value is None: + return + self._spPr.get_or_add_sp3d().extrusionH = int(value) + + @property + def contour_width(self) -> Length | None: + """Contour (edge) width as a |Length|, |None| if not defined.""" + sp3d = self._spPr.sp3d + if sp3d is None: + return None + return Emu(sp3d.contourW) + + @contour_width.setter + def contour_width(self, value: Length | None) -> None: + if value is None: + return + self._spPr.get_or_add_sp3d().contourW = int(value) diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 110c36d55..0de1e3997 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -330,13 +330,32 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): from pptx.oxml.dml.effect import ( # noqa: E402 CT_EffectList, + CT_GlowEffect, CT_InnerShadowEffect, CT_OuterShadowEffect, + CT_ReflectionEffect, + CT_SoftEdgesEffect, ) register_element_cls("a:effectLst", CT_EffectList) register_element_cls("a:innerShdw", CT_InnerShadowEffect) register_element_cls("a:outerShdw", CT_OuterShadowEffect) +register_element_cls("a:glow", CT_GlowEffect) +register_element_cls("a:reflection", CT_ReflectionEffect) +register_element_cls("a:softEdge", CT_SoftEdgesEffect) + + +from pptx.oxml.shapes.threed import ( # noqa: E402 + CT_Camera, + CT_LightRig, + CT_Scene3D, + CT_Shape3D, +) + +register_element_cls("a:scene3d", CT_Scene3D) +register_element_cls("a:camera", CT_Camera) +register_element_cls("a:lightRig", CT_LightRig) +register_element_cls("a:sp3d", CT_Shape3D) from pptx.oxml.dml.line import CT_PresetLineDashProperties # noqa: E402 diff --git a/src/pptx/oxml/dml/effect.py b/src/pptx/oxml/dml/effect.py index 252ee55af..ed1d66cbb 100644 --- a/src/pptx/oxml/dml/effect.py +++ b/src/pptx/oxml/dml/effect.py @@ -6,11 +6,18 @@ from pptx.oxml import parse_xml from pptx.oxml.ns import nsdecls -from pptx.oxml.simpletypes import ST_Angle, ST_PositiveCoordinate, XsdBoolean +from pptx.oxml.simpletypes import ( + ST_Angle, + ST_PositiveCoordinate, + ST_PositiveFixedAngle, + ST_PositiveFixedPercentage, + XsdBoolean, +) from pptx.oxml.xmlchemy import ( BaseOxmlElement, Choice, OptionalAttribute, + RequiredAttribute, ZeroOrOne, ZeroOrOneChoice, ) @@ -46,13 +53,79 @@ class CT_InnerShadowEffect(BaseOxmlElement): dir = OptionalAttribute("dir", ST_Angle, default=0) +class CT_GlowEffect(BaseOxmlElement): + """`a:glow` custom element class. + + DrawingML §20.1.8.16. Schema (`CT_GlowEffect`): one mandatory + `EG_ColorChoice` child and an optional `rad` attribute. The color child + is what PowerPoint draws the glow in, so — like `a:outerShdw` — it must + always be present for the effect to render and the file to open clean. + """ + + eg_colorChoice = ZeroOrOneChoice( + ( + Choice("a:scrgbClr"), + Choice("a:srgbClr"), + Choice("a:hslClr"), + Choice("a:sysClr"), + Choice("a:schemeClr"), + Choice("a:prstClr"), + ), + successors=(), + ) + rad = OptionalAttribute("rad", ST_PositiveCoordinate, default=0) + + +class CT_ReflectionEffect(BaseOxmlElement): + """`a:reflection` custom element class. + + DrawingML §20.1.8.45. Schema (`CT_ReflectionEffect`) is attribute-only + (no child elements). Only the attributes in common use are modeled; any + others present in a loaded file round-trip untouched via lxml. + """ + + blurRad = OptionalAttribute("blurRad", ST_PositiveCoordinate, default=0) + stA = OptionalAttribute("stA", ST_PositiveFixedPercentage, default=100000) + stPos = OptionalAttribute("stPos", ST_PositiveFixedPercentage, default=0) + endA = OptionalAttribute("endA", ST_PositiveFixedPercentage, default=0) + endPos = OptionalAttribute("endPos", ST_PositiveFixedPercentage, default=100000) + dist = OptionalAttribute("dist", ST_PositiveCoordinate, default=0) + dir = OptionalAttribute("dir", ST_PositiveFixedAngle, default=0) + fadeDir = OptionalAttribute("fadeDir", ST_PositiveFixedAngle, default=5400000) + rotWithShape: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "rotWithShape", XsdBoolean, default=True + ) + + +class CT_SoftEdgesEffect(BaseOxmlElement): + """`a:softEdge` custom element class. + + DrawingML §20.1.8.48. Schema (`CT_SoftEdgesEffect`) is a single + **required** `rad` attribute. PowerPoint rejects a `` with no + `rad` (silent repair), so `rad` is modeled `RequiredAttribute` and the + factory always sets it. + """ + + rad = RequiredAttribute("rad", ST_PositiveCoordinate) + + class CT_EffectList(BaseOxmlElement): """`a:effectLst` custom element class.""" get_or_add_outerShdw: Callable[[], CT_OuterShadowEffect] + get_or_add_glow: Callable[[], CT_GlowEffect] + get_or_add_reflection: Callable[[], CT_ReflectionEffect] + get_or_add_softEdge: Callable[[], CT_SoftEdgesEffect] _remove_outerShdw: Callable[[], None] _remove_innerShdw: Callable[[], None] + _remove_glow: Callable[[], None] + _remove_reflection: Callable[[], None] + _remove_softEdge: Callable[[], None] + # ---OOXML schema order (ECMA-376 dml-main.xsd CT_EffectList): blur, + # ---fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, + # ---softEdge. Emitting a child out of this order is a silent + # ---PowerPoint-repair trigger, so successors are derived from _tag_seq. _tag_seq = ( "a:blur", "a:fillOverlay", @@ -63,12 +136,21 @@ class CT_EffectList(BaseOxmlElement): "a:reflection", "a:softEdge", ) + glow: CT_GlowEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:glow", successors=_tag_seq[3:] + ) innerShdw: CT_InnerShadowEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "a:innerShdw", successors=_tag_seq[4:] ) outerShdw: CT_OuterShadowEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "a:outerShdw", successors=_tag_seq[5:] ) + reflection: CT_ReflectionEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:reflection", successors=_tag_seq[7:] + ) + softEdge: CT_SoftEdgesEffect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:softEdge", successors=_tag_seq[8:] + ) del _tag_seq def _new_outerShdw(self) -> CT_OuterShadowEffect: @@ -88,3 +170,41 @@ def _new_outerShdw(self) -> CT_OuterShadowEffect: f"" ), ) + + def _new_glow(self) -> CT_GlowEffect: + """Return a new `a:glow` element with a default color child. + + Schema requires the `EG_ColorChoice` child, so a default 5pt accent-1 + glow is provided; callers typically overwrite the color immediately. + """ + return cast( + CT_GlowEffect, + parse_xml( + f' ' + ), + ) + + def _new_reflection(self) -> CT_ReflectionEffect: + """Return a new `a:reflection` element with a PowerPoint-typical default. + + Mirrors the "Tight Reflection, touching" preset PowerPoint emits from + its effects gallery so the round-tripped file opens clean. + """ + return cast( + CT_ReflectionEffect, + parse_xml( + f'' + ), + ) + + def _new_softEdge(self) -> CT_SoftEdgesEffect: + """Return a new `a:softEdge` element. + + `rad` is required by the schema; default 2.5pt feather. + """ + return cast( + CT_SoftEdgesEffect, + parse_xml(f''), + ) diff --git a/src/pptx/oxml/shapes/shared.py b/src/pptx/oxml/shapes/shared.py index 6ef3a7721..afdc98bfc 100644 --- a/src/pptx/oxml/shapes/shared.py +++ b/src/pptx/oxml/shapes/shared.py @@ -532,6 +532,8 @@ class CT_ShapeProperties(BaseOxmlElement): "a:ln", successors=_tag_seq[10:] ) effectLst = ZeroOrOne("a:effectLst", successors=_tag_seq[11:]) + scene3d = ZeroOrOne("a:scene3d", successors=_tag_seq[13:]) + sp3d = ZeroOrOne("a:sp3d", successors=_tag_seq[14:]) del _tag_seq @property @@ -579,6 +581,23 @@ def y(self): def _new_gradFill(self): return CT_GradientFillProperties.new_gradFill() + def _new_scene3d(self): + """Factory for `ZeroOrOne` `scene3d` — schema-valid camera+lightRig. + + A bare `` is invalid (camera and lightRig are both + required); PowerPoint shows a repair dialog. Lazy import avoids a + circular dependency with the oxml registry. + """ + from pptx.oxml.shapes.threed import CT_Scene3D + + return CT_Scene3D.new_scene3d() + + def _new_sp3d(self): + """Factory for `ZeroOrOne` `sp3d` — a bare `` is schema-valid.""" + from pptx.oxml.shapes.threed import CT_Shape3D + + return CT_Shape3D.new_sp3d() + class CT_Transform2D(BaseOxmlElement): """`a:xfrm` custom element class. diff --git a/src/pptx/oxml/shapes/threed.py b/src/pptx/oxml/shapes/threed.py new file mode 100644 index 000000000..e871376a5 --- /dev/null +++ b/src/pptx/oxml/shapes/threed.py @@ -0,0 +1,101 @@ +"""lxml custom element classes for DrawingML 3-D scene/shape XML elements. + +Covers `a:scene3d` (preset camera + light rig) and `a:sp3d` (extrusion / +contour / bevel). DrawingML §20.1.4.2.x and §20.1.5.x; ECMA-376 dml-main.xsd +`CT_Scene3D`, `CT_Camera`, `CT_LightRig`, `CT_Shape3D`. + +The single hardest no-repair fact here: `CT_Scene3D` requires **both** a +`camera` and a `lightRig` child (`minOccurs="1"` each). A `` with +only a camera makes PowerPoint show a repair dialog, so the default factory +always emits both. +""" + +from __future__ import annotations + +from typing import Callable, cast + +from pptx.oxml import parse_xml +from pptx.oxml.ns import nsdecls +from pptx.oxml.simpletypes import ST_Coordinate, ST_PositiveCoordinate, XsdString +from pptx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrOne + + +class CT_Camera(BaseOxmlElement): + """`a:camera` custom element class. + + `prst` (preset camera type, e.g. ``orthographicFront``, + ``perspectiveRelaxedModerately``) is required by the schema. + """ + + prst = RequiredAttribute("prst", XsdString) + fov = OptionalAttribute("fov", XsdString) + zoom = OptionalAttribute("zoom", XsdString) + + +class CT_LightRig(BaseOxmlElement): + """`a:lightRig` custom element class. + + Both `rig` (e.g. ``threePt``) and `dir` (e.g. ``t``) are required by the + schema; a lightRig missing either is a repair trigger. + """ + + rig = RequiredAttribute("rig", XsdString) + dir = RequiredAttribute("dir", XsdString) + + +class CT_Scene3D(BaseOxmlElement): + """`a:scene3d` custom element class. + + Schema sequence: ``camera`` (required), ``lightRig`` (required), + ``backdrop`` (optional), ``extLst`` (optional). + """ + + get_or_add_camera: Callable[[], CT_Camera] + get_or_add_lightRig: Callable[[], CT_LightRig] + + _tag_seq = ("a:camera", "a:lightRig", "a:backdrop", "a:extLst") + camera: CT_Camera | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:camera", successors=_tag_seq[1:] + ) + lightRig: CT_LightRig | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:lightRig", successors=_tag_seq[2:] + ) + del _tag_seq + + @classmethod + def new_scene3d(cls, camera_prst: str = "orthographicFront") -> "CT_Scene3D": + """Return a new `a:scene3d` with the required camera + lightRig children. + + PowerPoint requires both children present; the lightRig default + (``threePt`` / ``t``) matches what PowerPoint's 3-D format gallery + emits for a flat preset, so a round-tripped file opens clean. + """ + return cast( + CT_Scene3D, + parse_xml( + f"" + f' ' + f' ' + f"" + ), + ) + + +class CT_Shape3D(BaseOxmlElement): + """`a:sp3d` custom element class. + + Schema children (all optional): ``bevelT``, ``bevelB``, ``extrusionClr``, + ``contourClr``, ``extLst``. Attributes: ``z``, ``extrusionH``, + ``contourW``, ``prstMaterial``. An attribute-only `` is schema- + valid and PowerPoint-accepted. + """ + + z = OptionalAttribute("z", ST_Coordinate, default=0) + extrusionH = OptionalAttribute("extrusionH", ST_PositiveCoordinate, default=0) + contourW = OptionalAttribute("contourW", ST_PositiveCoordinate, default=0) + prstMaterial = OptionalAttribute("prstMaterial", XsdString) + + @classmethod + def new_sp3d(cls) -> "CT_Shape3D": + """Return a new bare `a:sp3d` element (all attributes default).""" + return cast(CT_Shape3D, parse_xml(f"")) diff --git a/src/pptx/shapes/base.py b/src/pptx/shapes/base.py index 356ed7b5f..b6c63891a 100644 --- a/src/pptx/shapes/base.py +++ b/src/pptx/shapes/base.py @@ -8,8 +8,10 @@ from pptx.action import ActionSetting from pptx.dml.effect import ShadowFormat +from pptx.dml.threed import Scene3DFormat, Shape3DFormat +from pptx.oxml.ns import qn from pptx.shared import ElementProxy -from pptx.util import lazyproperty +from pptx.util import Emu, lazyproperty # ---bound to the lxml base method so `find_by_xpath(..., namespaces=ns)` can # ---honor the caller's prefix map without going through the project's @@ -283,6 +285,150 @@ def shape_id(self) -> int: """ return self._element.shape_id + @property + def flip_horizontal(self) -> bool: + """Read/write. |True| if this shape is mirrored left-to-right. + + Backed by the `flipH` attribute of the shape's `a:xfrm`. Assigning a + value creates the `a:xfrm` element if necessary (issue #18 SF8). + """ + return bool(self._element.flipH) + + @flip_horizontal.setter + def flip_horizontal(self, value: bool) -> None: + self._element.flipH = bool(value) + + @property + def flip_vertical(self) -> bool: + """Read/write. |True| if this shape is mirrored top-to-bottom. + + Backed by the `flipV` attribute of the shape's `a:xfrm` (issue #18 + SF8). `shape.flip_vertical = True` round-trips through PowerPoint. + """ + return bool(self._element.flipV) + + @flip_vertical.setter + def flip_vertical(self, value: bool) -> None: + self._element.flipV = bool(value) + + @lazyproperty + def scene_3d(self) -> Scene3DFormat: + """|Scene3DFormat| object providing access to this shape's 3-D scene. + + Lets a preset camera be applied (`a:scene3d`). A |Scene3DFormat| is + always returned; the `a:scene3d` element (with its schema-mandatory + camera + light-rig children) is created only when a camera preset is + assigned (issue #18 SF4). + """ + return Scene3DFormat(self._element.spPr) + + @lazyproperty + def shape_3d(self) -> Shape3DFormat: + """|Shape3DFormat| object providing access to this shape's 3-D format. + + Lets extrusion / contour be applied (`a:sp3d`). Always returned; the + `a:sp3d` element is created only when a 3-D property is assigned + (issue #18 SF4). + """ + return Shape3DFormat(self._element.spPr) + + @property + def slide_left(self) -> Length: + """World-space left edge of this shape in slide EMU. + + For a shape that is **not** inside a group this equals :attr:`left`. + For a shape inside one or more groups, the enclosing group + transforms (`a:off`/`a:ext` vs `a:chOff`/`a:chExt`) are composed + outward so the returned value is the true position on the slide + (issue #18 SF7). Read-only; this never mutates the stored `a:xfrm`. + """ + return self._world_rect()[0] + + @property + def slide_top(self) -> Length: + """World-space top edge of this shape in slide EMU (see :attr:`slide_left`).""" + return self._world_rect()[1] + + @property + def slide_width(self) -> Length: + """World-space width of this shape in slide EMU (see :attr:`slide_left`).""" + return self._world_rect()[2] + + @property + def slide_height(self) -> Length: + """World-space height of this shape in slide EMU (see :attr:`slide_left`).""" + return self._world_rect()[3] + + def _world_rect(self) -> tuple[Length, Length, Length, Length]: + """Return ``(left, top, width, height)`` of this shape in slide EMU. + + Composes every enclosing ``p:grpSp`` group transform outward. The + same affine handles arbitrary nesting depth — each group's + ``a:off``/``a:ext`` are expressed in its own parent's child space, so + re-applying the next ancestor's transform composes correctly with no + nested-vs-single special-casing. A degenerate group (``a:chExt`` of + zero on either axis) falls back to an identity scale rather than + dividing by zero. + + Scope note: only the scale + translate of each group is composed. + Group **rotation** and **flipH/flipV** are intentionally not folded + in — the result is the axis-aligned child-orientation box (matching + PowerPoint's COM ``Shape.Left`` semantics for grouped shapes). A + rotated/flipped enclosing group will therefore give the unrotated + rect; that is by design, not a bug. + """ + + def _f(elm, path: str, default: int = 0) -> int: + vals = elm.xpath(path) + return int(vals[0]) if vals else default + + x = float(self._element.x or 0) + y = float(self._element.y or 0) + cx = float(self._element.cx or 0) + cy = float(self._element.cy or 0) + + parent = self._element.getparent() + while parent is not None and parent.tag == qn("p:grpSp"): + base = "./p:grpSpPr/a:xfrm" + gx = _f(parent, f"{base}/a:off/@x") + gy = _f(parent, f"{base}/a:off/@y") + gcx = _f(parent, f"{base}/a:ext/@cx") + gcy = _f(parent, f"{base}/a:ext/@cy") + chx = _f(parent, f"{base}/a:chOff/@x") + chy = _f(parent, f"{base}/a:chOff/@y") + chcx = _f(parent, f"{base}/a:chExt/@cx") + chcy = _f(parent, f"{base}/a:chExt/@cy") + sx = (gcx / chcx) if chcx else 1.0 + sy = (gcy / chcy) if chcy else 1.0 + x = gx + (x - chx) * sx + y = gy + (y - chy) * sy + cx = cx * sx + cy = cy * sy + parent = parent.getparent() + + return (Emu(int(x)), Emu(int(y)), Emu(int(cx)), Emu(int(cy))) + + def duplicate(self, insert_at_z: int | None = None) -> "BaseShape": + """Return a deep-copy of this shape added to the same shape tree. + + The clone gets a fresh, unique shape id and a unique name; its XML is + an independent deep copy (mutating the clone does not affect the + original). With `insert_at_z` |None| (default) the clone is appended + at the top of the z-order; otherwise it is inserted at z-order index + `insert_at_z` (issue #18 SF9). + + Limitation: this is a pure XML deep-copy. For a relationship-backed + shape (picture, chart, table, OLE object) the `r:embed`/`r:id` + reference is copied but the target part is **not** cloned — both + shapes then share one image/chart part. That is fine for read-back + and for autoshapes/connectors/text boxes (no relationships), but a + true picture/chart duplicate that needs an independent part is out + of scope here. + """ + return self._parent._duplicate_shape( # pyright: ignore[reportAttributeAccessIssue] + self, insert_at_z + ) + @property def comments(self) -> "_ShapeComments": """The comments anchored to *this* shape (issue #25 Wave 3, SF7). diff --git a/src/pptx/shapes/connector.py b/src/pptx/shapes/connector.py index 070b080d5..59ab9d615 100644 --- a/src/pptx/shapes/connector.py +++ b/src/pptx/shapes/connector.py @@ -218,6 +218,23 @@ def end_y(self, value): cxnSp.y = new_y cxnSp.cy = dy - cy + def start_connection(self, shape, cxn_pt_idx): + """Issue-#18-named alias for :meth:`begin_connect`. + + Connect the begin point of this connector to `shape` at connection + point `cxn_pt_idx`. Identical behavior to the already-shipped + :meth:`begin_connect`; provided so the issue-#18 API name works. + """ + self.begin_connect(shape, cxn_pt_idx) + + def end_connection(self, shape, cxn_pt_idx): + """Issue-#18-named alias for :meth:`end_connect`. + + Connect the end point of this connector to `shape` at connection + point `cxn_pt_idx`. Identical behavior to :meth:`end_connect`. + """ + self.end_connect(shape, cxn_pt_idx) + def get_or_add_ln(self): """Helper method required by |LineFormat|.""" return self._element.spPr.get_or_add_ln() diff --git a/src/pptx/shapes/shapetree.py b/src/pptx/shapes/shapetree.py index 494e96472..fa2eedad5 100644 --- a/src/pptx/shapes/shapetree.py +++ b/src/pptx/shapes/shapetree.py @@ -317,6 +317,55 @@ def _shape_factory(self, shape_elm: ShapeElement) -> BaseShape: """Return an instance of the appropriate shape proxy class for `shape_elm`.""" return BaseShapeFactory(shape_elm, self) + def _duplicate_shape(self, shape: BaseShape, insert_at_z: int | None = None) -> BaseShape: + """Deep-copy `shape` into this shape tree and return the new shape. + + Every `p:cNvPr` id in the copied subtree is reassigned a fresh unique + id (a colliding id is a PowerPoint load/repair trigger, especially + when duplicating a group), the top-level shape gets a unique name, + and the clone is appended (or inserted at z-order `insert_at_z`). + """ + from copy import deepcopy + + new_elm = deepcopy(shape._element) # noqa: SLF001 + + # ---Reassign EVERY cNvPr id in the copied subtree to a fresh, + # ---sequentially-unique value. `_next_shape_id` is `max_shape_id + + # ---1`; before the clone is appended it would return the SAME id on + # ---every call, so a duplicated *group* (N descendant cNvPr) would + # ---get N colliding ids → PowerPoint repair. Allocate + # ---`max + 1 + i` instead. `max_shape_id` already scans every + # ---spTree descendant cNvPr (placeholders + group children + # ---included), so these are unique slide-wide.--- + base_id = self._spTree.max_shape_id + existing_names = set(self._spTree.xpath("//p:cNvPr/@name")) + cNvPrs = new_elm.xpath(".//p:cNvPr") + for i, cNvPr in enumerate(cNvPrs): + cNvPr.set("id", str(base_id + 1 + i)) + if i == 0: + root = cNvPr.get("name") or "Shape" + candidate, n = f"{root} (copy)", 1 + while candidate in existing_names: + n += 1 + candidate = f"{root} (copy {n})" + cNvPr.set("name", candidate) + existing_names.add(candidate) + + # ---z-order: `_iter_member_elms` yields only shape elements (never + # ---the leading `nvGrpSpPr`/`grpSpPr`), so inserting before any + # ---member — even index 0 — is always after `grpSpPr` and safe. + # ---Clamp negatives to 0 so a stray negative z can't reverse-index.--- + members = list(self._iter_member_elms()) + if insert_at_z is None or insert_at_z >= len(members): + self._spTree.append(new_elm) + else: + members[max(0, insert_at_z)].addprevious(new_elm) + + recalc = getattr(self, "_recalculate_extents", None) + if callable(recalc): + recalc() + return self._shape_factory(new_elm) + class _BaseGroupShapes(_BaseShapes): """Base class for shape-trees that can add shapes.""" diff --git a/tests/test_issue18_shape_effects.py b/tests/test_issue18_shape_effects.py new file mode 100644 index 000000000..70f53c148 --- /dev/null +++ b/tests/test_issue18_shape_effects.py @@ -0,0 +1,460 @@ +# pyright: reportPrivateUsage=false + +"""Unit + round-trip tests for issue #18. + +[Epic] Shape Effects, Arrowheads & Connectors — +https://github.com/MHoroszowski/python-pptx/issues/18 + +Covers the genuinely-new surface (glow / reflection / soft-edge effects, +scene_3d / shape_3d, group world-space coordinates, flip_vertical / +flip_horizontal, Shape.duplicate) plus the issue-named convenience aliases +over the already-shipped arrowhead / connector API. + +Layered like `tests/test_slide_duplicate.py`: +1. API-surface unit tests (build a shape, set a property, assert XML). +2. Round-trip integration tests (save → reopen → re-read) — the only + layer that proves Office-compatible packaging and that nothing is + silently dropped (the §6a thesis: schema-valid can still be wrong). +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.dml import MSO_LINE_END_SIZE, MSO_LINE_END_TYPE +from pptx.util import Emu, Inches, Pt + +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + + +def _blank_slide(): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + return prs, slide + + +def _rect(slide): + from pptx.enum.shapes import MSO_SHAPE + + return slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)) + + +def _roundtrip_first_autoshape(prs): + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + for shp in prs2.slides[0].shapes: + if shp.shape_type is not None and "AUTO_SHAPE" in str(shp.shape_type): + return prs2, shp + return prs2, list(prs2.slides[0].shapes)[0] + + +def _effectLst(shape): + return shape._element.spPr.find(f"{A}effectLst") + + +# ───────────────────────── A. Glow (SF1) ───────────────────────── + + +class DescribeGlowEffect: + def it_creates_a_glow_with_color_and_radius(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.glow_effect.color.rgb = RGBColor(0xFF, 0x00, 0x00) + sp.shadow.glow_effect.radius = Pt(20) + glow = _effectLst(sp).find(f"{A}glow") + assert glow is not None + assert glow.get("rad") == str(Pt(20)) + assert glow.find(f"{A}srgbClr").get("val") == "FF0000" + + def it_reads_back_none_when_no_glow(self): + _, slide = _blank_slide() + sp = _rect(slide) + assert sp.shadow.glow_effect.radius is None + assert sp.shadow.glow_effect.visible is False + + def it_round_trips_glow_through_save_reopen(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.glow_effect.color.rgb = RGBColor(0x00, 0x80, 0xFF) + sp.shadow.glow_effect.radius = Pt(15) + _, sp2 = _roundtrip_first_autoshape(prs) + assert sp2.shadow.glow_effect.radius == Emu(Pt(15)) + assert sp2.shadow.glow_effect.color.rgb == RGBColor(0x00, 0x80, 0xFF) + + def it_orders_glow_before_inner_and_outer_shadow(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.visible = True # outerShdw + sp.shadow.glow_effect.radius = Pt(10) + kids = [el.tag for el in _effectLst(sp)] + assert kids.index(f"{A}glow") < kids.index(f"{A}outerShdw") + + +# ───────────────────────── B. Reflection (SF2) ───────────────────────── + + +class DescribeReflectionEffect: + def it_creates_a_reflection_with_attrs(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.reflection_effect.blur_radius = Pt(2) + sp.shadow.reflection_effect.distance = Pt(5) + sp.shadow.reflection_effect.direction = 90.0 + refl = _effectLst(sp).find(f"{A}reflection") + assert refl is not None + assert refl.get("blurRad") == str(Pt(2)) + assert refl.get("dist") == str(Pt(5)) + + def it_reads_none_when_absent(self): + _, slide = _blank_slide() + sp = _rect(slide) + assert sp.shadow.reflection_effect.blur_radius is None + assert sp.shadow.reflection_effect.visible is False + + def it_round_trips_reflection(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.reflection_effect.blur_radius = Pt(3) + sp.shadow.reflection_effect.distance = Pt(7) + _, sp2 = _roundtrip_first_autoshape(prs) + assert sp2.shadow.reflection_effect.blur_radius == Emu(Pt(3)) + assert sp2.shadow.reflection_effect.distance == Emu(Pt(7)) + + def it_orders_reflection_after_outer_shadow(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.visible = True + sp.shadow.reflection_effect.blur_radius = Pt(2) + kids = [el.tag for el in _effectLst(sp)] + assert kids.index(f"{A}outerShdw") < kids.index(f"{A}reflection") + + +# ───────────────────────── C. Soft edge (SF3) ───────────────────────── + + +class DescribeSoftEdgeEffect: + def it_creates_a_soft_edge_with_radius(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.soft_edge_effect.radius = Pt(4) + se = _effectLst(sp).find(f"{A}softEdge") + assert se is not None + assert se.get("rad") == str(Pt(4)) + + def it_reads_none_when_absent(self): + _, slide = _blank_slide() + sp = _rect(slide) + assert sp.shadow.soft_edge_effect.radius is None + + def it_round_trips_soft_edge(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.soft_edge_effect.radius = Pt(6) + _, sp2 = _roundtrip_first_autoshape(prs) + assert sp2.shadow.soft_edge_effect.radius == Emu(Pt(6)) + + def it_emits_soft_edge_last_in_schema_order(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.glow_effect.radius = Pt(3) + sp.shadow.soft_edge_effect.radius = Pt(3) + kids = [el.tag for el in _effectLst(sp)] + assert kids[-1] == f"{A}softEdge" + + +# ───────────────────────── D. 3-D scene / shape (SF4) ───────────────────────── + + +class DescribeScene3DAndShape3D: + def it_sets_a_preset_camera_with_required_lightrig(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.scene_3d.camera_preset = "perspectiveRelaxedModerately" + s3d = sp._element.spPr.find(f"{A}scene3d") + assert s3d is not None + assert s3d.find(f"{A}camera").get("prst") == "perspectiveRelaxedModerately" + # ---lightRig is schema-required; absence => PowerPoint repair--- + assert s3d.find(f"{A}lightRig") is not None + + def it_sets_extrusion_and_contour(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shape_3d.extrusion_height = Pt(12) + sp.shape_3d.contour_width = Pt(1) + sp3d = sp._element.spPr.find(f"{A}sp3d") + assert sp3d.get("extrusionH") == str(Pt(12)) + assert sp3d.get("contourW") == str(Pt(1)) + + def it_round_trips_3d(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.scene_3d.camera_preset = "orthographicFront" + sp.shape_3d.extrusion_height = Pt(20) + _, sp2 = _roundtrip_first_autoshape(prs) + assert sp2.scene_3d.camera_preset == "orthographicFront" + assert sp2.shape_3d.extrusion_height == Emu(Pt(20)) + + def it_orders_scene3d_and_sp3d_after_effectlst(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.visible = True + sp.scene_3d.camera_preset = "orthographicFront" + sp.shape_3d.extrusion_height = Pt(5) + kids = [el.tag for el in sp._element.spPr] + assert kids.index(f"{A}effectLst") < kids.index(f"{A}scene3d") + assert kids.index(f"{A}scene3d") < kids.index(f"{A}sp3d") + + +# ───────────────────────── E. Arrowhead alias (SF5) ───────────────────────── + + +class DescribeHeadTailEndAlias: + def it_exposes_head_end_type_width_length(self): + _, slide = _blank_slide() + c = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(4), Inches(1)) + c.line.head_end.type = MSO_LINE_END_TYPE.TRIANGLE + c.line.head_end.width = MSO_LINE_END_SIZE.LARGE + c.line.head_end.length = MSO_LINE_END_SIZE.MEDIUM + assert c.line.begin_arrowhead_style == MSO_LINE_END_TYPE.TRIANGLE + assert c.line.head_end.width == MSO_LINE_END_SIZE.LARGE + assert c.line.head_end.length == MSO_LINE_END_SIZE.MEDIUM + + def it_round_trips_tail_end_arrowhead(self): + prs, slide = _blank_slide() + c = slide.shapes.add_connector(2, Inches(1), Inches(2), Inches(5), Inches(2)) + c.line.tail_end.type = MSO_LINE_END_TYPE.STEALTH + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + c2 = list(prs2.slides[0].shapes)[0] + assert c2.line.tail_end.type == MSO_LINE_END_TYPE.STEALTH + + def it_does_not_change_the_shipped_arrowhead_api(self): + # ---regression: the shipped properties must still work standalone--- + _, slide = _blank_slide() + c = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(2), Inches(2)) + c.line.end_arrowhead_style = MSO_LINE_END_TYPE.OVAL + assert c.line.tail_end.type == MSO_LINE_END_TYPE.OVAL + + +# ───────────────────────── F. Connector alias (SF6) ───────────────────────── + + +class DescribeStartEndConnectionAlias: + def it_aliases_begin_and_end_connect(self): + _, slide = _blank_slide() + a = _rect(slide) + b = slide.shapes.add_shape(1, Inches(5), Inches(1), Inches(2), Inches(1)) + c = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(4), Inches(4)) + c.start_connection(a, 1) + c.end_connection(b, 3) + cNvCxnSpPr = c._element.nvCxnSpPr.cNvCxnSpPr + assert cNvCxnSpPr.find(f"{A}stCxn").get("id") == str(a.shape_id) + assert cNvCxnSpPr.find(f"{A}endCxn").get("id") == str(b.shape_id) + + def it_round_trips_a_connected_connector(self): + prs, slide = _blank_slide() + a = _rect(slide) + c = slide.shapes.add_connector(2, Inches(1), Inches(1), Inches(4), Inches(4)) + c.start_connection(a, 0) + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + shapes2 = list(prs2.slides[0].shapes) + conn = [s for s in shapes2 if s.shape_type is not None and "LINE" in str(s.shape_type)][0] + assert conn._element.nvCxnSpPr.cNvCxnSpPr.find(f"{A}stCxn") is not None + + +# ───────────────────────── G. Group world coords (SF7) ───────────────────────── + + +class DescribeGroupWorldCoordinates: + def it_returns_plain_coords_for_ungrouped_shape(self): + _, slide = _blank_slide() + sp = _rect(slide) + assert sp.slide_left == sp.left + assert sp.slide_top == sp.top + assert sp.slide_width == sp.width + + def it_composes_one_group_transform(self): + _, slide = _blank_slide() + group = slide.shapes.add_group_shape() + child = group.shapes.add_shape(1, Inches(1), Inches(1), Inches(1), Inches(1)) + gx = group._element.grpSpPr.get_or_add_xfrm() + gx.get_or_add_off().x = Emu(Inches(2)) + gx.get_or_add_off().y = Emu(Inches(2)) + gx.get_or_add_ext().cx = Emu(Inches(4)) + gx.get_or_add_ext().cy = Emu(Inches(4)) + gx.get_or_add_chOff().x = Emu(0) + gx.get_or_add_chOff().y = Emu(0) + gx.get_or_add_chExt().cx = Emu(Inches(2)) + gx.get_or_add_chExt().cy = Emu(Inches(2)) + # child at (1",1") size 1" in a 2"→4" (×2) group offset to (2",2") + # world_x = 2" + (1" - 0)*2 = 4" ; world_w = 1" * 2 = 2" + assert child.slide_left == Emu(Inches(4)) + assert child.slide_top == Emu(Inches(4)) + assert child.slide_width == Emu(Inches(2)) + + def it_composes_nested_groups(self): + _, slide = _blank_slide() + outer = slide.shapes.add_group_shape() + inner = outer.shapes.add_group_shape() + child = inner.shapes.add_shape(1, Inches(1), Inches(1), Inches(1), Inches(1)) + + def setxf(grp, ox, oy, ex, ey, cox, coy, cex, cey): + x = grp._element.grpSpPr.get_or_add_xfrm() + x.get_or_add_off().x = Emu(ox) + x.get_or_add_off().y = Emu(oy) + x.get_or_add_ext().cx = Emu(ex) + x.get_or_add_ext().cy = Emu(ey) + x.get_or_add_chOff().x = Emu(cox) + x.get_or_add_chOff().y = Emu(coy) + x.get_or_add_chExt().cx = Emu(cex) + x.get_or_add_chExt().cy = Emu(cey) + + # inner: child-space 0..2", rendered 0..2" (×1), offset 0 + setxf(inner, 0, 0, Inches(2), Inches(2), 0, 0, Inches(2), Inches(2)) + # outer: child-space 0..2", rendered at (3",0") size 4" (×2) + setxf(outer, Inches(3), 0, Inches(4), Inches(4), 0, 0, Inches(2), Inches(2)) + # child world_x = 3" + (1"*1 - 0)*2 = 5" + assert child.slide_left == Emu(Inches(5)) + + def it_falls_back_to_identity_on_degenerate_group(self): + _, slide = _blank_slide() + group = slide.shapes.add_group_shape() + child = group.shapes.add_shape(1, Inches(1), Inches(1), Inches(1), Inches(1)) + gx = group._element.grpSpPr.get_or_add_xfrm() + gx.get_or_add_chExt().cx = Emu(0) # degenerate + gx.get_or_add_chExt().cy = Emu(0) + # must not raise ZeroDivisionError; identity → plain child coord + assert child.slide_left == Emu(Inches(1)) + + def it_does_not_mutate_stored_xfrm(self): + _, slide = _blank_slide() + group = slide.shapes.add_group_shape() + child = group.shapes.add_shape(1, Inches(1), Inches(1), Inches(1), Inches(1)) + before = child._element.xpath("string(./p:spPr/a:xfrm/a:off/@x)") + _ = child.slide_left + after = child._element.xpath("string(./p:spPr/a:xfrm/a:off/@x)") + assert before == after + + +# ───────────────────────── H. Flip (SF8) ───────────────────────── + + +class DescribeFlip: + def it_sets_and_reads_flip_vertical(self): + _, slide = _blank_slide() + sp = _rect(slide) + assert sp.flip_vertical is False + sp.flip_vertical = True + assert sp.flip_vertical is True + assert sp._element.xpath("string(./p:spPr/a:xfrm/@flipV)") == "1" + + def it_sets_and_reads_flip_horizontal(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.flip_horizontal = True + assert sp.flip_horizontal is True + + def it_round_trips_flip_vertical(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.flip_vertical = True + _, sp2 = _roundtrip_first_autoshape(prs) + assert sp2.flip_vertical is True + + def it_does_not_corrupt_sppr_order_creating_xfrm(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.flip_vertical = True + kids = [el.tag for el in sp._element.spPr] + assert kids[0] == f"{A}xfrm" + + +# ───────────────────────── I. Duplicate (SF9) ───────────────────────── + + +class DescribeShapeDuplicate: + def it_returns_a_distinct_shape(self): + _, slide = _blank_slide() + sp = _rect(slide) + dup = sp.duplicate() + assert dup is not sp + assert dup._element is not sp._element + + def it_assigns_a_unique_id_and_name(self): + _, slide = _blank_slide() + sp = _rect(slide) + dup = sp.duplicate() + assert dup.shape_id != sp.shape_id + assert dup.name != sp.name + + def it_appends_at_top_z_by_default(self): + _, slide = _blank_slide() + sp = _rect(slide) + dup = sp.duplicate() + assert list(slide.shapes)[-1].shape_id == dup.shape_id + + def it_inserts_at_requested_z(self): + _, slide = _blank_slide() + a = _rect(slide) + slide.shapes.add_shape(1, Inches(4), Inches(1), Inches(1), Inches(1)) + dup = a.duplicate(insert_at_z=0) + assert list(slide.shapes)[0].shape_id == dup.shape_id + + def it_is_an_independent_deep_copy(self): + _, slide = _blank_slide() + sp = _rect(slide) + dup = sp.duplicate() + dup.left = Inches(6) + assert sp.left != dup.left + + def it_preserves_an_effect_on_the_clone(self): + _, slide = _blank_slide() + sp = _rect(slide) + sp.shadow.glow_effect.color.rgb = RGBColor(0xFF, 0, 0) + sp.shadow.glow_effect.radius = Pt(10) + dup = sp.duplicate() + assert dup.shadow.glow_effect.radius == Emu(Pt(10)) + assert dup.shadow.glow_effect.color.rgb == RGBColor(0xFF, 0, 0) + + def it_gives_every_cNvPr_a_unique_id_when_duplicating_a_group(self): + # ---regression: pre-append max+1 returned the same id on every + # ---call, so a duplicated group's children all collided → repair--- + _, slide = _blank_slide() + group = slide.shapes.add_group_shape() + group.shapes.add_shape(1, Inches(1), Inches(1), Inches(1), Inches(1)) + group.shapes.add_shape(1, Inches(2), Inches(2), Inches(1), Inches(1)) + group.duplicate() + all_ids = slide.shapes._spTree.xpath("//p:cNvPr/@id") + assert len(all_ids) == len(set(all_ids)), f"duplicate id collision: {all_ids}" + + def it_round_trips_a_duplicated_shape(self): + prs, slide = _blank_slide() + sp = _rect(slide) + sp.duplicate() + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + prs2 = Presentation(buf) + autoshapes = [ + s + for s in prs2.slides[0].shapes + if s.shape_type is not None and "AUTO_SHAPE" in str(s.shape_type) + ] + assert len(autoshapes) == 2 + ids = [s.shape_id for s in prs2.slides[0].shapes] + assert len(ids) == len(set(ids)) # no id collision => no repair + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"]))