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.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- 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)
|