python-pptx2 2.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/shapes/base.py ADDED
@@ -0,0 +1,1078 @@
1
+ """Base shape-related objects such as BaseShape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import TYPE_CHECKING, Iterable, cast
7
+
8
+ from pptx2.action import ActionSetting
9
+ from pptx2.dml.effect import (
10
+ BlurFormat,
11
+ GlowFormat,
12
+ InnerShadowFormat,
13
+ PresetShadowFormat,
14
+ ReflectionFormat,
15
+ ShadowFormat,
16
+ SoftEdgeFormat,
17
+ )
18
+ from pptx2.dml.three_d import ThreeDFormat
19
+ from pptx2.shared import ElementProxy
20
+ from pptx2.util import _coerce_emu, lazyproperty
21
+
22
+ if TYPE_CHECKING:
23
+ from pptx2.design.style import ShapeStyle
24
+ from pptx2.enum.shapes import MSO_SHAPE_TYPE, PP_PLACEHOLDER
25
+ from pptx2.oxml.shapes import ShapeElement
26
+ from pptx2.oxml.shapes.shared import CT_Placeholder
27
+ from pptx2.parts.slide import BaseSlidePart
28
+ from pptx2.types import ProvidesPart
29
+ from pptx2.util import Length
30
+
31
+
32
+ class BaseShape(object):
33
+ """Base class for shape objects.
34
+
35
+ Subclasses include |Shape|, |Picture|, and |GraphicFrame|.
36
+ """
37
+
38
+ def __init__(self, shape_elm: ShapeElement, parent: ProvidesPart):
39
+ super().__init__()
40
+ self._element = shape_elm
41
+ self._parent = parent
42
+
43
+ def __eq__(self, other: object) -> bool:
44
+ """|True| if this shape object proxies the same element as *other*.
45
+
46
+ Equality for proxy objects is defined as referring to the same XML element, whether or not
47
+ they are the same proxy object instance.
48
+ """
49
+ if not isinstance(other, BaseShape):
50
+ return False
51
+ return self._element is other._element
52
+
53
+ def __ne__(self, other: object) -> bool:
54
+ if not isinstance(other, BaseShape):
55
+ return True
56
+ return self._element is not other._element
57
+
58
+ @lazyproperty
59
+ def click_action(self) -> ActionSetting:
60
+ """|ActionSetting| instance providing access to click behaviors.
61
+
62
+ Click behaviors are hyperlink-like behaviors including jumping to a hyperlink (web page)
63
+ or to another slide in the presentation. The click action is that defined on the overall
64
+ shape, not a run of text within the shape. An |ActionSetting| object is always returned,
65
+ even when no click behavior is defined on the shape.
66
+ """
67
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
68
+ return ActionSetting(cNvPr, self)
69
+
70
+ @property
71
+ def element(self) -> ShapeElement:
72
+ """`lxml` element for this shape, e.g. a CT_Shape instance.
73
+
74
+ Note that manipulating this element improperly can produce an invalid presentation file.
75
+ Make sure you know what you're doing if you use this to change the underlying XML.
76
+ """
77
+ return self._element
78
+
79
+ @property
80
+ def has_chart(self) -> bool:
81
+ """|True| if this shape is a graphic frame containing a chart object.
82
+
83
+ |False| otherwise. When |True|, the chart object can be accessed using the ``.chart``
84
+ property.
85
+ """
86
+ # This implementation is unconditionally False, the True version is
87
+ # on GraphicFrame subclass.
88
+ return False
89
+
90
+ @property
91
+ def has_table(self) -> bool:
92
+ """|True| if this shape is a graphic frame containing a table object.
93
+
94
+ |False| otherwise. When |True|, the table object can be accessed using the ``.table``
95
+ property.
96
+ """
97
+ # This implementation is unconditionally False, the True version is
98
+ # on GraphicFrame subclass.
99
+ return False
100
+
101
+ @property
102
+ def has_text_frame(self) -> bool:
103
+ """|True| if this shape can contain text."""
104
+ # overridden on Shape to return True. Only <p:sp> has text frame
105
+ return False
106
+
107
+ @property
108
+ def height(self) -> Length:
109
+ """Read/write. Integer distance between top and bottom extents of shape in EMUs."""
110
+ return self._element.cy
111
+
112
+ @height.setter
113
+ def height(self, value: Length):
114
+ self._element.cy = _coerce_emu(value)
115
+
116
+ @property
117
+ def is_placeholder(self) -> bool:
118
+ """True if this shape is a placeholder.
119
+
120
+ A shape is a placeholder if it has a <p:ph> element.
121
+ """
122
+ return self._element.has_ph_elm
123
+
124
+ @property
125
+ def left(self) -> Length:
126
+ """Integer distance of the left edge of this shape from the left edge of the slide.
127
+
128
+ Read/write. Expressed in English Metric Units (EMU)
129
+ """
130
+ return self._element.x
131
+
132
+ @left.setter
133
+ def left(self, value: Length):
134
+ self._element.x = _coerce_emu(value)
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ """Name of this shape, e.g. 'Picture 7'."""
139
+ return self._element.shape_name
140
+
141
+ @name.setter
142
+ def name(self, value: str):
143
+ self._element._nvXxPr.cNvPr.name = value # pyright: ignore[reportPrivateUsage]
144
+
145
+ @property
146
+ def alt_text(self) -> str:
147
+ """Accessibility description (alt text) for this shape.
148
+
149
+ Read/write ``str``. Maps to the ``descr`` attribute of the
150
+ shape's ``<p:cNvPr>`` element — the OOXML-sanctioned alt-text
151
+ slot that screen readers announce and that PowerPoint surfaces
152
+ in its *Alt Text* pane. Reading returns ``""`` when no
153
+ description has been set.
154
+
155
+ Example::
156
+
157
+ picture.alt_text = "Bar chart of Q3 revenue by region."
158
+
159
+ Assigning ``""`` (or ``None``) clears the description.
160
+ """
161
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
162
+ return cNvPr.get("descr") or ""
163
+
164
+ @alt_text.setter
165
+ def alt_text(self, value: str | None):
166
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
167
+ if value is None or value == "":
168
+ if "descr" in cNvPr.attrib:
169
+ del cNvPr.attrib["descr"]
170
+ return
171
+ if not isinstance(value, str):
172
+ raise TypeError(f"alt_text must be a string or None; got {type(value).__name__}")
173
+ cNvPr.set("descr", value)
174
+
175
+ @property
176
+ def title_text(self) -> str:
177
+ """Accessibility title for this shape.
178
+
179
+ Read/write ``str``. Maps to the ``title`` attribute of the
180
+ shape's ``<p:cNvPr>`` element — a short one-line label that
181
+ complements the longer :attr:`alt_text` description. Reading
182
+ returns ``""`` when no title has been set; assigning ``""`` (or
183
+ ``None``) clears it.
184
+ """
185
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
186
+ return cNvPr.get("title") or ""
187
+
188
+ @title_text.setter
189
+ def title_text(self, value: str | None):
190
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
191
+ if value is None or value == "":
192
+ if "title" in cNvPr.attrib:
193
+ del cNvPr.attrib["title"]
194
+ return
195
+ if not isinstance(value, str):
196
+ raise TypeError(f"title_text must be a string or None; got {type(value).__name__}")
197
+ cNvPr.set("title", value)
198
+
199
+ @property
200
+ def part(self) -> BaseSlidePart:
201
+ """The package part containing this shape.
202
+
203
+ A |BaseSlidePart| subclass in this case. Access to a slide part should only be required if
204
+ you are extending the behavior of |pp| API objects.
205
+ """
206
+ return cast("BaseSlidePart", self._parent.part)
207
+
208
+ @property
209
+ def placeholder_format(self) -> _PlaceholderFormat:
210
+ """Provides access to placeholder-specific properties such as placeholder type.
211
+
212
+ Raises |ValueError| on access if the shape is not a placeholder.
213
+ """
214
+ ph = self._element.ph
215
+ if ph is None:
216
+ raise ValueError("shape is not a placeholder")
217
+ return _PlaceholderFormat(ph)
218
+
219
+ @property
220
+ def rotation(self) -> float:
221
+ """Degrees of clockwise rotation.
222
+
223
+ Read/write float. Negative values can be assigned to indicate counter-clockwise rotation,
224
+ e.g. assigning -45.0 will change setting to 315.0.
225
+ """
226
+ return self._element.rot
227
+
228
+ @rotation.setter
229
+ def rotation(self, value: float):
230
+ self._element.rot = value
231
+
232
+ @lazyproperty
233
+ def blur(self) -> BlurFormat:
234
+ """|BlurFormat| object providing access to the Gaussian blur effect.
235
+
236
+ Always returned, even when no blur is explicitly set. Reading
237
+ ``blur.radius`` returns None in that case.
238
+ """
239
+ return BlurFormat(self._element.spPr)
240
+
241
+ @lazyproperty
242
+ def glow(self) -> GlowFormat:
243
+ """|GlowFormat| object providing access to glow effect for this shape.
244
+
245
+ A |GlowFormat| object is always returned even when no glow is explicitly
246
+ defined. Reading ``glow.radius`` returns None in that case.
247
+ """
248
+ return GlowFormat(self._element.spPr)
249
+
250
+ @lazyproperty
251
+ def reflection(self) -> ReflectionFormat:
252
+ """|ReflectionFormat| object providing access to the reflection effect.
253
+
254
+ Always returned, even when no reflection is explicitly set. Reads of
255
+ the individual properties return None in that case.
256
+ """
257
+ return ReflectionFormat(self._element.spPr)
258
+
259
+ @lazyproperty
260
+ def shadow(self) -> ShadowFormat | None:
261
+ """|ShadowFormat| object providing access to shadow for this shape.
262
+
263
+ For ordinary shapes (autoshapes, pictures, group shapes, connectors)
264
+ a |ShadowFormat| facade is always returned, even when no shadow is
265
+ explicitly defined — its individual properties return ``None`` in
266
+ that case.
267
+
268
+ :class:`~pptx2.shapes.graphfrm.GraphicFrame` returns ``None``
269
+ instead of a facade: charts and tables expose effects at
270
+ content-specific locations that the unified |ShadowFormat| API
271
+ doesn't apply to. Callers that probe ``shape.shadow`` across every
272
+ shape on a slide should branch on ``if shape.shadow is None`` to
273
+ skip GraphicFrames cleanly.
274
+ """
275
+ return ShadowFormat(self._element.spPr)
276
+
277
+ @lazyproperty
278
+ def inner_shadow(self) -> InnerShadowFormat:
279
+ """|InnerShadowFormat| object providing access to the inner-shadow effect.
280
+
281
+ An |InnerShadowFormat| facade is always returned, even when no inner
282
+ shadow is explicitly defined — its individual properties
283
+ (``blur_radius``, ``distance``, ``direction``, ``color``) return
284
+ ``None`` in that case.
285
+ """
286
+ return InnerShadowFormat(self._element.spPr)
287
+
288
+ @lazyproperty
289
+ def preset_shadow(self) -> PresetShadowFormat:
290
+ """|PresetShadowFormat| object providing access to the preset-shadow effect.
291
+
292
+ A |PresetShadowFormat| facade is always returned, even when no preset
293
+ shadow is explicitly defined — ``preset`` returns ``None`` in that
294
+ case. Assign ``preset_shadow.preset`` an :class:`MSO_PRESET_SHADOW`
295
+ member or a ``"shdw1".."shdw20"`` string to apply one.
296
+ """
297
+ return PresetShadowFormat(self._element.spPr)
298
+
299
+ @lazyproperty
300
+ def soft_edges(self) -> SoftEdgeFormat:
301
+ """|SoftEdgeFormat| object providing access to soft-edge effect for this shape.
302
+
303
+ A |SoftEdgeFormat| object is always returned even when no soft-edge is
304
+ explicitly defined. Reading ``soft_edges.radius`` returns None in that case.
305
+ """
306
+ return SoftEdgeFormat(self._element.spPr)
307
+
308
+ @lazyproperty
309
+ def style(self) -> ShapeStyle:
310
+ """Token-resolving design-system facade for this shape.
311
+
312
+ Returns a :class:`pptx2.design.style.ShapeStyle` whose setters
313
+ accept :class:`pptx2.design.tokens` values (palette colors,
314
+ shadow tokens, typography tokens) and fan them out into the
315
+ shape's underlying ``fill`` / ``line`` / ``shadow`` proxies.
316
+
317
+ Example::
318
+
319
+ shape.style.fill = tokens.palette["primary"]
320
+ shape.style.shadow = tokens.shadows["card"]
321
+ shape.style.font = tokens.typography["body"]
322
+ """
323
+ from pptx2.design.style import ShapeStyle
324
+
325
+ return ShapeStyle(self)
326
+
327
+ @lazyproperty
328
+ def three_d(self) -> ThreeDFormat:
329
+ """|ThreeDFormat| object providing access to 3-D formatting for this shape.
330
+
331
+ A |ThreeDFormat| object is always returned even when no 3-D properties are
332
+ explicitly defined. Reading e.g. ``three_d.bevel_top.preset`` returns None in that case.
333
+
334
+ Example::
335
+
336
+ from pptx2.enum.dml import BevelPreset, PresetMaterial
337
+ from pptx2.util import Pt
338
+
339
+ shape.three_d.bevel_top.preset = BevelPreset.CIRCLE
340
+ shape.three_d.bevel_top.width = Pt(4)
341
+ shape.three_d.extrusion_height = Pt(6)
342
+ shape.three_d.preset_material = PresetMaterial.MATTE
343
+ """
344
+ return ThreeDFormat(self._element.spPr)
345
+
346
+ @property
347
+ def shape_id(self) -> int:
348
+ """Read-only positive integer identifying this shape.
349
+
350
+ The id of a shape is unique among all shapes on a slide.
351
+ """
352
+ return self._element.shape_id
353
+
354
+ @property
355
+ def lint_group(self) -> str | None:
356
+ """Group tag consulted by the layout linter to suppress same-group collisions.
357
+
358
+ Shapes that share a non-empty ``lint_group`` may overlap without
359
+ producing a :class:`~pptx2.lint.ShapeCollision` warning. Shapes
360
+ with ``lint_group is None`` (the default) and shapes belonging to
361
+ different groups continue to warn on overlap.
362
+
363
+ The value is round-tripped through save/load via an ``<a:ext>``
364
+ element under the shape's ``cNvPr/extLst`` — the OOXML-sanctioned
365
+ extension mechanism. PowerPoint preserves the element verbatim and
366
+ does not flag it as unrecognised content.
367
+
368
+ Example::
369
+
370
+ card.lint_group = "kpi-card-1"
371
+ accent_bar.lint_group = "kpi-card-1"
372
+ # card and accent_bar may overlap without a lint warning.
373
+
374
+ Assigning ``None`` clears the tag.
375
+ """
376
+ from pptx2.lint import _read_lint_group
377
+
378
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
379
+ return _read_lint_group(cNvPr)
380
+
381
+ @lint_group.setter
382
+ def lint_group(self, value: str | None) -> None:
383
+ from pptx2.lint import _clear_lint_group, _write_lint_group
384
+
385
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
386
+ if value is None:
387
+ _clear_lint_group(cNvPr)
388
+ return
389
+ if not isinstance(value, str):
390
+ raise ValueError("lint_group must be a string, an empty string, or None")
391
+ # Empty string is the explicit "no group" sentinel — overrides
392
+ # any implicit name-prefix group the linter would otherwise
393
+ # infer from a dotted shape name. Persist it verbatim rather
394
+ # than clearing so the override round-trips.
395
+ _write_lint_group(cNvPr, value)
396
+
397
+ def animate(
398
+ self,
399
+ *,
400
+ entry: str | None = None,
401
+ exit: str | None = None,
402
+ emphasis: str | None = None,
403
+ trigger: str = "on_click",
404
+ delay_ms: int = 0,
405
+ duration_ms: int = 500,
406
+ direction: str | None = None,
407
+ ) -> None:
408
+ """Add a constrained-subset animation to this shape.
409
+
410
+ A small façade over the full :mod:`pptx2.animation` API for
411
+ the five most common cases. Heavy animation use is rarely
412
+ appropriate in a professional deck, so the surface is
413
+ deliberately narrow:
414
+
415
+ Pass exactly one of ``entry``, ``exit``, or ``emphasis``.
416
+ Recognised presets:
417
+
418
+ * ``entry``: ``"fade"``, ``"appear"``, ``"fly_in"``,
419
+ ``"float_in"``, ``"wipe"``, ``"zoom"``, ``"wheel"``,
420
+ ``"random_bars"``.
421
+ * ``exit``: ``"fade"``, ``"disappear"``, ``"fly_out"``,
422
+ ``"float_out"``, ``"wipe"``, ``"zoom"``, ``"wheel"``,
423
+ ``"random_bars"``.
424
+ * ``emphasis``: ``"pulse"``, ``"spin"``, ``"teeter"``.
425
+
426
+ ``trigger`` is one of ``"on_click"``, ``"with_previous"``,
427
+ ``"after_previous"``. ``delay_ms`` and ``duration_ms`` are
428
+ OOXML milliseconds. ``direction`` is consumed by ``fly_in`` /
429
+ ``fly_out`` / ``wipe`` (``"left"``, ``"right"``, ``"top"``,
430
+ ``"bottom"``); ignored otherwise.
431
+
432
+ For animation types not covered here, drop down to
433
+ :class:`pptx2.animation.Entrance` /
434
+ :class:`~pptx2.animation.Exit` /
435
+ :class:`~pptx2.animation.Emphasis` directly.
436
+ """
437
+ kinds_set = sum(1 for v in (entry, exit, emphasis) if v is not None)
438
+ if kinds_set != 1:
439
+ raise ValueError(
440
+ "Pass exactly one of entry=, exit=, emphasis=; "
441
+ f"got entry={entry!r}, exit={exit!r}, emphasis={emphasis!r}"
442
+ )
443
+
444
+ from pptx2.animation import Emphasis, Entrance, Exit
445
+ from pptx2.enum.animation import PP_ANIM_TRIGGER
446
+
447
+ try:
448
+ slide = self.part.slide # type: ignore[attr-defined]
449
+ except AttributeError as exc:
450
+ raise ValueError(
451
+ "shape.animate() requires the shape to be on a slide"
452
+ ) from exc
453
+
454
+ trigger_map = {
455
+ "on_click": PP_ANIM_TRIGGER.ON_CLICK,
456
+ "with_previous": PP_ANIM_TRIGGER.WITH_PREVIOUS,
457
+ "after_previous": PP_ANIM_TRIGGER.AFTER_PREVIOUS,
458
+ }
459
+ if trigger not in trigger_map:
460
+ raise ValueError(
461
+ f"trigger must be one of {sorted(trigger_map)}; got {trigger!r}"
462
+ )
463
+ trig = trigger_map[trigger]
464
+
465
+ common_kwargs = {"trigger": trig, "delay": int(delay_ms)}
466
+
467
+ def _call(facade_method, preset, *, supports_direction=False):
468
+ kwargs = dict(common_kwargs)
469
+ if preset != "appear" and preset != "disappear":
470
+ kwargs["duration"] = int(duration_ms)
471
+ if supports_direction and direction is not None:
472
+ kwargs["direction"] = direction
473
+ facade_method(slide, self, **kwargs)
474
+
475
+ if entry is not None:
476
+ preset = entry
477
+ method = getattr(Entrance, preset, None)
478
+ if method is None:
479
+ raise ValueError(f"unknown entry preset: {preset!r}")
480
+ _call(method, preset, supports_direction=preset in ("fly_in", "wipe"))
481
+ elif exit is not None:
482
+ preset = exit
483
+ method = getattr(Exit, preset, None)
484
+ if method is None:
485
+ raise ValueError(f"unknown exit preset: {preset!r}")
486
+ _call(method, preset, supports_direction=preset in ("fly_out", "wipe"))
487
+ else: # emphasis
488
+ preset = emphasis # type: ignore[assignment]
489
+ method = getattr(Emphasis, preset, None)
490
+ if method is None:
491
+ raise ValueError(f"unknown emphasis preset: {preset!r}")
492
+ _call(method, preset)
493
+
494
+ @property
495
+ def lint_skip(self) -> frozenset[str]:
496
+ """Lint check codes silenced on this shape.
497
+
498
+ Per-shape opt-out for the linter: any :class:`LintIssue` whose
499
+ ``code`` is in this set is dropped from the report when ``slide.lint()``
500
+ is called. Cross-shape issues (e.g. ``ShapeCollision``,
501
+ ``ZOrderAnomaly``) are only suppressed when *both* shapes opt out —
502
+ a one-sided opt-out keeps the warning, since the other shape may
503
+ still want it surfaced.
504
+
505
+ Example — silence intentional 8pt chrome::
506
+
507
+ footer_label.lint_skip = {"MinFontSize"}
508
+ rag_pill.lint_skip = {"MinFontSize"}
509
+
510
+ Stored alongside ``lint_group`` in the same ``cNvPr/extLst/ext``
511
+ block so it round-trips through save/load. Assign ``set()`` /
512
+ ``frozenset()`` to clear.
513
+ """
514
+ from pptx2.lint import _read_lint_skip
515
+
516
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
517
+ return _read_lint_skip(cNvPr)
518
+
519
+ @lint_skip.setter
520
+ def lint_skip(self, value) -> None:
521
+ from pptx2.lint import _write_lint_skip
522
+
523
+ if value is None:
524
+ value = frozenset()
525
+ if not isinstance(value, (set, frozenset, list, tuple)):
526
+ raise TypeError(
527
+ "lint_skip must be a set/frozenset/list/tuple of issue "
528
+ f"codes; got {type(value).__name__}"
529
+ )
530
+ # Validate each code: must be a non-empty trimmed string with no
531
+ # commas (the on-disk form is comma-joined, so a comma in a code
532
+ # would corrupt the round-trip). Trim whitespace so callers
533
+ # don't have to be precious about formatting.
534
+ codes: set[str] = set()
535
+ for raw in value:
536
+ if not isinstance(raw, str):
537
+ raise TypeError(
538
+ "lint_skip codes must be strings; got "
539
+ f"{type(raw).__name__}"
540
+ )
541
+ code = raw.strip()
542
+ if not code:
543
+ raise ValueError("lint_skip codes must be non-empty strings")
544
+ if "," in code:
545
+ raise ValueError(
546
+ f"lint_skip code {raw!r} contains ',', which is reserved "
547
+ "as the on-disk separator"
548
+ )
549
+ codes.add(code)
550
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
551
+ _write_lint_skip(cNvPr, frozenset(codes))
552
+
553
+ def allow_overlap_with(self, *shapes: "BaseShape") -> None:
554
+ """Declare that overlapping each shape in *shapes* is intentional.
555
+
556
+ The narrow counterpart to :attr:`lint_group`. A ``lint_group`` is
557
+ n-ary and symmetric — every shape sharing the tag may overlap every
558
+ other one. An allowance licenses exactly one pair, which is what you
559
+ want for "this badge may sit on this card, but nothing else"::
560
+
561
+ badge.allow_overlap_with(card)
562
+
563
+ The declaration is one-sided to write but read symmetrically: it
564
+ takes only one of the pair to vouch for the overlap. Calling it on
565
+ either shape is equivalent, and calling it on both is harmless.
566
+
567
+ Allowances accumulate, so repeated calls add to the set rather than
568
+ replacing it. Clear them with :meth:`disallow_overlap_with` (one
569
+ pair) or by assigning ``shape.overlap_allowances = ()``.
570
+
571
+ Stored as shape ids in the same ``cNvPr/extLst/ext`` block as
572
+ ``lint_group`` and ``lint_skip``, so it round-trips through
573
+ save/load.
574
+
575
+ Raises:
576
+ ValueError: if any argument is this same shape, or if either
577
+ shape has no usable shape id.
578
+ """
579
+ from pptx2.lint import _read_lint_allow, _write_lint_allow
580
+
581
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
582
+ ids = set(_read_lint_allow(cNvPr))
583
+ for other in shapes:
584
+ other_id = self._require_shape_id(other)
585
+ if other_id == self.shape_id:
586
+ raise ValueError(
587
+ "a shape cannot be given an overlap allowance with itself"
588
+ )
589
+ ids.add(other_id)
590
+ _write_lint_allow(cNvPr, ids)
591
+
592
+ def disallow_overlap_with(self, *shapes: "BaseShape") -> None:
593
+ """Revoke the overlap allowance for each shape in *shapes*.
594
+
595
+ The inverse of :meth:`allow_overlap_with`. Revoking an allowance
596
+ that was never granted is a no-op rather than an error, so callers
597
+ can clear defensively.
598
+
599
+ Note this only clears the allowance recorded *on this shape*. If the
600
+ pair was vouched for from the other side as well, the overlap stays
601
+ suppressed until that one is revoked too.
602
+ """
603
+ from pptx2.lint import _read_lint_allow, _write_lint_allow
604
+
605
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
606
+ ids = set(_read_lint_allow(cNvPr))
607
+ for other in shapes:
608
+ ids.discard(self._require_shape_id(other))
609
+ _write_lint_allow(cNvPr, ids)
610
+
611
+ def _require_shape_id(self, shape: "BaseShape") -> int:
612
+ """Return *shape*'s id, raising a useful error when it is unusable.
613
+
614
+ Two things make a shape unusable as an allowance target. It may have
615
+ no ``cNvPr`` and therefore no id to key on. Or it may live on a
616
+ different slide: shape ids are unique only *within* a slide, so an
617
+ id borrowed from another slide either collides with this shape's own
618
+ id — reading as a bogus self-reference — or silently matches an
619
+ unrelated shape here and suppresses a collision that was real.
620
+ """
621
+ try:
622
+ shape_id = shape.shape_id
623
+ except (AttributeError, TypeError, ValueError) as exc:
624
+ raise ValueError(
625
+ f"{type(shape).__name__} has no shape id, so it cannot take "
626
+ "part in an overlap allowance; tag both shapes with a shared "
627
+ "lint_group instead"
628
+ ) from exc
629
+ if not self._on_same_slide_as(shape):
630
+ raise ValueError(
631
+ f"cannot record an overlap allowance with {shape.name!r}: it "
632
+ "is on a different slide. Shape ids are only unique within a "
633
+ "slide, so an allowance can only name a shape on this one."
634
+ )
635
+ return shape_id
636
+
637
+ def _on_same_slide_as(self, shape: "BaseShape") -> bool:
638
+ """Return True unless *shape* is known to live on another slide.
639
+
640
+ Shapes on one slide share a part, including shapes nested in groups.
641
+ When either part cannot be resolved — a shape built outside a
642
+ package, as unit tests do — this answers ``True``: the check exists
643
+ to catch a real mistake, not to make detached shapes unusable.
644
+ """
645
+ try:
646
+ return self.part is shape.part
647
+ except Exception:
648
+ return True
649
+
650
+ @property
651
+ def overlap_allowances(self) -> frozenset[int]:
652
+ """Shape ids this shape has been cleared to overlap.
653
+
654
+ Read the set granted by :meth:`allow_overlap_with`. Note this
655
+ reflects only the allowances recorded on *this* shape — an overlap
656
+ may also be suppressed by an allowance held on the other shape, or
657
+ by a shared :attr:`lint_group`.
658
+
659
+ Assign an iterable of shape ids (or an empty one to clear). Most
660
+ callers want :meth:`allow_overlap_with` instead, which takes shapes
661
+ rather than raw ids and accumulates.
662
+ """
663
+ from pptx2.lint import _read_lint_allow
664
+
665
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
666
+ return _read_lint_allow(cNvPr)
667
+
668
+ @overlap_allowances.setter
669
+ def overlap_allowances(self, value) -> None:
670
+ from pptx2.lint import _write_lint_allow
671
+
672
+ if value is None:
673
+ value = ()
674
+ if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
675
+ raise TypeError(
676
+ "overlap_allowances must be an iterable of shape ids; got "
677
+ f"{type(value).__name__}"
678
+ )
679
+ ids: set[int] = set()
680
+ for raw in value:
681
+ # bool is an int subclass, and True/False are never valid ids.
682
+ if isinstance(raw, bool) or not isinstance(raw, int):
683
+ raise TypeError(
684
+ "overlap_allowances entries must be integer shape ids; "
685
+ f"got {type(raw).__name__}"
686
+ )
687
+ ids.add(raw)
688
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
689
+ _write_lint_allow(cNvPr, ids)
690
+
691
+ @property
692
+ def layer(self) -> str | None:
693
+ """Name of the visual layer this shape belongs to.
694
+
695
+ Layer hints are the third way to declare an intentional overlap, and
696
+ the only one that asserts a *direction*. A shape names its own layer
697
+ with :attr:`layer`; a shape that means to sit on top of that layer
698
+ names it in :attr:`layer_above`::
699
+
700
+ card.layer = "card"
701
+ badge.layer_above = "card"
702
+
703
+ Overlaps that agree with the declaration are treated as intentional
704
+ and stay out of the report. An overlap that *contradicts* it — the
705
+ shape claiming to be on top is drawn underneath — is reported as a
706
+ :class:`~pptx2.lint.LayerOrderViolation` error, since the
707
+ declaration records what the author meant and the drawing order is
708
+ what fails to deliver it.
709
+
710
+ Unlike :attr:`lint_group`, a layer name may be shared by any number
711
+ of unrelated shapes: it describes a stratum of the design, not one
712
+ grouped cluster. Assign ``None`` to clear.
713
+ """
714
+ from pptx2.lint import _read_lint_layer
715
+
716
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
717
+ return _read_lint_layer(cNvPr)[0]
718
+
719
+ @layer.setter
720
+ def layer(self, value: str | None) -> None:
721
+ from pptx2.lint import _read_lint_layer, _write_lint_layer
722
+
723
+ value = self._validate_layer_name(value, "layer")
724
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
725
+ _, above = _read_lint_layer(cNvPr)
726
+ _write_lint_layer(cNvPr, name=value, above=above)
727
+
728
+ @property
729
+ def layer_above(self) -> str | None:
730
+ """Name of the layer this shape declares it is drawn on top of.
731
+
732
+ See :attr:`layer` for the full picture. Assign ``None`` to clear.
733
+ """
734
+ from pptx2.lint import _read_lint_layer
735
+
736
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
737
+ return _read_lint_layer(cNvPr)[1]
738
+
739
+ @layer_above.setter
740
+ def layer_above(self, value: str | None) -> None:
741
+ from pptx2.lint import _read_lint_layer, _write_lint_layer
742
+
743
+ value = self._validate_layer_name(value, "layer_above")
744
+ cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
745
+ name, _ = _read_lint_layer(cNvPr)
746
+ _write_lint_layer(cNvPr, name=name, above=value)
747
+
748
+ @staticmethod
749
+ def _validate_layer_name(value: str | None, attr: str) -> str | None:
750
+ """Normalise a layer name, or raise if it is unusable.
751
+
752
+ An all-whitespace string is treated as ``None`` (a clear) rather
753
+ than as a layer literally named ``" "``, which is never what the
754
+ caller meant.
755
+ """
756
+ if value is None:
757
+ return None
758
+ if not isinstance(value, str):
759
+ raise TypeError(
760
+ f"{attr} must be a string or None; got {type(value).__name__}"
761
+ )
762
+ value = value.strip()
763
+ return value or None
764
+
765
+ def delete(self) -> None:
766
+ """Remove this shape from its slide and clean up dependent state.
767
+
768
+ In addition to removing the shape's XML element, this purges the
769
+ references other parts of the slide still hold to it:
770
+
771
+ * animation entries in the timing tree that targeted this shape.
772
+ PowerPoint silently "repairs" decks with orphan timing
773
+ references on open, but a clean tree avoids the prompt.
774
+ * overlap allowances naming this shape's id. Ids are recycled --
775
+ the allocator hands out ``max(existing) + 1``, so deleting the
776
+ highest-id shape frees its id for the next shape added after a
777
+ save/reopen. A leftover allowance would then match that
778
+ unrelated newcomer and silently suppress a real
779
+ :class:`~pptx2.lint.ShapeCollision`.
780
+
781
+ Equivalent in spirit to::
782
+
783
+ shape._element.getparent().remove(shape._element)
784
+
785
+ but with the cleanup passes that the manual idiom misses.
786
+ """
787
+ # Snapshot the slide reference and this shape's id *before*
788
+ # detaching the element, because once detached the parent walk
789
+ # would fail.
790
+ slide = None
791
+ try:
792
+ slide = self.part.slide # type: ignore[attr-defined]
793
+ except Exception:
794
+ slide = None
795
+ # Deleting a group takes its descendants with it, so every id
796
+ # about to disappear has to be collected, not just this one.
797
+ deleted_ids = self._descendant_shape_ids()
798
+
799
+ parent = self._element.getparent()
800
+ if parent is not None:
801
+ parent.remove(self._element)
802
+
803
+ if slide is not None:
804
+ try:
805
+ slide.animations.purge_orphans()
806
+ except Exception:
807
+ pass
808
+ if deleted_ids:
809
+ self._purge_overlap_allowances(slide, deleted_ids)
810
+
811
+ def _descendant_shape_ids(self) -> "frozenset[int]":
812
+ """Return this shape's id plus every id nested beneath it.
813
+
814
+ Removing a group's element removes its members with it, so all of
815
+ their ids go stale at once. Best-effort: an id that cannot be read
816
+ is skipped rather than failing the delete.
817
+ """
818
+ ids: set[int] = set()
819
+
820
+ def _collect(shape) -> None:
821
+ with contextlib.suppress(Exception):
822
+ ids.add(shape.shape_id)
823
+ nested = getattr(shape, "shapes", None)
824
+ if nested is None:
825
+ return
826
+ try:
827
+ members = list(nested)
828
+ except Exception:
829
+ return
830
+ for member in members:
831
+ _collect(member)
832
+
833
+ _collect(self)
834
+ return frozenset(ids)
835
+
836
+ @staticmethod
837
+ def _purge_overlap_allowances(slide, deleted_ids: "frozenset[int]") -> None:
838
+ """Drop every id in *deleted_ids* from allowances on *slide*.
839
+
840
+ Best-effort and never fatal: deleting a shape must not start
841
+ raising because some sibling has unreadable lint metadata.
842
+ """
843
+ from pptx2.lint import _read_lint_allow, _shape_cNvPr, _write_lint_allow
844
+
845
+ def _walk(shapes):
846
+ for shape in shapes:
847
+ yield shape
848
+ nested = getattr(shape, "shapes", None)
849
+ if nested is not None:
850
+ try:
851
+ yield from _walk(nested)
852
+ except Exception:
853
+ continue
854
+
855
+ try:
856
+ shapes = list(_walk(slide.shapes))
857
+ except Exception:
858
+ return
859
+ for shape in shapes:
860
+ try:
861
+ cNvPr = _shape_cNvPr(shape)
862
+ if cNvPr is None:
863
+ continue
864
+ allowances = _read_lint_allow(cNvPr)
865
+ if allowances & deleted_ids:
866
+ _write_lint_allow(cNvPr, allowances - deleted_ids)
867
+ except Exception:
868
+ continue
869
+
870
+ @property
871
+ def shape_type(self) -> MSO_SHAPE_TYPE:
872
+ """A member of MSO_SHAPE_TYPE classifying this shape by type.
873
+
874
+ Like ``MSO_SHAPE_TYPE.CHART``. Must be implemented by subclasses.
875
+ """
876
+ raise NotImplementedError(f"{type(self).__name__} does not implement `.shape_type`")
877
+
878
+ @property
879
+ def top(self) -> Length:
880
+ """Distance from the top edge of the slide to the top edge of this shape.
881
+
882
+ Read/write. Expressed in English Metric Units (EMU)
883
+ """
884
+ return self._element.y
885
+
886
+ @top.setter
887
+ def top(self, value: Length):
888
+ self._element.y = _coerce_emu(value)
889
+
890
+ @property
891
+ def width(self) -> Length:
892
+ """Distance between left and right extents of this shape.
893
+
894
+ Read/write. Expressed in English Metric Units (EMU).
895
+ """
896
+ return self._element.cx
897
+
898
+ @width.setter
899
+ def width(self, value: Length):
900
+ self._element.cx = _coerce_emu(value)
901
+
902
+ @property
903
+ def bbox(self):
904
+ """Return the shape's geometry as an immutable :class:`BBox`.
905
+
906
+ ``shape.bbox`` is a snapshot — mutating the shape afterwards
907
+ does not update the box. Use :meth:`BBox.apply_to` to push a
908
+ new box back onto the shape.
909
+
910
+ Example::
911
+
912
+ from pptx2 import BBox
913
+
914
+ inner = shape.bbox.inset(all=Inches(0.2))
915
+ slide.shapes.add_textbox(*inner)
916
+ """
917
+ from pptx2.geometry import BBox
918
+
919
+ return BBox.from_shape(self)
920
+
921
+ def fill_hex(self, hex_color: "str | None") -> "BaseShape":
922
+ """Set a solid fill from a hex string (``"#RRGGBB"`` or ``"RRGGBB"``).
923
+
924
+ Convenience for the three-line ``shape.fill.solid();
925
+ shape.fill.fore_color.rgb = RGBColor(...)`` dance. Returns
926
+ ``self`` so calls can be chained.
927
+
928
+ Example::
929
+
930
+ slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, *box).fill_hex("#0B5CFF")
931
+
932
+ Pass ``None`` to clear the fill (the shape inherits from its
933
+ theme afterwards). Hex strings, ``RGBColor`` instances, and
934
+ ``(r, g, b)`` tuples are all accepted.
935
+ """
936
+ from pptx2._color import coerce_color
937
+
938
+ if hex_color is None:
939
+ # ``fill.background()`` produces a transparent (no-fill)
940
+ # solid; the closest thing to "clear" without ripping the
941
+ # element out wholesale.
942
+ try:
943
+ self.fill.background() # type: ignore[attr-defined]
944
+ except AttributeError as exc:
945
+ raise AttributeError(
946
+ f"{type(self).__name__} does not support fill"
947
+ ) from exc
948
+ return self
949
+ try:
950
+ fill = self.fill # type: ignore[attr-defined]
951
+ except AttributeError as exc:
952
+ raise AttributeError(
953
+ f"{type(self).__name__} does not support fill"
954
+ ) from exc
955
+ fill.solid()
956
+ fill.fore_color.rgb = coerce_color(hex_color)
957
+ return self
958
+
959
+ def line_hex(
960
+ self,
961
+ hex_color: "str | None",
962
+ *,
963
+ weight_pt: float | None = None,
964
+ ) -> "BaseShape":
965
+ """Set the line stroke from a hex string (``"#RRGGBB"``).
966
+
967
+ Optional ``weight_pt`` sets the stroke width in points. Returns
968
+ ``self`` so calls can be chained.
969
+
970
+ Example::
971
+
972
+ slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, *box).line_hex(
973
+ "#0D0D0D", weight_pt=1.25,
974
+ )
975
+ """
976
+ from pptx2._color import coerce_color
977
+ from pptx2.util import Pt
978
+
979
+ try:
980
+ line = self.line # type: ignore[attr-defined]
981
+ except AttributeError as exc:
982
+ raise AttributeError(
983
+ f"{type(self).__name__} does not support line"
984
+ ) from exc
985
+ if hex_color is None:
986
+ line.fill.background()
987
+ else:
988
+ line.color.rgb = coerce_color(hex_color)
989
+ if weight_pt is not None:
990
+ line.width = Pt(float(weight_pt))
991
+ return self
992
+
993
+ def set_text_preserving_format(self, new_text: str) -> "BaseShape":
994
+ """Replace all text in this shape with ``new_text``, keeping run formatting.
995
+
996
+ Captures the first run's character properties (``<a:rPr>``) and
997
+ the first paragraph's properties (``<a:pPr>``), rebuilds the
998
+ text body to hold ``new_text`` (one paragraph per ``\\n``), then
999
+ re-applies those properties to every new run and paragraph.
1000
+
1001
+ Font face / size / colour / bold / italic on that first run are
1002
+ preserved verbatim — useful when overwriting a templated
1003
+ placeholder (e.g. ``"<TITLE>"``) without losing the designer's
1004
+ font choices.
1005
+
1006
+ Example::
1007
+
1008
+ shape.set_text_preserving_format("Q4 revenue overview")
1009
+
1010
+ Raises :class:`ValueError` if the shape has no text frame.
1011
+ """
1012
+ if not getattr(self, "has_text_frame", False):
1013
+ raise ValueError(
1014
+ f"shape {self.name!r} has no text frame; can't replace text"
1015
+ )
1016
+ tf = self.text_frame # type: ignore[attr-defined]
1017
+
1018
+ from copy import deepcopy
1019
+
1020
+ rPr_template = None
1021
+ pPr_template = None
1022
+ first_para = tf.paragraphs[0] if tf.paragraphs else None
1023
+ if first_para is not None:
1024
+ pPr = first_para._p.pPr # type: ignore[attr-defined]
1025
+ if pPr is not None:
1026
+ pPr_template = deepcopy(pPr)
1027
+ if first_para.runs:
1028
+ rPr = first_para.runs[0]._r.rPr # type: ignore[attr-defined]
1029
+ if rPr is not None:
1030
+ rPr_template = deepcopy(rPr)
1031
+
1032
+ # Rebuild the body using the high-level text setter; this gives
1033
+ # us one paragraph per "\n" with a single run per paragraph.
1034
+ tf.text = new_text if new_text else ""
1035
+
1036
+ for para in tf.paragraphs:
1037
+ p_elm = para._p # type: ignore[attr-defined]
1038
+ if pPr_template is not None:
1039
+ existing_pPr = p_elm.pPr
1040
+ if existing_pPr is not None:
1041
+ p_elm._remove_pPr()
1042
+ p_elm._insert_pPr(deepcopy(pPr_template))
1043
+ if rPr_template is not None:
1044
+ for run in para.runs:
1045
+ r_elm = run._r # type: ignore[attr-defined]
1046
+ if r_elm.rPr is not None:
1047
+ r_elm._remove_rPr()
1048
+ r_elm._insert_rPr(deepcopy(rPr_template))
1049
+ return self
1050
+
1051
+
1052
+ class _PlaceholderFormat(ElementProxy):
1053
+ """Provides properties specific to placeholders, such as the placeholder type.
1054
+
1055
+ Accessed via the :attr:`~.BaseShape.placeholder_format` property of a placeholder shape,
1056
+ """
1057
+
1058
+ def __init__(self, element: CT_Placeholder):
1059
+ super().__init__(element)
1060
+ self._ph = element
1061
+
1062
+ @property
1063
+ def element(self) -> CT_Placeholder:
1064
+ """The `p:ph` element proxied by this object."""
1065
+ return self._ph
1066
+
1067
+ @property
1068
+ def idx(self) -> int:
1069
+ """Integer placeholder 'idx' attribute."""
1070
+ return self._ph.idx
1071
+
1072
+ @property
1073
+ def type(self) -> PP_PLACEHOLDER:
1074
+ """Placeholder type.
1075
+
1076
+ A member of the :ref:`PpPlaceholderType` enumeration, e.g. PP_PLACEHOLDER.CHART
1077
+ """
1078
+ return self._ph.type