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/accessibility.py ADDED
@@ -0,0 +1,365 @@
1
+ """Read-only accessibility audit for a generated deck.
2
+
3
+ Decks produced programmatically routinely ship without the metadata a
4
+ screen reader needs — pictures with no alt text, slides with no title,
5
+ text whose contrast against its fill falls below WCAG AA. These slip
6
+ past the layout linter (the deck *looks* fine) but make the result
7
+ unusable for assistive technology.
8
+
9
+ :func:`audit_accessibility` walks every slide and shape and returns a
10
+ small structured :class:`AccessibilityReport` so an agent that builds a
11
+ deck can answer "is this accessible?" without crawling the slides by
12
+ hand::
13
+
14
+ from pptx2 import accessibility
15
+
16
+ report = accessibility.audit_accessibility(prs)
17
+ print(report.markdown())
18
+ if report.has_errors:
19
+ ...
20
+
21
+ The audit is strictly read-only — it never mutates the deck.
22
+
23
+ Issue codes:
24
+
25
+ * ``MissingAltText`` — a picture or other meaningful shape carries no
26
+ ``alt_text`` (``<p:cNvPr descr=...>``). Pictures are ERROR (a screen
27
+ reader has nothing to announce); other meaningful shapes are WARNING.
28
+ * ``LowContrast`` — text-on-fill contrast is below WCAG AA (4.5:1),
29
+ reusing the contrast math from :mod:`pptx2.lint`.
30
+ * ``NoSlideTitle`` — a slide has no (non-empty) title, so it has no
31
+ landmark for navigation.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import asdict, dataclass, field
37
+ from enum import Enum
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ if TYPE_CHECKING:
41
+ from pptx2.api import Presentation
42
+ from pptx2.shapes.base import BaseShape
43
+ from pptx2.slide import Slide
44
+
45
+
46
+ __all__ = [
47
+ "AccessibilitySeverity",
48
+ "AccessibilityIssue",
49
+ "AccessibilityReport",
50
+ "audit_accessibility",
51
+ ]
52
+
53
+
54
+ class AccessibilitySeverity(str, Enum):
55
+ ERROR = "error"
56
+ WARNING = "warning"
57
+ INFO = "info"
58
+
59
+
60
+ @dataclass
61
+ class AccessibilityIssue:
62
+ """A single accessibility problem found on a slide.
63
+
64
+ ``slide`` is the zero-based slide index; ``shape`` is the offending
65
+ shape's name (``None`` for slide-level issues such as a missing
66
+ title). ``code`` is one of ``"MissingAltText"``, ``"LowContrast"``,
67
+ ``"NoSlideTitle"``.
68
+ """
69
+
70
+ slide: int
71
+ code: str
72
+ message: str
73
+ severity: AccessibilitySeverity = AccessibilitySeverity.WARNING
74
+ shape: str | None = None
75
+
76
+ def __str__(self) -> str:
77
+ where = f"slide {self.slide}"
78
+ if self.shape is not None:
79
+ where += f" / {self.shape!r}"
80
+ return f"[{self.severity.value.upper()}] {self.code} ({where}): {self.message}"
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Return a JSON-serializable dict describing this issue."""
84
+ payload = asdict(self)
85
+ payload["severity"] = self.severity.value
86
+ return payload
87
+
88
+
89
+ # Shape types that carry visual meaning and should describe themselves to a
90
+ # screen reader. Decorative chrome (plain text boxes, which already expose
91
+ # their text, and group containers) is handled separately below.
92
+ _MEANINGFUL_TYPE_NAMES = frozenset(
93
+ {
94
+ "PICTURE",
95
+ "LINKED_PICTURE",
96
+ "CHART",
97
+ "TABLE",
98
+ "DIAGRAM",
99
+ "IGX_GRAPHIC",
100
+ "MEDIA",
101
+ "EMBEDDED_OLE_OBJECT",
102
+ "LINKED_OLE_OBJECT",
103
+ "FREEFORM",
104
+ "WEB_VIDEO",
105
+ }
106
+ )
107
+
108
+ # Picture-like types are held to a higher standard: a screen reader has
109
+ # literally nothing to announce for them without alt text, so a missing
110
+ # description is an ERROR rather than a WARNING.
111
+ _PICTURE_TYPE_NAMES = frozenset({"PICTURE", "LINKED_PICTURE", "MEDIA", "WEB_VIDEO"})
112
+
113
+
114
+ def _shape_type_name(shape: "BaseShape") -> str | None:
115
+ """Return the ``MSO_SHAPE_TYPE`` member name for *shape*, or ``None``.
116
+
117
+ Some shapes (notably bare group children or proxies that don't
118
+ implement ``shape_type``) raise on access; treat those as untyped.
119
+ """
120
+ try:
121
+ st = shape.shape_type
122
+ except Exception:
123
+ return None
124
+ return getattr(st, "name", None)
125
+
126
+
127
+ def _iter_shapes_recursive(shapes: Any) -> "Any":
128
+ """Yield every shape, recursing into group members.
129
+
130
+ A meaningful image or text run inside a |GroupShape| still needs alt text
131
+ / adequate contrast, so the audit must look past the group container.
132
+ ``GroupShape.walk()`` already yields all descendants depth-first, so it is
133
+ only invoked on top-level groups (recursing on its output would
134
+ double-count nested groups).
135
+ """
136
+ for shape in shapes:
137
+ yield shape
138
+ if _shape_type_name(shape) == "GROUP":
139
+ try:
140
+ yield from shape.walk()
141
+ except Exception:
142
+ pass
143
+
144
+
145
+ def _has_text(shape: "BaseShape") -> bool:
146
+ try:
147
+ if not getattr(shape, "has_text_frame", False):
148
+ return False
149
+ return bool(shape.text_frame.text.strip()) # type: ignore[attr-defined]
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ def _slide_has_title(slide: "Slide") -> bool:
155
+ """Return ``True`` when *slide* has a title placeholder with text."""
156
+ try:
157
+ title = slide.shapes.title
158
+ except Exception:
159
+ title = None
160
+ if title is None:
161
+ return False
162
+ try:
163
+ return bool(title.text_frame.text.strip())
164
+ except Exception:
165
+ # A title placeholder exists; without readable text we still
166
+ # treat the landmark as present (the placeholder is navigable).
167
+ return True
168
+
169
+
170
+ def _check_alt_text(slide_idx: int, shape: "BaseShape") -> list[AccessibilityIssue]:
171
+ """Flag meaningful shapes that carry no alt text."""
172
+ type_name = _shape_type_name(shape)
173
+ if type_name is None:
174
+ return []
175
+ if type_name not in _MEANINGFUL_TYPE_NAMES:
176
+ return []
177
+ try:
178
+ alt = shape.alt_text
179
+ except Exception:
180
+ return []
181
+ if alt.strip():
182
+ return []
183
+ # A meaningful shape that also exposes its text content is partially
184
+ # self-describing; still flag it, but as a warning either way.
185
+ is_picture = type_name in _PICTURE_TYPE_NAMES
186
+ severity = AccessibilitySeverity.ERROR if is_picture else AccessibilitySeverity.WARNING
187
+ kind = "picture" if is_picture else type_name.replace("_", " ").lower()
188
+ return [
189
+ AccessibilityIssue(
190
+ slide=slide_idx,
191
+ code="MissingAltText",
192
+ message=f"{kind} has no alt text; set shape.alt_text for screen readers.",
193
+ severity=severity,
194
+ shape=shape.name,
195
+ )
196
+ ]
197
+
198
+
199
+ def _check_contrast(slide_idx: int, shape: "BaseShape", slide: "Slide") -> list[AccessibilityIssue]:
200
+ """Flag low text/background contrast, reusing lint's contrast math.
201
+
202
+ Imports the contrast helpers from :mod:`pptx2.lint` read-only.
203
+ Skips silently whenever colours can't be resolved (theme colours,
204
+ gradients, inherited backgrounds) — matching the linter's behaviour.
205
+ """
206
+ try:
207
+ from pptx2.lint import (
208
+ _CONTRAST_THRESHOLD,
209
+ _contrast_ratio,
210
+ _resolve_solid_rgb,
211
+ _slide_background_rgb,
212
+ )
213
+ except Exception:
214
+ return []
215
+
216
+ if not _has_text(shape):
217
+ return []
218
+
219
+ tf = shape.text_frame # type: ignore[attr-defined]
220
+ text_rgb = None
221
+ try:
222
+ for paragraph in tf.paragraphs:
223
+ for run in paragraph.runs:
224
+ try:
225
+ rgb = run.font.color.rgb
226
+ except (AttributeError, ValueError):
227
+ rgb = None
228
+ if rgb is not None:
229
+ text_rgb = rgb
230
+ break
231
+ if text_rgb is not None:
232
+ break
233
+ except Exception:
234
+ return []
235
+ if text_rgb is None:
236
+ return []
237
+
238
+ bg_rgb = None
239
+ try:
240
+ bg_rgb = _resolve_solid_rgb(shape.fill) # type: ignore[attr-defined]
241
+ except Exception:
242
+ bg_rgb = None
243
+ if bg_rgb is None:
244
+ bg_rgb = _slide_background_rgb(slide)
245
+ if bg_rgb is None:
246
+ return []
247
+
248
+ ratio = _contrast_ratio(text_rgb, bg_rgb)
249
+ if ratio >= _CONTRAST_THRESHOLD:
250
+ return []
251
+ return [
252
+ AccessibilityIssue(
253
+ slide=slide_idx,
254
+ code="LowContrast",
255
+ message=(
256
+ f"text-on-fill contrast {ratio:.2f}:1 is below WCAG AA "
257
+ f"({_CONTRAST_THRESHOLD:.1f}:1)."
258
+ ),
259
+ severity=AccessibilitySeverity.WARNING,
260
+ shape=shape.name,
261
+ )
262
+ ]
263
+
264
+
265
+ @dataclass
266
+ class AccessibilityReport:
267
+ """Structured summary returned by :func:`audit_accessibility`."""
268
+
269
+ issues: list[AccessibilityIssue] = field(default_factory=list)
270
+ total_slides: int = 0
271
+
272
+ @property
273
+ def has_errors(self) -> bool:
274
+ """True when at least one ERROR-severity issue is present."""
275
+ return any(i.severity == AccessibilitySeverity.ERROR for i in self.issues)
276
+
277
+ def markdown(self) -> str:
278
+ """Render the report as a markdown string suitable for chat replies."""
279
+ lines = [f"# Accessibility report — {self.total_slides} slide(s)"]
280
+ if not self.issues:
281
+ lines.append("")
282
+ lines.append("**No accessibility issues found.**")
283
+ return "\n".join(lines)
284
+
285
+ # Group by code so the reader sees "all the missing alt text"
286
+ # together rather than interleaved per slide.
287
+ by_code: dict[str, list[AccessibilityIssue]] = {}
288
+ for issue in self.issues:
289
+ by_code.setdefault(issue.code, []).append(issue)
290
+
291
+ for code in sorted(by_code):
292
+ bucket = by_code[code]
293
+ lines.append("")
294
+ lines.append(f"## {code} ({len(bucket)})")
295
+ for issue in bucket:
296
+ where = f"slide {issue.slide}"
297
+ if issue.shape is not None:
298
+ where += f" — `{issue.shape}`"
299
+ lines.append(f"- {where}: {issue.message}")
300
+ return "\n".join(lines)
301
+
302
+ def to_dict(self) -> dict[str, Any]:
303
+ """Return a JSON-serializable dict of the whole audit."""
304
+ return {
305
+ "total_slides": self.total_slides,
306
+ "has_errors": self.has_errors,
307
+ "issues": [issue.to_dict() for issue in self.issues],
308
+ }
309
+
310
+ def to_json(self, *, indent: int | None = 2) -> str:
311
+ """Return :meth:`to_dict` serialized as a JSON string."""
312
+ import json
313
+
314
+ return json.dumps(self.to_dict(), indent=indent)
315
+
316
+ def __str__(self) -> str:
317
+ return self.markdown()
318
+
319
+
320
+ def audit_accessibility(
321
+ prs: "Presentation",
322
+ *,
323
+ check_contrast: bool = True,
324
+ ) -> AccessibilityReport:
325
+ """Walk the deck and return an :class:`AccessibilityReport`.
326
+
327
+ Read-only — never mutates the presentation. Flags:
328
+
329
+ * pictures and other meaningful shapes missing ``alt_text``;
330
+ * text whose contrast against its fill/background is below WCAG AA
331
+ (when ``check_contrast`` is True and the colours are resolvable);
332
+ * slides with no (non-empty) title placeholder.
333
+ """
334
+ report = AccessibilityReport()
335
+ slides = list(prs.slides)
336
+ report.total_slides = len(slides)
337
+
338
+ for idx, slide in enumerate(slides):
339
+ if not _slide_has_title(slide):
340
+ report.issues.append(
341
+ AccessibilityIssue(
342
+ slide=idx,
343
+ code="NoSlideTitle",
344
+ message=(
345
+ "slide has no title; screen-reader users rely on the "
346
+ "title as a navigation landmark."
347
+ ),
348
+ severity=AccessibilitySeverity.WARNING,
349
+ shape=None,
350
+ )
351
+ )
352
+
353
+ for shape in _iter_shapes_recursive(slide.shapes):
354
+ report.issues.extend(_check_alt_text(idx, shape))
355
+ if check_contrast:
356
+ report.issues.extend(_check_contrast(idx, shape, slide))
357
+
358
+ # Order: errors first, then warnings, then info — mirrors lint.
359
+ _order = {
360
+ AccessibilitySeverity.ERROR: 0,
361
+ AccessibilitySeverity.WARNING: 1,
362
+ AccessibilitySeverity.INFO: 2,
363
+ }
364
+ report.issues.sort(key=lambda i: (_order.get(i.severity, 3), i.slide))
365
+ return report
pptx2/action.py ADDED
@@ -0,0 +1,270 @@
1
+ """Objects related to mouse click and hover actions on a shape or text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from pptx2.enum.action import PP_ACTION
8
+ from pptx2.opc.constants import RELATIONSHIP_TYPE as RT
9
+ from pptx2.shapes import Subshape
10
+ from pptx2.util import lazyproperty
11
+
12
+ if TYPE_CHECKING:
13
+ from pptx2.oxml.action import CT_Hyperlink
14
+ from pptx2.oxml.shapes.shared import CT_NonVisualDrawingProps
15
+ from pptx2.oxml.text import CT_TextCharacterProperties
16
+ from pptx2.parts.slide import SlidePart
17
+ from pptx2.shapes.base import BaseShape
18
+ from pptx2.slide import Slide, Slides
19
+
20
+
21
+ class ActionSetting(Subshape):
22
+ """Properties specifying how a shape or run reacts to mouse actions."""
23
+
24
+ # -- The Subshape base class provides access to the Slide Part, which is needed to access
25
+ # -- relationships, which is where hyperlinks live.
26
+
27
+ def __init__(
28
+ self,
29
+ xPr: CT_NonVisualDrawingProps | CT_TextCharacterProperties,
30
+ parent: BaseShape,
31
+ hover: bool = False,
32
+ ):
33
+ super(ActionSetting, self).__init__(parent)
34
+ # xPr is either a cNvPr or rPr element
35
+ self._element = xPr
36
+ # _hover determines use of `a:hlinkClick` or `a:hlinkHover`
37
+ self._hover = hover
38
+
39
+ @property
40
+ def action(self):
41
+ """Member of :ref:`PpActionType` enumeration, such as `PP_ACTION.HYPERLINK`.
42
+
43
+ The returned member indicates the type of action that will result when the
44
+ specified shape or text is clicked or the mouse pointer is positioned over the
45
+ shape during a slide show.
46
+
47
+ If there is no click-action or the click-action value is not recognized (is not
48
+ one of the official `MsoPpAction` values) then `PP_ACTION.NONE` is returned.
49
+ """
50
+ hlink = self._hlink
51
+
52
+ if hlink is None:
53
+ return PP_ACTION.NONE
54
+
55
+ action_verb = hlink.action_verb
56
+
57
+ if action_verb == "hlinkshowjump":
58
+ relative_target = hlink.action_fields["jump"]
59
+ return {
60
+ "firstslide": PP_ACTION.FIRST_SLIDE,
61
+ "lastslide": PP_ACTION.LAST_SLIDE,
62
+ "lastslideviewed": PP_ACTION.LAST_SLIDE_VIEWED,
63
+ "nextslide": PP_ACTION.NEXT_SLIDE,
64
+ "previousslide": PP_ACTION.PREVIOUS_SLIDE,
65
+ "endshow": PP_ACTION.END_SHOW,
66
+ }[relative_target]
67
+
68
+ return {
69
+ None: PP_ACTION.HYPERLINK,
70
+ "hlinksldjump": PP_ACTION.NAMED_SLIDE,
71
+ "hlinkpres": PP_ACTION.PLAY,
72
+ "hlinkfile": PP_ACTION.OPEN_FILE,
73
+ "customshow": PP_ACTION.NAMED_SLIDE_SHOW,
74
+ "ole": PP_ACTION.OLE_VERB,
75
+ "macro": PP_ACTION.RUN_MACRO,
76
+ "program": PP_ACTION.RUN_PROGRAM,
77
+ }.get(action_verb, PP_ACTION.NONE)
78
+
79
+ @lazyproperty
80
+ def hyperlink(self) -> Hyperlink:
81
+ """
82
+ A |Hyperlink| object representing the hyperlink action defined on
83
+ this click or hover mouse event. A |Hyperlink| object is always
84
+ returned, even if no hyperlink or other click action is defined.
85
+ """
86
+ return Hyperlink(self._element, self._parent, self._hover)
87
+
88
+ @property
89
+ def target_slide(self) -> Slide | None:
90
+ """
91
+ A reference to the slide in this presentation that is the target of
92
+ the slide jump action in this shape. Slide jump actions include
93
+ `PP_ACTION.FIRST_SLIDE`, `LAST_SLIDE`, `NEXT_SLIDE`,
94
+ `PREVIOUS_SLIDE`, and `NAMED_SLIDE`. Returns |None| for all other
95
+ actions. In particular, the `LAST_SLIDE_VIEWED` action and the `PLAY`
96
+ (start other presentation) actions are not supported.
97
+
98
+ A slide object may be assigned to this property, which makes the
99
+ shape an "internal hyperlink" to the assigened slide::
100
+
101
+ slide, target_slide = prs.slides[0], prs.slides[1]
102
+ shape = slide.shapes[0]
103
+ shape.target_slide = target_slide
104
+
105
+ Assigning |None| removes any slide jump action. Note that this is
106
+ accomplished by removing any action present (such as a hyperlink),
107
+ without first checking that it is a slide jump action.
108
+ """
109
+ slide_jump_actions = (
110
+ PP_ACTION.FIRST_SLIDE,
111
+ PP_ACTION.LAST_SLIDE,
112
+ PP_ACTION.NEXT_SLIDE,
113
+ PP_ACTION.PREVIOUS_SLIDE,
114
+ PP_ACTION.NAMED_SLIDE,
115
+ )
116
+
117
+ if self.action not in slide_jump_actions:
118
+ return None
119
+
120
+ if self.action == PP_ACTION.FIRST_SLIDE:
121
+ return self._slides[0]
122
+ elif self.action == PP_ACTION.LAST_SLIDE:
123
+ return self._slides[-1]
124
+ elif self.action == PP_ACTION.NEXT_SLIDE:
125
+ next_slide_idx = self._slide_index + 1
126
+ if next_slide_idx >= len(self._slides):
127
+ raise ValueError("no next slide")
128
+ return self._slides[next_slide_idx]
129
+ elif self.action == PP_ACTION.PREVIOUS_SLIDE:
130
+ prev_slide_idx = self._slide_index - 1
131
+ if prev_slide_idx < 0:
132
+ raise ValueError("no previous slide")
133
+ return self._slides[prev_slide_idx]
134
+ elif self.action == PP_ACTION.NAMED_SLIDE:
135
+ assert self._hlink is not None
136
+ rId = self._hlink.rId
137
+ slide_part = cast("SlidePart", self.part.related_part(rId))
138
+ return slide_part.slide
139
+
140
+ @target_slide.setter
141
+ def target_slide(self, slide: Slide | None):
142
+ self._clear_click_action()
143
+ if slide is None:
144
+ return
145
+ hlink = self._element.get_or_add_hlinkClick()
146
+ hlink.action = "ppaction://hlinksldjump"
147
+ hlink.rId = self.part.relate_to(slide.part, RT.SLIDE)
148
+
149
+ def _clear_click_action(self):
150
+ """Remove any existing click action."""
151
+ hlink = self._hlink
152
+ if hlink is None:
153
+ return
154
+ rId = hlink.rId
155
+ if rId:
156
+ self.part.drop_rel(rId)
157
+ self._element.remove(hlink)
158
+
159
+ @property
160
+ def _hlink(self) -> CT_Hyperlink | None:
161
+ """
162
+ Reference to the `a:hlinkClick` or `a:hlinkHover` element for this
163
+ click action. Returns |None| if the element is not present.
164
+ """
165
+ if self._hover:
166
+ assert isinstance(self._element, CT_NonVisualDrawingProps)
167
+ return self._element.hlinkHover
168
+ return self._element.hlinkClick
169
+
170
+ @lazyproperty
171
+ def _slide(self):
172
+ """
173
+ Reference to the slide containing the shape having this click action.
174
+ """
175
+ return self.part.slide
176
+
177
+ @lazyproperty
178
+ def _slide_index(self):
179
+ """
180
+ Position in the slide collection of the slide containing the shape
181
+ having this click action.
182
+ """
183
+ return self._slides.index(self._slide)
184
+
185
+ @lazyproperty
186
+ def _slides(self) -> Slides:
187
+ """
188
+ Reference to the slide collection for this presentation.
189
+ """
190
+ return self.part.package.presentation_part.presentation.slides
191
+
192
+
193
+ class Hyperlink(Subshape):
194
+ """Represents a hyperlink action on a shape or text run."""
195
+
196
+ def __init__(
197
+ self,
198
+ xPr: CT_NonVisualDrawingProps | CT_TextCharacterProperties,
199
+ parent: BaseShape,
200
+ hover: bool = False,
201
+ ):
202
+ super(Hyperlink, self).__init__(parent)
203
+ # xPr is either a cNvPr or rPr element
204
+ self._element = xPr
205
+ # _hover determines use of `a:hlinkClick` or `a:hlinkHover`
206
+ self._hover = hover
207
+
208
+ @property
209
+ def address(self) -> str | None:
210
+ """Read/write. The URL of the hyperlink.
211
+
212
+ URL can be on http, https, mailto, or file scheme; others may work. Returns |None| if no
213
+ hyperlink is defined, including when another action such as `RUN_MACRO` is defined on the
214
+ object. Assigning |None| removes any action defined on the object, whether it is a hyperlink
215
+ action or not.
216
+ """
217
+ hlink = self._hlink
218
+
219
+ # there's no URL if there's no click action
220
+ if hlink is None:
221
+ return None
222
+
223
+ # a click action without a relationship has no URL
224
+ rId = hlink.rId
225
+ if not rId:
226
+ return None
227
+
228
+ return self.part.target_ref(rId)
229
+
230
+ @address.setter
231
+ def address(self, url: str | None):
232
+ # implements all three of add, change, and remove hyperlink
233
+ self._remove_hlink()
234
+
235
+ if url:
236
+ rId = self.part.relate_to(url, RT.HYPERLINK, is_external=True)
237
+ hlink = self._get_or_add_hlink()
238
+ hlink.rId = rId
239
+
240
+ def _get_or_add_hlink(self) -> CT_Hyperlink:
241
+ """Get the `a:hlinkClick` or `a:hlinkHover` element for the Hyperlink object.
242
+
243
+ The actual element depends on the value of `self._hover`. Create the element if not present.
244
+ """
245
+ if self._hover:
246
+ return cast("CT_NonVisualDrawingProps", self._element).get_or_add_hlinkHover()
247
+ return self._element.get_or_add_hlinkClick()
248
+
249
+ @property
250
+ def _hlink(self) -> CT_Hyperlink | None:
251
+ """Reference to the `a:hlinkClick` or `h:hlinkHover` element for this click action.
252
+
253
+ Returns |None| if the element is not present.
254
+ """
255
+ if self._hover:
256
+ return cast("CT_NonVisualDrawingProps", self._element).hlinkHover
257
+ return self._element.hlinkClick
258
+
259
+ def _remove_hlink(self):
260
+ """Remove the a:hlinkClick or a:hlinkHover element.
261
+
262
+ Also drops any relationship it might have.
263
+ """
264
+ hlink = self._hlink
265
+ if hlink is None:
266
+ return
267
+ rId = hlink.rId
268
+ if rId:
269
+ self.part.drop_rel(rId)
270
+ self._element.remove(hlink)