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/lint.py ADDED
@@ -0,0 +1,2256 @@
1
+ """Slide and deck linter — detects layout/typographic issues on generated slides.
2
+
3
+ Public entry point::
4
+
5
+ report = slide.lint() # SlideLintReport
6
+ report.issues # list[LintIssue]
7
+ report.has_errors # bool
8
+ report.summary() # human-readable string
9
+ report.auto_fix() # mutates; returns list of fix descriptions
10
+
11
+ Issue types:
12
+
13
+ * ``TextOverflow`` — text likely exceeds the text-frame bounds.
14
+ * ``ShapeCollision`` — two shapes' bounding boxes overlap.
15
+ * ``OffSlide`` — a shape extends outside the slide.
16
+ * ``MinFontSize`` — a text run is below the legibility threshold.
17
+ * ``OffGridDrift`` — shape is slightly off a column/row grid that
18
+ several siblings hit cleanly.
19
+ * ``LowContrast`` — text/background contrast is below WCAG AA.
20
+ * ``ZOrderAnomaly`` — a filled card-shaped backdrop is drawn above
21
+ shapes it visually contains.
22
+ * ``MasterPlaceholderCollision`` — a shape sits exactly on a placeholder it
23
+ should likely have inherited from the layout.
24
+ * ``LayerOrderViolation`` — a shape declares ``layer_above`` but is drawn
25
+ below the layer it claims to sit on top of.
26
+
27
+ Declaring intentional overlap (so the collision detector stays quiet)::
28
+
29
+ badge.lint_group = "card-1" # n-ary: everything sharing a tag
30
+ badge.allow_overlap_with(card) # pairwise: exactly this one pair
31
+ card.layer, badge.layer_above = "card", "card" # asserts z-order too
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from dataclasses import dataclass, field
38
+ from dataclasses import fields as dataclass_fields
39
+ from enum import Enum
40
+ from typing import TYPE_CHECKING, Sequence
41
+
42
+ from pptx2.enum.text import MSO_AUTO_SIZE
43
+ from pptx2.util import Emu
44
+
45
+ if TYPE_CHECKING:
46
+ from pptx2.shapes.base import BaseShape
47
+ from pptx2.slide import Slide
48
+ from pptx2.util import Length
49
+
50
+
51
+ class LintSeverity(str, Enum):
52
+ ERROR = "error"
53
+ WARNING = "warning"
54
+ INFO = "info"
55
+
56
+
57
+ @dataclass
58
+ class LintIssue:
59
+ """A single linting issue detected on a slide."""
60
+
61
+ severity: LintSeverity
62
+ code: str
63
+ message: str
64
+ shapes: tuple[BaseShape, ...] = field(default_factory=tuple)
65
+
66
+ def __str__(self) -> str:
67
+ return f"[{self.severity.value.upper()}] {self.code}: {self.message}"
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ """Return a JSON-serializable dict describing this issue.
71
+
72
+ Shapes are represented by name (the full shape objects aren't
73
+ serializable). Every subclass-specific field — ``ratio`` on
74
+ :class:`TextOverflow`, ``side`` on :class:`OffSlide`, the collision
75
+ scoring on :class:`ShapeCollision`, etc. — is included automatically,
76
+ so the payload is self-describing for an LLM auto-fix loop or a CI
77
+ dashboard consuming ``report.to_json()``.
78
+ """
79
+ skip = {"severity", "code", "message", "shapes"}
80
+ # Reading a shape name dereferences its XML; guard so a single
81
+ # orphaned/malformed shape can't crash the whole serialization
82
+ # (this payload feeds automated agent / CI loops).
83
+ shape_names: list[str] = []
84
+ for s in self.shapes:
85
+ try:
86
+ shape_names.append(s.name or "")
87
+ except Exception:
88
+ shape_names.append("")
89
+ payload: dict[str, object] = {
90
+ "code": self.code,
91
+ "severity": self.severity.value,
92
+ "message": self.message,
93
+ "shapes": shape_names,
94
+ }
95
+ for f in dataclass_fields(self):
96
+ if f.name in skip:
97
+ continue
98
+ value = getattr(self, f.name)
99
+ # Normalize tuples (e.g. ShapeCollision.groups) to lists for JSON.
100
+ payload[f.name] = list(value) if isinstance(value, tuple) else value
101
+ return payload
102
+
103
+
104
+ @dataclass
105
+ class TextOverflow(LintIssue):
106
+ """Estimated text content exceeds the text frame's visible area."""
107
+
108
+ ratio: float = 1.0
109
+
110
+ def __init__(self, shape: BaseShape, ratio: float):
111
+ super().__init__(
112
+ severity=LintSeverity.ERROR,
113
+ code="TextOverflow",
114
+ message=(
115
+ f"Shape '{shape.name}': estimated text height is {ratio:.1f}× "
116
+ f"the text frame height."
117
+ ),
118
+ shapes=(shape,),
119
+ )
120
+ self.ratio = ratio
121
+
122
+
123
+ @dataclass
124
+ class OffSlide(LintIssue):
125
+ """Shape extends beyond the slide boundary."""
126
+
127
+ side: str = ""
128
+
129
+ def __init__(self, shape: BaseShape, side: str, code: str = "OffSlide"):
130
+ super().__init__(
131
+ severity=LintSeverity.ERROR,
132
+ code=code,
133
+ message=f"Shape '{shape.name}' extends beyond the {side} edge of the slide.",
134
+ shapes=(shape,),
135
+ )
136
+ self.side = side
137
+
138
+
139
+ @dataclass
140
+ class OffSlideShadow(OffSlide):
141
+ """Shape's shadow bleed extends beyond the slide boundary.
142
+
143
+ Emitted when the raw shape bbox sits inside the slide but
144
+ inflating by the shadow blur radius pushes it past an edge. Only
145
+ fires when ``slide.lint(include_effect_bleed=True)`` is set. Use
146
+ ``shape.lint_skip = {"OffSlideShadow"}`` to silence the bleed-only
147
+ variant without silencing real :class:`OffSlide` issues.
148
+ """
149
+
150
+ def __init__(self, shape: BaseShape, side: str):
151
+ super().__init__(shape, side, code="OffSlideShadow")
152
+ # The raw bbox stays on-slide; only the shadow bleed crosses
153
+ # the edge, so the inherited "Shape extends beyond …" message
154
+ # would mislead readers.
155
+ self.message = (
156
+ f"Shape '{shape.name}': shadow bleed extends beyond the "
157
+ f"{side} edge of the slide (raw bbox is on-slide)."
158
+ )
159
+
160
+
161
+ @dataclass
162
+ class ShapeCollision(LintIssue):
163
+ """Two shapes' bounding boxes overlap.
164
+
165
+ The detector tiers each collision into a *kind* and a numeric *score*
166
+ so callers can distinguish layered card-on-panel patterns
167
+ ("incidental", INFO) from genuine duplicate-rectangle bugs
168
+ ("matched", ERROR). See :func:`_check_collisions` for the heuristic.
169
+ """
170
+
171
+ intersection_area: int = 0
172
+ intersection_pct: float = 0.0
173
+ #: ``(group_a, group_b)`` — the ``lint_group`` tag of each shape (or
174
+ #: ``None`` if untagged). Lets callers triage "intentional overlap I
175
+ #: forgot to tag" (one or both ``None``) vs. "genuine layout bug"
176
+ #: (different non-``None`` tags) at a glance in ``report.summary()``.
177
+ groups: tuple[str | None, str | None] = (None, None)
178
+ #: Likelihood this overlap is a layout bug, in [0.0, 1.0]. Higher is
179
+ #: more suspicious; ``"incidental"`` collisions tend to score low.
180
+ score: float = 0.0
181
+ #: One of ``"incidental"`` (small inside large), ``"partial"``
182
+ #: (similarly-sized partial overlap), or ``"matched"`` (near-identical
183
+ #: bbox — almost certainly a duplicate).
184
+ kind: str = "partial"
185
+
186
+ def __init__(
187
+ self,
188
+ shape_a: BaseShape,
189
+ shape_b: BaseShape,
190
+ intersection_area: int,
191
+ intersection_pct: float,
192
+ groups: tuple[str | None, str | None] = (None, None),
193
+ score: float = 0.0,
194
+ kind: str = "partial",
195
+ code: str = "ShapeCollision",
196
+ ):
197
+ severity = _SEVERITY_BY_KIND.get(kind, LintSeverity.WARNING)
198
+ group_suffix = ""
199
+ if groups != (None, None):
200
+ group_suffix = f" [groups: {groups[0]!r} vs {groups[1]!r}]"
201
+ # When neither shape is tagged, append a one-line hint so
202
+ # readers don't have to know about ``shape.lint_group`` from
203
+ # the docstring alone. Skip the hint when the user already
204
+ # set tags (the warning still fires because the tags don't
205
+ # match — that signal carries different intent).
206
+ hint_suffix = ""
207
+ if groups == (None, None):
208
+ hint_suffix = (
209
+ " — tip: if this overlap is intentional, set "
210
+ 'shape.lint_group = "<name>" on both shapes, or use '
211
+ "slide.shapes.lint_group_scope() to tag a block of "
212
+ "shapes at once."
213
+ )
214
+ super().__init__(
215
+ severity=severity,
216
+ code=code,
217
+ message=(
218
+ f"Shapes '{shape_a.name}' and '{shape_b.name}' overlap "
219
+ f"({intersection_pct:.0%} of the smaller shape's area) "
220
+ f"[kind={kind}, score={score:.2f}]"
221
+ + group_suffix
222
+ + hint_suffix
223
+ ),
224
+ shapes=(shape_a, shape_b),
225
+ )
226
+ self.intersection_area = intersection_area
227
+ self.intersection_pct = intersection_pct
228
+ self.groups = groups
229
+ self.score = score
230
+ self.kind = kind
231
+
232
+
233
+ _SEVERITY_BY_KIND: dict[str, LintSeverity] = {
234
+ "incidental": LintSeverity.INFO,
235
+ "partial": LintSeverity.WARNING,
236
+ # ``matched`` (near-identical bbox) was originally ERROR, but in
237
+ # practice the overwhelmingly common cause is intentional visual
238
+ # layering — a badge drawn over a number, a button drawn over its
239
+ # label. Demoting to INFO keeps the signal in the report without
240
+ # flooding ``has_errors`` and CI pipelines with false positives;
241
+ # the ``matched`` kind is preserved on the issue so callers who
242
+ # really want to flag duplicates can filter on it.
243
+ "matched": LintSeverity.INFO,
244
+ }
245
+
246
+
247
+ @dataclass
248
+ class ShapeCollisionShadow(ShapeCollision):
249
+ """Two shapes' shadow bleed regions overlap.
250
+
251
+ Emitted when raw bboxes don't overlap but shadow-inflated bboxes
252
+ do. Only fires when ``slide.lint(include_effect_bleed=True)`` is
253
+ set. Suppress with ``shape.lint_skip = {"ShapeCollisionShadow"}``.
254
+ """
255
+
256
+ def __init__(
257
+ self,
258
+ shape_a: BaseShape,
259
+ shape_b: BaseShape,
260
+ intersection_area: int,
261
+ intersection_pct: float,
262
+ groups: tuple[str | None, str | None] = (None, None),
263
+ score: float = 0.0,
264
+ kind: str = "partial",
265
+ ):
266
+ super().__init__(
267
+ shape_a,
268
+ shape_b,
269
+ intersection_area=intersection_area,
270
+ intersection_pct=intersection_pct,
271
+ groups=groups,
272
+ score=score,
273
+ kind=kind,
274
+ code="ShapeCollisionShadow",
275
+ )
276
+ # The raw bboxes don't overlap; only the shadow-inflated ones
277
+ # do, so the inherited "Shapes … overlap …" wording from
278
+ # ShapeCollision would mislead readers.
279
+ group_suffix = ""
280
+ if groups != (None, None):
281
+ group_suffix = f" [groups: {groups[0]!r} vs {groups[1]!r}]"
282
+ self.message = (
283
+ f"Shapes '{shape_a.name}' and '{shape_b.name}': shadow bleed "
284
+ f"regions overlap ({intersection_pct:.0%} of the smaller "
285
+ f"shape's area), though the raw bboxes do not "
286
+ f"[kind={kind}, score={score:.2f}]"
287
+ + group_suffix
288
+ )
289
+
290
+
291
+ @dataclass
292
+ class MinFontSize(LintIssue):
293
+ """A text run uses a font size below the configured legibility threshold."""
294
+
295
+ pt: float = 0.0
296
+ threshold_pt: float = 9.0
297
+
298
+ def __init__(self, shape: BaseShape, pt: float, threshold_pt: float):
299
+ super().__init__(
300
+ severity=LintSeverity.WARNING,
301
+ code="MinFontSize",
302
+ message=(
303
+ f"Shape '{shape.name}': run at {pt:.1f}pt is below the "
304
+ f"{threshold_pt:.0f}pt legibility threshold."
305
+ ),
306
+ shapes=(shape,),
307
+ )
308
+ self.pt = pt
309
+ self.threshold_pt = threshold_pt
310
+
311
+
312
+ @dataclass
313
+ class OffGridDrift(LintIssue):
314
+ """Shape sits slightly off a column/row grid that other shapes hit cleanly."""
315
+
316
+ axis: str = ""
317
+ drift_emu: int = 0
318
+ grid_emu: int = 0
319
+
320
+ def __init__(self, shape: BaseShape, axis: str, drift_emu: int, grid_emu: int):
321
+ drift_in = drift_emu / 914400.0
322
+ super().__init__(
323
+ severity=LintSeverity.WARNING,
324
+ code="OffGridDrift",
325
+ message=(
326
+ f"Shape '{shape.name}': {axis} edge is {drift_in:.3f}\" "
327
+ f"off the dominant grid line at {grid_emu / 914400.0:.3f}\"."
328
+ ),
329
+ shapes=(shape,),
330
+ )
331
+ self.axis = axis
332
+ self.drift_emu = drift_emu
333
+ self.grid_emu = grid_emu
334
+
335
+
336
+ @dataclass
337
+ class LowContrast(LintIssue):
338
+ """Text/background contrast ratio is below the WCAG AA threshold."""
339
+
340
+ ratio: float = 0.0
341
+ threshold: float = 4.5
342
+
343
+ def __init__(self, shape: BaseShape, ratio: float, threshold: float = 4.5):
344
+ super().__init__(
345
+ severity=LintSeverity.WARNING,
346
+ code="LowContrast",
347
+ message=(
348
+ f"Shape '{shape.name}': text-on-fill contrast ratio "
349
+ f"{ratio:.2f}:1 is below WCAG AA threshold ({threshold:.1f}:1)."
350
+ ),
351
+ shapes=(shape,),
352
+ )
353
+ self.ratio = ratio
354
+ self.threshold = threshold
355
+
356
+
357
+ @dataclass
358
+ class ZOrderAnomaly(LintIssue):
359
+ """A filled shape is drawn above a shape it visually contains."""
360
+
361
+ def __init__(self, container: BaseShape, contained: BaseShape):
362
+ super().__init__(
363
+ severity=LintSeverity.WARNING,
364
+ code="ZOrderAnomaly",
365
+ message=(
366
+ f"Shape '{container.name}' (filled) is drawn above "
367
+ f"'{contained.name}' that it visually contains; "
368
+ f"'{contained.name}' will be hidden."
369
+ ),
370
+ shapes=(container, contained),
371
+ )
372
+
373
+
374
+ @dataclass
375
+ class LayerOrderViolation(LintIssue):
376
+ """A shape declares it sits above another layer but is drawn below it.
377
+
378
+ Emitted when ``shape.layer_above`` names a layer the shape actually
379
+ overlaps, *but* the z-order contradicts the declaration — the shape
380
+ that claims to be on top is earlier in ``spTree`` and is therefore
381
+ painted underneath. The declaration is treated as the author's
382
+ intent, so the drawing order is what's reported as wrong.
383
+ """
384
+
385
+ #: The layer name the shape declared it sits above.
386
+ layer: str = ""
387
+
388
+ def __init__(self, above: BaseShape, below: BaseShape, layer: str):
389
+ super().__init__(
390
+ severity=LintSeverity.ERROR,
391
+ code="LayerOrderViolation",
392
+ message=(
393
+ f"Shape '{above.name}' declares layer_above={layer!r} but is "
394
+ f"drawn *below* '{below.name}' (layer={layer!r}); it will be "
395
+ f"hidden. Move '{above.name}' later in the shape tree, or drop "
396
+ f"the layer_above declaration."
397
+ ),
398
+ shapes=(above, below),
399
+ )
400
+ self.layer = layer
401
+
402
+
403
+ @dataclass
404
+ class MasterPlaceholderCollision(LintIssue):
405
+ """A non-placeholder shape sits at exactly the position of a layout placeholder."""
406
+
407
+ placeholder_idx: int = 0
408
+
409
+ def __init__(self, shape: BaseShape, placeholder_idx: int):
410
+ super().__init__(
411
+ severity=LintSeverity.WARNING,
412
+ code="MasterPlaceholderCollision",
413
+ message=(
414
+ f"Shape '{shape.name}' sits at the position of layout "
415
+ f"placeholder idx={placeholder_idx}; it likely should have "
416
+ f"inherited from the placeholder instead of redrawing it."
417
+ ),
418
+ shapes=(shape,),
419
+ )
420
+ self.placeholder_idx = placeholder_idx
421
+
422
+
423
+ class SlideLintReport:
424
+ """Lint report for a single slide.
425
+
426
+ Returned by :meth:`Slide.lint()`. Provides a list of issues, a boolean
427
+ ``has_errors`` flag, a human-readable ``summary()``, and an ``auto_fix()``
428
+ mutator for the fixable subset.
429
+ """
430
+
431
+ def __init__(
432
+ self,
433
+ slide: Slide,
434
+ issues: list[LintIssue],
435
+ *,
436
+ include_effect_bleed: bool = False,
437
+ disable: Sequence[str] = (),
438
+ min_severity: LintSeverity = LintSeverity.INFO,
439
+ ):
440
+ self._slide = slide
441
+ self._issues = issues
442
+ # Remember the mode the report was generated under so
443
+ # ``auto_fix()``'s post-fix refresh stays consistent — refreshing
444
+ # under default kwargs would otherwise drop bleed-only issues
445
+ # from a bleed-enabled report or surface issues the caller had
446
+ # asked to disable.
447
+ self._include_effect_bleed = include_effect_bleed
448
+ self._disable = tuple(disable)
449
+ self._min_severity = min_severity
450
+
451
+ @property
452
+ def issues(self) -> list[LintIssue]:
453
+ """All detected issues, ordered: errors first, then warnings, then info."""
454
+ return self._issues
455
+
456
+ @property
457
+ def has_errors(self) -> bool:
458
+ """True when at least one ERROR-severity issue is present."""
459
+ return any(i.severity == LintSeverity.ERROR for i in self._issues)
460
+
461
+ def summary(self) -> str:
462
+ """Return a human-readable string summarising the issues found."""
463
+ if not self._issues:
464
+ return "No issues found."
465
+ lines = [f"{len(self._issues)} issue(s) found:"]
466
+ for issue in self._issues:
467
+ lines.append(f" {issue}")
468
+ return "\n".join(lines)
469
+
470
+ def to_dict(self) -> dict[str, object]:
471
+ """Return a JSON-serializable dict of this slide's lint result.
472
+
473
+ Shape: ``{"has_errors": bool, "issue_count": int, "issues": [...]}``
474
+ where each issue is :meth:`LintIssue.to_dict`. Feed it to an LLM or a
475
+ CI gate instead of parsing :meth:`summary`.
476
+ """
477
+ return {
478
+ "has_errors": self.has_errors,
479
+ "issue_count": len(self._issues),
480
+ "issues": [issue.to_dict() for issue in self._issues],
481
+ }
482
+
483
+ def to_json(self, *, indent: int | None = 2) -> str:
484
+ """Return :meth:`to_dict` serialized as a JSON string."""
485
+ import json
486
+
487
+ return json.dumps(self.to_dict(), indent=indent)
488
+
489
+ def auto_fix(self, *, dry_run: bool = False) -> list[str]:
490
+ """Apply automatic fixes for issues that can be resolved without designer judgment.
491
+
492
+ Returns a list of human-readable descriptions of the fixes applied (or
493
+ that *would* be applied if *dry_run* is True). After a non-dry-run
494
+ call, :attr:`issues` is refreshed to reflect the post-fix state — so
495
+ the residual punch list is just ``report.issues`` rather than a
496
+ second ``slide.lint()`` call.
497
+
498
+ Currently auto-fixable:
499
+
500
+ * ``OffSlide`` — clamps the shape on-slide. Shrinks the
501
+ width / height first when the shape is larger than the slide
502
+ (translation alone can't fix that), then nudges position
503
+ inside the bounds. Each shape is clamped at most once even
504
+ when it triggered multiple OffSlide issues (e.g. left + right).
505
+ * ``OffGridDrift`` — snaps the shape's drifted edge onto the dominant
506
+ grid line (Tier 3 of the auto-fix tier list).
507
+ * ``TextOverflow`` — flips the offending text frame's auto-size
508
+ setting to ``MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE`` so PowerPoint
509
+ shrinks the runs at render time. This is a non-destructive
510
+ fix: the text content is preserved verbatim, only the
511
+ render-time sizing changes. Frames that already have a
512
+ non-NONE auto-size are skipped (they should not have linted
513
+ in the first place).
514
+
515
+ * ``LayerOrderViolation`` — restacks the shape that declared
516
+ ``layer_above`` to sit immediately after the layer it named, so
517
+ the drawing order matches the declaration. Unlike the
518
+ collision fixes below this needs no designer judgment: the
519
+ author already stated which shape belongs on top, and the fix
520
+ only makes the z-order say the same thing. Geometry is
521
+ untouched.
522
+
523
+ Not auto-fixable:
524
+
525
+ * ``ShapeCollision`` — nudging shapes apart almost always breaks intent;
526
+ declare deliberate overlaps with ``shape.lint_group``,
527
+ ``shape.allow_overlap_with(...)``, or a ``layer`` /
528
+ ``layer_above`` pair to suppress.
529
+ * ``LowContrast``, ``MinFontSize``, ``ZOrderAnomaly``,
530
+ ``MasterPlaceholderCollision`` — require designer judgment.
531
+ """
532
+ fixes: list[str] = []
533
+ slide_w, slide_h = _slide_dimensions(self._slide)
534
+
535
+ # Cross-issue de-dup: a single shape can pop several OffSlide
536
+ # issues (left + right, or top + bottom) and the per-issue loop
537
+ # would otherwise fire twice for the same nudge. Tracking
538
+ # already-clamped shapes by id keeps the fix descriptions and
539
+ # the resulting position consistent.
540
+ clamped: set[int] = set()
541
+ # Same idea for layer restacks: at most one move per shape per
542
+ # auto_fix pass, so interacting declarations can't ping-pong.
543
+ restacked: set[int] = set()
544
+
545
+ for issue in list(self._issues):
546
+ if isinstance(issue, OffSlide):
547
+ shape = issue.shapes[0]
548
+ shape_key = id(shape._element) # pyright: ignore[reportPrivateUsage]
549
+ if shape_key in clamped:
550
+ continue
551
+ left, top, width, height = shape.left, shape.top, shape.width, shape.height
552
+ new_left, new_top = left, top
553
+ new_width, new_height = width, height
554
+
555
+ # First clamp size: a shape larger than the slide can
556
+ # never be fully on-slide just by translation, so shrink
557
+ # to fit before deciding where to put it. This used to
558
+ # be a silent no-op — auto_fix would translate but never
559
+ # converge.
560
+ if slide_w is not None and int(width) > int(slide_w):
561
+ new_width = Emu(int(slide_w))
562
+ if slide_h is not None and int(height) > int(slide_h):
563
+ new_height = Emu(int(slide_h))
564
+
565
+ if int(left) < 0:
566
+ new_left = Emu(0)
567
+ if int(top) < 0:
568
+ new_top = Emu(0)
569
+ if slide_w is not None and (int(new_left) + int(new_width)) > int(slide_w):
570
+ new_left = Emu(max(0, int(slide_w) - int(new_width)))
571
+ if slide_h is not None and (int(new_top) + int(new_height)) > int(slide_h):
572
+ new_top = Emu(max(0, int(slide_h) - int(new_height)))
573
+
574
+ changed = (
575
+ new_left != left
576
+ or new_top != top
577
+ or new_width != width
578
+ or new_height != height
579
+ )
580
+ if changed:
581
+ parts = []
582
+ if new_left != left or new_top != top:
583
+ parts.append(
584
+ f"position ({left},{top}) → ({new_left},{new_top})"
585
+ )
586
+ if new_width != width or new_height != height:
587
+ parts.append(
588
+ f"size ({width},{height}) → ({new_width},{new_height})"
589
+ )
590
+ desc = f"Clamped '{shape.name}' on-slide: " + "; ".join(parts) + "."
591
+ fixes.append(desc)
592
+ if not dry_run:
593
+ shape.left = new_left
594
+ shape.top = new_top
595
+ if new_width != width:
596
+ shape.width = new_width
597
+ if new_height != height:
598
+ shape.height = new_height
599
+ clamped.add(shape_key)
600
+
601
+ elif isinstance(issue, TextOverflow):
602
+ shape = issue.shapes[0]
603
+ # Skip silently if the shape no longer has a text frame
604
+ # (defensive — TextOverflow only fires for has_text_frame).
605
+ if not getattr(shape, "has_text_frame", False):
606
+ continue
607
+ tf = shape.text_frame # type: ignore[attr-defined]
608
+ # Only fix frames whose auto-size hasn't been set yet —
609
+ # SHAPE_TO_FIT_TEXT or TEXT_TO_FIT_SHAPE owners have made
610
+ # an explicit choice and shouldn't be silently flipped.
611
+ if tf.auto_size in (
612
+ MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT,
613
+ MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE,
614
+ ):
615
+ continue
616
+ desc = (
617
+ f"Set '{shape.name}' text frame auto_size = "
618
+ f"TEXT_TO_FIT_SHAPE (estimated {issue.ratio:.1f}× overflow)."
619
+ )
620
+ fixes.append(desc)
621
+ if not dry_run:
622
+ tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
623
+
624
+ elif isinstance(issue, LayerOrderViolation):
625
+ above, below = issue.shapes[0], issue.shapes[1]
626
+ above_el = above._element # pyright: ignore[reportPrivateUsage]
627
+ below_el = below._element # pyright: ignore[reportPrivateUsage]
628
+ # Only restack shapes that are actually siblings; a
629
+ # cross-container pair (one inside a group) has no
630
+ # single ordering to fix and is left for the author.
631
+ if above_el.getparent() is not below_el.getparent():
632
+ continue
633
+ # A shape already moved by an earlier violation is left
634
+ # alone: with three-plus layers the moves interact, and
635
+ # re-running lint after the first pass gives a truer
636
+ # picture than stacking speculative restacks.
637
+ if id(above_el) in restacked:
638
+ continue
639
+ desc = (
640
+ f"Restacked '{above.name}' above '{below.name}' to honour "
641
+ f"layer_above={issue.layer!r}."
642
+ )
643
+ # Record the move before the dry_run gate, so a preview
644
+ # reports exactly the fixes a real run would apply --
645
+ # matching how the OffSlide branch populates ``clamped``.
646
+ restacked.add(id(above_el))
647
+ fixes.append(desc)
648
+ if not dry_run:
649
+ # ``addnext`` moves the element rather than copying
650
+ # it, so this is a reorder, not a duplication.
651
+ below_el.addnext(above_el)
652
+
653
+ elif isinstance(issue, OffGridDrift):
654
+ shape = issue.shapes[0]
655
+ if issue.axis == "left":
656
+ old, new = int(shape.left), issue.grid_emu
657
+ desc = (
658
+ f"Snapped '{shape.name}' left edge from {old} to "
659
+ f"{new} EMU (grid)."
660
+ )
661
+ fixes.append(desc)
662
+ if not dry_run:
663
+ shape.left = Emu(new)
664
+ elif issue.axis == "top":
665
+ old, new = int(shape.top), issue.grid_emu
666
+ desc = (
667
+ f"Snapped '{shape.name}' top edge from {old} to "
668
+ f"{new} EMU (grid)."
669
+ )
670
+ fixes.append(desc)
671
+ if not dry_run:
672
+ shape.top = Emu(new)
673
+
674
+ # Refresh ``issues`` so the residual punch list is just
675
+ # ``report.issues`` (no extra ``slide.lint()`` call needed).
676
+ # Skipped on dry_run because nothing changed on the slide.
677
+ # Re-uses the same ``include_effect_bleed`` mode the original
678
+ # report was built under, so a bleed-enabled report's residual
679
+ # punch list still includes bleed-only issues.
680
+ if not dry_run and fixes:
681
+ self._issues = self._slide.lint(
682
+ include_effect_bleed=self._include_effect_bleed,
683
+ disable=self._disable,
684
+ min_severity=self._min_severity,
685
+ ).issues
686
+
687
+ return fixes
688
+
689
+ def fingerprints(self) -> list[str]:
690
+ """Return a stable fingerprint string for each issue.
691
+
692
+ Useful for CI baselining: serialise this list into the repo,
693
+ re-run lint after changes, and diff to see only newly-introduced
694
+ issues.
695
+
696
+ The fingerprint is a 12-char hex digest of
697
+ ``code | shape names | side / kind / axis``: the *content* of
698
+ the issue, not its position in the report or any volatile field
699
+ like the exact intersection area. Tweaking text inside a shape
700
+ without moving it does not change the fingerprint; resizing a
701
+ shape that was already off-slide does not produce a new
702
+ fingerprint either (still the same OffSlide on the same shape).
703
+ """
704
+ return [_issue_fingerprint(i) for i in self._issues]
705
+
706
+ def diff(self, baseline: "SlideLintReport") -> list[LintIssue]:
707
+ """Return issues present in *self* but not in *baseline*.
708
+
709
+ This is the CI-hookable "did my change add new problems?"
710
+ primitive. Identity is the stable :meth:`fingerprints` digest, so
711
+ an issue that merely *moved* (a shape already off-slide that was
712
+ nudged but still off-slide) is treated as the same issue and is
713
+ *not* reported as new. The returned list preserves ``self``'s
714
+ ordering (errors → warnings → info), and is empty when ``self`` is
715
+ a subset of (or identical to) *baseline*.
716
+
717
+ Use :meth:`diff_detail` if you also need the set of issues that
718
+ were *fixed* relative to the baseline.
719
+ """
720
+ baseline_fps = set(baseline.fingerprints())
721
+ return [
722
+ issue
723
+ for issue, fp in zip(self._issues, self.fingerprints())
724
+ if fp not in baseline_fps
725
+ ]
726
+
727
+ def diff_detail(self, baseline: "SlideLintReport") -> dict[str, list[LintIssue]]:
728
+ """Return a symmetric diff against *baseline*.
729
+
730
+ Shape: ``{"added": [...], "fixed": [...]}`` where ``added`` are
731
+ issues new in ``self`` (the same list :meth:`diff` returns) and
732
+ ``fixed`` are issues that were in ``baseline`` but are gone in
733
+ ``self``. Both are matched by the stable :meth:`fingerprints`
734
+ digest.
735
+ """
736
+ self_fps = self.fingerprints()
737
+ base_fps = baseline.fingerprints()
738
+ self_fp_set = set(self_fps)
739
+ base_fp_set = set(base_fps)
740
+ added = [
741
+ issue
742
+ for issue, fp in zip(self._issues, self_fps)
743
+ if fp not in base_fp_set
744
+ ]
745
+ fixed = [
746
+ issue
747
+ for issue, fp in zip(baseline.issues, base_fps)
748
+ if fp not in self_fp_set
749
+ ]
750
+ return {"added": added, "fixed": fixed}
751
+
752
+ def to_sarif(self, *, slide_index: int | None = None) -> dict[str, object]:
753
+ """Return a SARIF v2.1.0 document describing this slide's issues.
754
+
755
+ SARIF (Static Analysis Results Interchange Format) is the format
756
+ GitHub code-scanning ingests, so a CI job can upload the result
757
+ and have lint issues surface as annotations on a PR. The returned
758
+ value is a plain ``dict`` that is directly ``json.dumps``-able.
759
+
760
+ The document has a single ``run`` whose ``tool.driver`` is named
761
+ ``python-pptx2-lint``; the driver's ``rules`` list is derived from
762
+ the distinct issue codes present, and each issue becomes one entry
763
+ under ``runs[0].results`` with a ``ruleId``, a ``level`` mapped
764
+ from :class:`LintSeverity` (ERROR→``error``, WARNING→``warning``,
765
+ INFO→``note``), a ``message.text``, and a ``locations`` entry
766
+ naming the involved shape(s).
767
+
768
+ When *slide_index* is given it is recorded on every result (as a
769
+ ``logicalLocation`` and a ``properties.slideIndex``) so a
770
+ per-slide SARIF can be merged or read with the slide it came from.
771
+ Prefer :func:`lint_report_to_sarif` to aggregate a whole deck into
772
+ a single document.
773
+ """
774
+ return _build_sarif([(slide_index, self._issues)])
775
+
776
+ def to_sarif_json(
777
+ self, *, slide_index: int | None = None, indent: int | None = 2
778
+ ) -> str:
779
+ """Return :meth:`to_sarif` serialized as a JSON string."""
780
+ import json
781
+
782
+ return json.dumps(self.to_sarif(slide_index=slide_index), indent=indent)
783
+
784
+
785
+ # ---------------------------------------------------------------------------
786
+ # Internal helpers
787
+ # ---------------------------------------------------------------------------
788
+
789
+ _DEFAULT_SLIDE_W = Emu(9144000) # 10 inches in EMU (standard widescreen)
790
+ _DEFAULT_SLIDE_H = Emu(6858000) # 7.5 inches in EMU
791
+
792
+
793
+ def _issue_fingerprint(issue: LintIssue) -> str:
794
+ """Return a 12-char hex digest stable across runs for *issue*.
795
+
796
+ Encodes only the *content* of the issue: the rule code, the names
797
+ of the involved shapes, and any classifying field (``side`` for
798
+ OffSlide, ``axis`` for OffGridDrift, ``kind`` for ShapeCollision,
799
+ ``layer`` for LayerOrderViolation).
800
+ Volatile fields like exact intersection area or absolute position
801
+ are deliberately excluded so a CI baseline survives small layout
802
+ nudges that don't fix the underlying issue.
803
+ """
804
+ import hashlib
805
+
806
+ parts: list[str] = [issue.code]
807
+ for shape in issue.shapes:
808
+ try:
809
+ parts.append(shape.name or "")
810
+ except Exception:
811
+ parts.append("?")
812
+ for attr in ("side", "axis", "kind", "layer"):
813
+ val = getattr(issue, attr, None)
814
+ if val:
815
+ parts.append(f"{attr}={val}")
816
+ digest = hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()
817
+ return digest[:12]
818
+
819
+
820
+ # ---------------------------------------------------------------------------
821
+ # SARIF v2.1.0 export — the GitHub code-scanning interchange format.
822
+ # ---------------------------------------------------------------------------
823
+
824
+ #: SARIF schema URI / version constants. ``$schema`` is advisory but lets
825
+ #: GitHub and other consumers validate the document.
826
+ _SARIF_VERSION = "2.1.0"
827
+ _SARIF_SCHEMA = (
828
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/"
829
+ "Schemata/sarif-schema-2.1.0.json"
830
+ )
831
+ _SARIF_TOOL_NAME = "python-pptx2-lint"
832
+
833
+ #: LintSeverity → SARIF result level. SARIF defines exactly these four
834
+ #: notification levels; ``info`` maps to ``note`` (its lowest).
835
+ _SARIF_LEVEL_BY_SEVERITY: dict[LintSeverity, str] = {
836
+ LintSeverity.ERROR: "error",
837
+ LintSeverity.WARNING: "warning",
838
+ LintSeverity.INFO: "note",
839
+ }
840
+
841
+ #: One-line descriptions per known rule code, surfaced as the SARIF
842
+ #: rule's ``shortDescription``. Unknown codes fall back to the code
843
+ #: itself so a new issue type still produces a valid (if terse) rule.
844
+ _RULE_DESCRIPTIONS: dict[str, str] = {
845
+ "TextOverflow": "Estimated text content exceeds the text frame's visible area.",
846
+ "OffSlide": "A shape extends beyond the slide boundary.",
847
+ "OffSlideShadow": "A shape's shadow bleed extends beyond the slide boundary.",
848
+ "ShapeCollision": "Two shapes' bounding boxes overlap.",
849
+ "ShapeCollisionShadow": "Two shapes' shadow-bleed regions overlap.",
850
+ "MinFontSize": "A text run is below the legibility threshold.",
851
+ "OffGridDrift": "A shape is slightly off a column/row grid its siblings hit cleanly.",
852
+ "LowContrast": "Text/background contrast is below the WCAG AA threshold.",
853
+ "ZOrderAnomaly": "A filled shape is drawn above a shape it visually contains.",
854
+ "MasterPlaceholderCollision": (
855
+ "A shape sits at the position of an inheritable layout placeholder."
856
+ ),
857
+ "LayerOrderViolation": (
858
+ "A shape declares it sits above a layer but is drawn below it."
859
+ ),
860
+ }
861
+
862
+
863
+ def _sarif_artifact_uri(slide_index: int | None) -> str:
864
+ """Return a stable artifact URI for *slide_index*.
865
+
866
+ A deck has no on-disk per-slide file, so we synthesize a logical
867
+ ``slide/<n>`` (or ``slide/unknown``) URI. GitHub keys annotations off
868
+ this; keeping it stable means re-runs land on the same logical file.
869
+ """
870
+ if slide_index is None:
871
+ return "slide/unknown"
872
+ return f"slide/{slide_index}"
873
+
874
+
875
+ def _sarif_result(issue: LintIssue, slide_index: int | None) -> dict[str, object]:
876
+ """Return a single SARIF ``result`` object for *issue*."""
877
+ level = _SARIF_LEVEL_BY_SEVERITY.get(issue.severity, "warning")
878
+
879
+ shape_names: list[str] = []
880
+ for shape in issue.shapes:
881
+ try:
882
+ shape_names.append(shape.name or "")
883
+ except Exception:
884
+ shape_names.append("?")
885
+
886
+ locations: list[dict[str, object]] = []
887
+ artifact_uri = _sarif_artifact_uri(slide_index)
888
+ if shape_names:
889
+ for name in shape_names:
890
+ location: dict[str, object] = {
891
+ "physicalLocation": {
892
+ "artifactLocation": {"uri": artifact_uri},
893
+ },
894
+ "logicalLocations": [
895
+ {"name": name, "kind": "shape"},
896
+ ],
897
+ }
898
+ locations.append(location)
899
+ else:
900
+ # No shape attached (rare) — still anchor the result to the slide.
901
+ locations.append(
902
+ {"physicalLocation": {"artifactLocation": {"uri": artifact_uri}}}
903
+ )
904
+
905
+ if slide_index is not None:
906
+ for location in locations:
907
+ logical = location.setdefault("logicalLocations", [])
908
+ assert isinstance(logical, list)
909
+ logical.append(
910
+ {"fullyQualifiedName": f"slide[{slide_index}]", "kind": "slide"}
911
+ )
912
+
913
+ result: dict[str, object] = {
914
+ "ruleId": issue.code,
915
+ "level": level,
916
+ "message": {"text": issue.message},
917
+ "locations": locations,
918
+ "partialFingerprints": {"powerPptxLintFingerprint/v1": _issue_fingerprint(issue)},
919
+ "properties": {"shapes": shape_names},
920
+ }
921
+ if slide_index is not None:
922
+ props = result["properties"]
923
+ assert isinstance(props, dict)
924
+ props["slideIndex"] = slide_index
925
+ return result
926
+
927
+
928
+ def _build_sarif(
929
+ grouped: Sequence[tuple[int | None, Sequence[LintIssue]]],
930
+ ) -> dict[str, object]:
931
+ """Assemble a SARIF v2.1.0 document from ``(slide_index, issues)`` groups.
932
+
933
+ Shared by :meth:`SlideLintReport.to_sarif` (one group) and
934
+ :func:`lint_report_to_sarif` (one group per slide). The driver's
935
+ ``rules`` list is the de-duplicated set of issue codes encountered,
936
+ in first-seen order.
937
+ """
938
+ results: list[dict[str, object]] = []
939
+ rule_ids: list[str] = []
940
+ seen_rules: set[str] = set()
941
+
942
+ for slide_index, issues in grouped:
943
+ for issue in issues:
944
+ if issue.code not in seen_rules:
945
+ seen_rules.add(issue.code)
946
+ rule_ids.append(issue.code)
947
+ results.append(_sarif_result(issue, slide_index))
948
+
949
+ rules: list[dict[str, object]] = []
950
+ for code in rule_ids:
951
+ rules.append(
952
+ {
953
+ "id": code,
954
+ "name": code,
955
+ "shortDescription": {"text": _RULE_DESCRIPTIONS.get(code, code)},
956
+ }
957
+ )
958
+
959
+ return {
960
+ "version": _SARIF_VERSION,
961
+ "$schema": _SARIF_SCHEMA,
962
+ "runs": [
963
+ {
964
+ "tool": {
965
+ "driver": {
966
+ "name": _SARIF_TOOL_NAME,
967
+ "informationUri": "https://github.com/python-pptx2/python-pptx2",
968
+ "rules": rules,
969
+ }
970
+ },
971
+ "results": results,
972
+ }
973
+ ],
974
+ }
975
+
976
+
977
+ def lint_report_to_sarif(
978
+ reports: "SlideLintReport | Sequence[SlideLintReport]",
979
+ *,
980
+ start_index: int = 0,
981
+ ) -> dict[str, object]:
982
+ """Aggregate per-slide lint reports into one deck-level SARIF document.
983
+
984
+ Pass a single :class:`SlideLintReport` or a sequence of them (one per
985
+ slide, in deck order). Each result records the slide index it came
986
+ from (in ``properties.slideIndex`` and as a ``logicalLocation``), so a
987
+ whole-deck SARIF uploaded to GitHub code-scanning keeps the slide
988
+ provenance of every annotation. *start_index* sets the index assigned
989
+ to the first report (default ``0``).
990
+
991
+ The result is a plain, ``json.dumps``-able ``dict``::
992
+
993
+ reports = [s.lint() for s in prs.slides]
994
+ sarif = lint_report_to_sarif(reports)
995
+ """
996
+ if isinstance(reports, SlideLintReport):
997
+ reports = [reports]
998
+ grouped: list[tuple[int | None, Sequence[LintIssue]]] = [
999
+ (start_index + i, report.issues) for i, report in enumerate(reports)
1000
+ ]
1001
+ return _build_sarif(grouped)
1002
+
1003
+
1004
+ def _slide_dimensions(slide: Slide) -> tuple[Length | None, Length | None]:
1005
+ """Return (width, height) of the slide in EMU, falling back to widescreen defaults."""
1006
+ try:
1007
+ prs = slide.part.package.presentation_part.presentation
1008
+ return prs.slide_width or _DEFAULT_SLIDE_W, prs.slide_height or _DEFAULT_SLIDE_H
1009
+ except Exception:
1010
+ return _DEFAULT_SLIDE_W, _DEFAULT_SLIDE_H
1011
+
1012
+
1013
+ def _shape_bbox(shape: BaseShape) -> tuple[int, int, int, int]:
1014
+ """Return (left, top, right, bottom) in EMU for the shape's bounding box.
1015
+
1016
+ Returns (0, 0, 0, 0) when position/size information is not available.
1017
+ """
1018
+ try:
1019
+ left = int(shape.left or 0)
1020
+ top = int(shape.top or 0)
1021
+ width = int(shape.width or 0)
1022
+ height = int(shape.height or 0)
1023
+ return left, top, left + width, top + height
1024
+ except Exception:
1025
+ return 0, 0, 0, 0
1026
+
1027
+
1028
+ def _effective_bbox(shape: BaseShape) -> tuple[int, int, int, int]:
1029
+ """Return the shape bbox inflated by its shadow's blur radius.
1030
+
1031
+ Each side is extended by ``blur_radius / 2``. Returns the raw bbox
1032
+ when no shadow is set or when ``shape.shadow`` is ``None`` (e.g.
1033
+ :class:`~pptx2.shapes.graphfrm.GraphicFrame`).
1034
+
1035
+ TODO: project the shadow ``distance`` along its ``direction`` to
1036
+ extend only the side(s) the shadow falls on, instead of inflating
1037
+ every side uniformly. Glow / soft-edges / reflection follow the
1038
+ same pattern and should be folded in.
1039
+ """
1040
+ left, top, right, bottom = _shape_bbox(shape)
1041
+ try:
1042
+ shadow = shape.shadow
1043
+ except Exception:
1044
+ return left, top, right, bottom
1045
+ if shadow is None:
1046
+ return left, top, right, bottom
1047
+ try:
1048
+ blur = shadow.blur_radius
1049
+ except Exception:
1050
+ return left, top, right, bottom
1051
+ if blur is None:
1052
+ return left, top, right, bottom
1053
+ inflate = int(int(blur) / 2)
1054
+ if inflate <= 0:
1055
+ return left, top, right, bottom
1056
+ return left - inflate, top - inflate, right + inflate, bottom + inflate
1057
+
1058
+
1059
+ def _check_off_slide(
1060
+ shape: BaseShape,
1061
+ slide_w: Length,
1062
+ slide_h: Length,
1063
+ *,
1064
+ bbox_fn=None,
1065
+ ) -> list[LintIssue]:
1066
+ """Return OffSlide issues for *shape* if it exceeds the slide boundary.
1067
+
1068
+ *bbox_fn* picks the bbox provider; defaults to :func:`_shape_bbox`.
1069
+ Pass :func:`_effective_bbox` to inflate by shadow blur radius. When
1070
+ a non-default *bbox_fn* is used, edges that exceed the slide *only*
1071
+ because of effect bleed are emitted as :class:`OffSlideShadow`.
1072
+ """
1073
+ issues: list[LintIssue] = []
1074
+ bbox_fn = bbox_fn or _shape_bbox
1075
+ left, top, right, bottom = bbox_fn(shape)
1076
+ raw = (
1077
+ (left, top, right, bottom)
1078
+ if bbox_fn is _shape_bbox
1079
+ else _shape_bbox(shape)
1080
+ )
1081
+ sw, sh = int(slide_w), int(slide_h)
1082
+
1083
+ def _cls(side: str, raw_off: bool) -> LintIssue:
1084
+ if bbox_fn is _shape_bbox or raw_off:
1085
+ return OffSlide(shape, side)
1086
+ return OffSlideShadow(shape, side)
1087
+
1088
+ if left < 0:
1089
+ issues.append(_cls("left", raw[0] < 0))
1090
+ if top < 0:
1091
+ issues.append(_cls("top", raw[1] < 0))
1092
+ if right > sw:
1093
+ issues.append(_cls("right", raw[2] > sw))
1094
+ if bottom > sh:
1095
+ issues.append(_cls("bottom", raw[3] > sh))
1096
+ return issues
1097
+
1098
+
1099
+ def _check_text_overflow(shape: BaseShape) -> list[LintIssue]:
1100
+ """Return TextOverflow issues for *shape* using a simple line-count heuristic.
1101
+
1102
+ Skips shapes with auto-size enabled or when no text frame is present.
1103
+ The heuristic estimates the number of lines the text would require
1104
+ (assuming ~60 characters per line at the default font size) and compares
1105
+ that to the number of lines that fit in the text-frame height.
1106
+ """
1107
+ issues: list[LintIssue] = []
1108
+ if not shape.has_text_frame:
1109
+ return issues
1110
+
1111
+ tf = shape.text_frame # type: ignore[attr-defined]
1112
+ # Skip when the shape auto-sizes (no overflow possible by definition)
1113
+ if tf.auto_size in (MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT, MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE):
1114
+ return issues
1115
+
1116
+ text = tf.text.strip()
1117
+ if not text:
1118
+ return issues
1119
+
1120
+ # Rough per-run font-size estimate: fall back to 18pt (914400 EMU/pt = 12700)
1121
+ _PT_TO_EMU = 12700
1122
+ _DEFAULT_FONT_PT = 18
1123
+ try:
1124
+ first_run = tf.paragraphs[0].runs[0]
1125
+ font_pt = (first_run.font.size or (_DEFAULT_FONT_PT * _PT_TO_EMU)) / _PT_TO_EMU
1126
+ except (IndexError, AttributeError):
1127
+ font_pt = _DEFAULT_FONT_PT
1128
+
1129
+ # Estimate shape inner width/height in EMU, accounting for text-frame margins.
1130
+ try:
1131
+ frame_w = int(shape.width or 0) - int(tf.margin_left or 0) - int(tf.margin_right or 0)
1132
+ frame_h = int(shape.height or 0) - int(tf.margin_top or 0) - int(tf.margin_bottom or 0)
1133
+ except Exception:
1134
+ return issues
1135
+
1136
+ if frame_w <= 0 or frame_h <= 0:
1137
+ return issues
1138
+
1139
+ # Approximate character width at the given pt size. The base 0.55×
1140
+ # multiplier is a Calibri-ish average across mixed casing, but it
1141
+ # systematically over-estimates short uppercase strings (badge /
1142
+ # pill labels) where every character is an upper-case letter and
1143
+ # there's only one line. For short single-line strings (≤ 20
1144
+ # chars), use a tighter 0.45× multiplier — see IMPROVEMENT_PLAN
1145
+ # item 11 for the failure case ("MOST POPULAR" pill at 9pt).
1146
+ has_newline = "\n" in text
1147
+ short_single_line = (not has_newline) and len(text) <= 20
1148
+ char_w_pt_mult = 0.45 if short_single_line else 0.55
1149
+ char_w_emu = font_pt * char_w_pt_mult * _PT_TO_EMU
1150
+ # Line height ~ 1.2 × font size
1151
+ line_h_emu = font_pt * 1.2 * _PT_TO_EMU
1152
+
1153
+ chars_per_line = max(1, frame_w / char_w_emu)
1154
+ lines_available = max(1, frame_h / line_h_emu)
1155
+ # When a line wraps, it occupies a whole-number line slot in the
1156
+ # rendered frame — half a line of overflow still pushes content out.
1157
+ # Counting the raw float (e.g. 1.5 lines for a 31-char line that
1158
+ # wraps to two) under-counts overflow vs. ``lines_available`` (also
1159
+ # a raw float), so a real two-line render slips past the check.
1160
+ # ``ceil`` matches what the renderer does: each wrapped line is one
1161
+ # full line of vertical space. See IMPROVEMENTS item 7.
1162
+ estimated_lines = sum(
1163
+ max(1, math.ceil(len(line) / chars_per_line)) for line in text.split("\n")
1164
+ )
1165
+
1166
+ if estimated_lines > lines_available:
1167
+ ratio = estimated_lines / lines_available
1168
+ issues.append(TextOverflow(shape, ratio))
1169
+
1170
+ return issues
1171
+
1172
+
1173
+ # Minimum overlap fraction to report a collision (avoids noise from barely
1174
+ # touching shapes)
1175
+ _COLLISION_THRESHOLD = 0.05
1176
+
1177
+ # Namespace for python-pptx2 metadata round-tripped through the deck. The
1178
+ # metadata is stored as a child element under ``cNvPr/extLst/ext``, the
1179
+ # OOXML-sanctioned extension mechanism — using a custom-namespaced
1180
+ # *attribute* on ``cNvPr`` (as the previous implementation did) violates
1181
+ # the CT_NonVisualDrawingProps schema, which has no ``xsd:anyAttribute``,
1182
+ # and triggers PowerPoint's "Repaired and removed" prompt on open.
1183
+ _LINT_NS = "https://python-pptx2.io/lint/2024"
1184
+
1185
+ # Stable GUID identifying the lint-metadata ``<a:ext>`` block. Once
1186
+ # published it must not change — PowerPoint preserves the element verbatim
1187
+ # as long as it doesn't recognise the URI.
1188
+ _LINT_EXT_URI = "{B7AB0FE6-95E5-4FB6-B41F-2C8B9F4D3A21}"
1189
+
1190
+ _A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
1191
+ _A_EXTLST = "{%s}extLst" % _A_NS
1192
+ _A_EXT = "{%s}ext" % _A_NS
1193
+ _PP_LINTGROUP = "{%s}lintGroup" % _LINT_NS
1194
+ _PP_LINTSKIP = "{%s}lintSkip" % _LINT_NS
1195
+ _PP_LINTALLOW = "{%s}lintAllow" % _LINT_NS
1196
+ _PP_LINTLAYER = "{%s}lintLayer" % _LINT_NS
1197
+
1198
+ # Pre-2.1.1 layout stored the value as a custom-namespaced attribute
1199
+ # directly on ``cNvPr``. Read-only fallback so decks saved with the old
1200
+ # release continue to round-trip after upgrade.
1201
+ _LEGACY_LINT_GROUP_ATTR = "{%s}group" % _LINT_NS
1202
+
1203
+ # Backwards-compatible alias for the public-but-underscored name some
1204
+ # external code (and our own tests) imported. Reads still work via the
1205
+ # fallback path; new writes go through the helpers below.
1206
+ _LINT_GROUP_ATTR = _LEGACY_LINT_GROUP_ATTR
1207
+
1208
+
1209
+ def _find_lint_ext(cNvPr):
1210
+ """Return the ``<a:ext uri=...>`` element holding lint metadata, or None."""
1211
+ extLst = cNvPr.find(_A_EXTLST)
1212
+ if extLst is None:
1213
+ return None
1214
+ for ext in extLst.findall(_A_EXT):
1215
+ if ext.get("uri") == _LINT_EXT_URI:
1216
+ return ext
1217
+ return None
1218
+
1219
+
1220
+ def _read_lint_group(cNvPr) -> str | None:
1221
+ """Return the ``lint_group`` value stored on *cNvPr*, or ``None``.
1222
+
1223
+ An *explicitly empty* tag is preserved as ``""`` (the "opted out
1224
+ of any implicit group" sentinel); a *missing* tag returns ``None``.
1225
+ """
1226
+ ext = _find_lint_ext(cNvPr)
1227
+ if ext is not None:
1228
+ node = ext.find(_PP_LINTGROUP)
1229
+ if node is not None:
1230
+ name = node.get("name")
1231
+ if name is not None:
1232
+ return name
1233
+ # Fallback: legacy attribute layout from pre-2.1.1.
1234
+ legacy = cNvPr.get(_LEGACY_LINT_GROUP_ATTR)
1235
+ return legacy if legacy else None
1236
+
1237
+
1238
+ def _write_lint_group(cNvPr, value: str) -> None:
1239
+ """Store ``lint_group = value`` on *cNvPr* using ``a:extLst/a:ext``.
1240
+
1241
+ Any legacy-format attribute is dropped by :func:`_get_or_add_lint_ext`
1242
+ and then overwritten here, so the new format is canonical.
1243
+ """
1244
+ from lxml import etree
1245
+
1246
+ ext = _get_or_add_lint_ext(cNvPr)
1247
+ node = ext.find(_PP_LINTGROUP)
1248
+ if node is None:
1249
+ node = etree.SubElement(ext, _PP_LINTGROUP)
1250
+ node.set("name", value)
1251
+
1252
+
1253
+ def _clear_lint_group(cNvPr) -> None:
1254
+ """Remove any lint-group metadata from *cNvPr* (both new and legacy).
1255
+
1256
+ Only the ``<pp:lintGroup>`` node is removed; siblings under the same
1257
+ ``<a:ext>`` (notably ``<pp:lintSkip>``) are preserved. The
1258
+ enclosing ``<a:ext>`` and ``<a:extLst>`` are removed only when they
1259
+ become empty as a side-effect.
1260
+ """
1261
+ if _LEGACY_LINT_GROUP_ATTR in cNvPr.attrib:
1262
+ del cNvPr.attrib[_LEGACY_LINT_GROUP_ATTR]
1263
+ extLst = cNvPr.find(_A_EXTLST)
1264
+ if extLst is None:
1265
+ return
1266
+ ext = _find_lint_ext(cNvPr)
1267
+ if ext is None:
1268
+ return
1269
+ node = ext.find(_PP_LINTGROUP)
1270
+ if node is not None:
1271
+ ext.remove(node)
1272
+ # Tidy up: drop the wrapper elements only if nothing else lives in
1273
+ # them, so an unrelated ``lint_skip`` setting on the same shape
1274
+ # survives a ``lint_group = None`` clear.
1275
+ if len(ext) == 0:
1276
+ extLst.remove(ext)
1277
+ if len(extLst) == 0:
1278
+ cNvPr.remove(extLst)
1279
+
1280
+
1281
+ def _shape_lint_group(shape: BaseShape) -> str | None:
1282
+ """Return the ``lint_group`` tag for *shape*, or ``None`` if untagged.
1283
+
1284
+ Resolution order:
1285
+
1286
+ 1. An explicit ``lint_group`` value stored on the shape's ``cNvPr``
1287
+ (set via ``shape.lint_group = "card"``).
1288
+ 2. A name-prefix convention: a shape named ``"card.bg"`` /
1289
+ ``"card.label"`` / ``"card.title"`` is implicitly grouped under
1290
+ ``"card"``. This lets a recipe author group several
1291
+ co-positioned shapes by naming them once, rather than tagging
1292
+ each individually after the fact.
1293
+
1294
+ The dotted-prefix convention is only a fallback — an explicit tag
1295
+ always wins, so callers who really want shapes named ``"foo.bar"``
1296
+ not to be grouped can clear the implicit grouping with
1297
+ ``shape.lint_group = ""`` (empty string is treated as a no-group
1298
+ sentinel).
1299
+ """
1300
+ try:
1301
+ cNvPr = shape._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
1302
+ except AttributeError:
1303
+ cNvPr = None
1304
+ if cNvPr is not None:
1305
+ explicit = _read_lint_group(cNvPr)
1306
+ if explicit is not None:
1307
+ # Empty-string explicit tag opts the shape *out* of any
1308
+ # implicit name-prefix group.
1309
+ return explicit or None
1310
+ # Name-prefix fallback.
1311
+ try:
1312
+ name = shape.name
1313
+ except Exception:
1314
+ return None
1315
+ if name and "." in name:
1316
+ prefix = name.split(".", 1)[0].strip()
1317
+ if prefix:
1318
+ return prefix
1319
+ return None
1320
+
1321
+
1322
+ def _read_lint_skip(cNvPr) -> frozenset[str]:
1323
+ """Return the set of lint check codes silenced on *cNvPr*."""
1324
+ ext = _find_lint_ext(cNvPr)
1325
+ if ext is None:
1326
+ return frozenset()
1327
+ node = ext.find(_PP_LINTSKIP)
1328
+ if node is None:
1329
+ return frozenset()
1330
+ raw = node.get("codes") or ""
1331
+ return frozenset(code for code in raw.split(",") if code)
1332
+
1333
+
1334
+ def _write_lint_skip(cNvPr, codes) -> None:
1335
+ """Store ``lint_skip = codes`` on *cNvPr* using ``a:extLst/a:ext``."""
1336
+ from lxml import etree
1337
+
1338
+ # ``_get_or_add_lint_ext`` drops the pre-2.1.1 legacy attribute —
1339
+ # the schema-invalid XML PowerPoint "repairs and removes" on open —
1340
+ # and migrates its value so writing ``lint_skip`` never costs the
1341
+ # caller their group tag.
1342
+ ext = _get_or_add_lint_ext(cNvPr)
1343
+
1344
+ node = ext.find(_PP_LINTSKIP)
1345
+ if not codes:
1346
+ # Empty assignment clears the node entirely, then drops the
1347
+ # ext / extLst if nothing else lives in them.
1348
+ if node is not None:
1349
+ ext.remove(node)
1350
+ _prune_lint_ext(cNvPr, ext, cNvPr.find(_A_EXTLST))
1351
+ return
1352
+
1353
+ if node is None:
1354
+ node = etree.SubElement(ext, _PP_LINTSKIP)
1355
+ # Sort for stable round-trip diffs; comma-joined is the simplest legal
1356
+ # serialisation for a small string set on a single attribute.
1357
+ node.set("codes", ",".join(sorted(codes)))
1358
+
1359
+
1360
+ def _shape_lint_skip(shape: BaseShape) -> frozenset[str]:
1361
+ """Return the set of lint check codes suppressed on *shape*."""
1362
+ try:
1363
+ cNvPr = shape._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
1364
+ except AttributeError:
1365
+ return frozenset()
1366
+ return _read_lint_skip(cNvPr)
1367
+
1368
+
1369
+ def _shape_id(shape: BaseShape) -> int | None:
1370
+ """Return the ``cNvPr/@id`` of *shape*, or ``None`` if unavailable.
1371
+
1372
+ Shape ids are unique within a slide and are what the pairwise
1373
+ overlap allowances below are keyed on — unlike ``shape.name`` they
1374
+ are guaranteed unique, and unlike ``id(shape)`` they survive a
1375
+ save/open round trip.
1376
+ """
1377
+ cNvPr = _shape_cNvPr(shape)
1378
+ if cNvPr is None:
1379
+ return None
1380
+ raw = cNvPr.get("id")
1381
+ if raw is None:
1382
+ return None
1383
+ try:
1384
+ return int(raw)
1385
+ except (TypeError, ValueError):
1386
+ return None
1387
+
1388
+
1389
+ def _shape_cNvPr(shape: BaseShape):
1390
+ """Return the shape's ``cNvPr`` element, or ``None`` if it has none."""
1391
+ try:
1392
+ return shape._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage]
1393
+ except AttributeError:
1394
+ return None
1395
+
1396
+
1397
+ def _prune_lint_ext(cNvPr, ext, extLst) -> None:
1398
+ """Drop *ext* / *extLst* once they hold no lint metadata.
1399
+
1400
+ Keeps the emitted XML minimal, so clearing the last lint setting on
1401
+ a shape leaves no residue behind.
1402
+ """
1403
+ if len(ext) == 0:
1404
+ extLst.remove(ext)
1405
+ if len(extLst) == 0:
1406
+ cNvPr.remove(extLst)
1407
+
1408
+
1409
+ def _get_or_add_lint_ext(cNvPr):
1410
+ """Return the lint ``<a:ext>`` for *cNvPr*, creating it if absent.
1411
+
1412
+ Touching the ext also *migrates* any pre-2.1.1 legacy ``lint_group``
1413
+ attribute into the canonical ``<pp:lintGroup>`` node. The legacy
1414
+ attribute is schema-invalid and has to go, but dropping it outright
1415
+ would mean that writing an unrelated field — a layer name, an
1416
+ overlap allowance, even a documented-no-op ``lint_skip = set()`` —
1417
+ silently erased a group tag the caller never mentioned.
1418
+ """
1419
+ from lxml import etree
1420
+
1421
+ legacy = cNvPr.get(_LEGACY_LINT_GROUP_ATTR)
1422
+ if _LEGACY_LINT_GROUP_ATTR in cNvPr.attrib:
1423
+ del cNvPr.attrib[_LEGACY_LINT_GROUP_ATTR]
1424
+
1425
+ # Go through the oxml descriptor so cNvPr's child ordering
1426
+ # (hlinkClick -> hlinkHover -> extLst) is respected.
1427
+ extLst = cNvPr.get_or_add_extLst()
1428
+ ext = _find_lint_ext(cNvPr)
1429
+ if ext is None:
1430
+ ext = etree.SubElement(extLst, _A_EXT)
1431
+ ext.set("uri", _LINT_EXT_URI)
1432
+
1433
+ # Carry the legacy value across, unless the canonical node already
1434
+ # exists (in which case it is authoritative and the attribute was
1435
+ # stale residue).
1436
+ if legacy and ext.find(_PP_LINTGROUP) is None:
1437
+ node = etree.SubElement(ext, _PP_LINTGROUP)
1438
+ node.set("name", legacy)
1439
+ return ext
1440
+
1441
+
1442
+ def _read_lint_allow(cNvPr) -> frozenset[int]:
1443
+ """Return the set of shape ids *cNvPr* is allowed to overlap."""
1444
+ ext = _find_lint_ext(cNvPr)
1445
+ if ext is None:
1446
+ return frozenset()
1447
+ node = ext.find(_PP_LINTALLOW)
1448
+ if node is None:
1449
+ return frozenset()
1450
+ ids: set[int] = set()
1451
+ for token in (node.get("ids") or "").split(","):
1452
+ token = token.strip()
1453
+ if not token:
1454
+ continue
1455
+ try:
1456
+ ids.add(int(token))
1457
+ except ValueError:
1458
+ # Ignore junk rather than failing the whole lint run — the
1459
+ # value may have been hand-edited or written by a future
1460
+ # release using a wider id syntax.
1461
+ continue
1462
+ return frozenset(ids)
1463
+
1464
+
1465
+ def _write_lint_allow(cNvPr, ids) -> None:
1466
+ """Store the pairwise overlap allowance set *ids* on *cNvPr*."""
1467
+ from lxml import etree
1468
+
1469
+ ext = _get_or_add_lint_ext(cNvPr)
1470
+ node = ext.find(_PP_LINTALLOW)
1471
+ if not ids:
1472
+ if node is not None:
1473
+ ext.remove(node)
1474
+ _prune_lint_ext(cNvPr, ext, cNvPr.find(_A_EXTLST))
1475
+ return
1476
+ if node is None:
1477
+ node = etree.SubElement(ext, _PP_LINTALLOW)
1478
+ # Sorted + comma-joined for stable round-trip diffs.
1479
+ node.set("ids", ",".join(str(i) for i in sorted(ids)))
1480
+
1481
+
1482
+ def _shape_lint_allow(shape: BaseShape) -> frozenset[int]:
1483
+ """Return the shape ids *shape* has been cleared to overlap."""
1484
+ cNvPr = _shape_cNvPr(shape)
1485
+ if cNvPr is None:
1486
+ return frozenset()
1487
+ return _read_lint_allow(cNvPr)
1488
+
1489
+
1490
+ def _read_lint_layer(cNvPr) -> tuple[str | None, str | None]:
1491
+ """Return ``(layer, layer_above)`` declared on *cNvPr*."""
1492
+ ext = _find_lint_ext(cNvPr)
1493
+ if ext is None:
1494
+ return None, None
1495
+ node = ext.find(_PP_LINTLAYER)
1496
+ if node is None:
1497
+ return None, None
1498
+ return node.get("name"), node.get("above")
1499
+
1500
+
1501
+ def _write_lint_layer(cNvPr, *, name: str | None, above: str | None) -> None:
1502
+ """Store the layer declaration ``(name, above)`` on *cNvPr*.
1503
+
1504
+ Passing ``None`` for both clears the declaration entirely.
1505
+ """
1506
+ from lxml import etree
1507
+
1508
+ ext = _get_or_add_lint_ext(cNvPr)
1509
+ node = ext.find(_PP_LINTLAYER)
1510
+ if name is None and above is None:
1511
+ if node is not None:
1512
+ ext.remove(node)
1513
+ _prune_lint_ext(cNvPr, ext, cNvPr.find(_A_EXTLST))
1514
+ return
1515
+ if node is None:
1516
+ node = etree.SubElement(ext, _PP_LINTLAYER)
1517
+ for attr, value in (("name", name), ("above", above)):
1518
+ if value is None:
1519
+ node.attrib.pop(attr, None)
1520
+ else:
1521
+ node.set(attr, value)
1522
+
1523
+
1524
+ def _shape_lint_layer(shape: BaseShape) -> tuple[str | None, str | None]:
1525
+ """Return ``(layer, layer_above)`` for *shape*."""
1526
+ cNvPr = _shape_cNvPr(shape)
1527
+ if cNvPr is None:
1528
+ return None, None
1529
+ return _read_lint_layer(cNvPr)
1530
+
1531
+
1532
+ def _overlap_allowed(
1533
+ shape_a: BaseShape,
1534
+ shape_b: BaseShape,
1535
+ allow_a: frozenset[int],
1536
+ allow_b: frozenset[int],
1537
+ ) -> bool:
1538
+ """Return True if either shape has cleared the other to overlap it.
1539
+
1540
+ The relationship is declared one-sided but read symmetrically: it
1541
+ takes only one of the pair to say "this overlap is deliberate" for
1542
+ the collision to be suppressed, matching how a designer thinks
1543
+ about it ("the badge is allowed to sit on the card") without
1544
+ forcing them to tag both ends.
1545
+ """
1546
+ id_a, id_b = _shape_id(shape_a), _shape_id(shape_b)
1547
+ if id_b is not None and id_b in allow_a:
1548
+ return True
1549
+ return id_a is not None and id_a in allow_b
1550
+
1551
+
1552
+ def _bbox_overlap(
1553
+ bbox_a: tuple[int, int, int, int],
1554
+ bbox_b: tuple[int, int, int, int],
1555
+ ) -> tuple[int, float]:
1556
+ """Return ``(intersection_area, overlap_pct)`` for two bboxes.
1557
+
1558
+ ``overlap_pct`` is the intersection area as a fraction of the
1559
+ smaller shape's area — the same metric the collision detector
1560
+ thresholds against. Returns ``(0, 0.0)`` when the bboxes do not
1561
+ intersect.
1562
+ """
1563
+ al, at, ar, ab = bbox_a
1564
+ bl, bt, br, bb = bbox_b
1565
+ ix_l = max(al, bl)
1566
+ ix_t = max(at, bt)
1567
+ ix_r = min(ar, br)
1568
+ ix_b = min(ab, bb)
1569
+ if ix_r <= ix_l or ix_b <= ix_t:
1570
+ return 0, 0.0
1571
+ area = (ix_r - ix_l) * (ix_b - ix_t)
1572
+ area_a = max(1, (ar - al) * (ab - at))
1573
+ area_b = max(1, (br - bl) * (bb - bt))
1574
+ return area, area / min(area_a, area_b)
1575
+
1576
+
1577
+ def _classify_collision(
1578
+ bbox_a: tuple[int, int, int, int],
1579
+ bbox_b: tuple[int, int, int, int],
1580
+ overlap_pct: float,
1581
+ ) -> tuple[str, float]:
1582
+ """Score and tier a collision into ``(kind, score)``.
1583
+
1584
+ ``score`` is the likelihood the overlap is a layout bug, in
1585
+ ``[0.0, 1.0]``. Higher = more suspicious. ``kind`` is one of
1586
+ ``"incidental"``, ``"partial"``, ``"matched"``.
1587
+
1588
+ Heuristic intent:
1589
+
1590
+ * **Containment** (the smaller shape is fully inside the larger)
1591
+ pulls the score *down* — this is the card-on-panel pattern.
1592
+ ``overlap_pct`` doubles as the containment ratio: it's the
1593
+ intersection area divided by the smaller shape's area, which
1594
+ reaches 1.0 exactly when the smaller shape is fully contained.
1595
+ * **Size ratio** (smaller_area / larger_area) close to 1.0 pulls
1596
+ the score *up* — same-size pairs are more likely duplicates.
1597
+ * **Overlap percentage** of the smaller shape pulls the score *up*.
1598
+ """
1599
+ al, at, ar, ab = bbox_a
1600
+ bl, bt, br, bb = bbox_b
1601
+ area_a = max(1, (ar - al) * (ab - at))
1602
+ area_b = max(1, (br - bl) * (bb - bt))
1603
+ size_ratio = min(area_a, area_b) / max(area_a, area_b) # in (0, 1]
1604
+
1605
+ # Tolerance in EMU for "near-identical bbox" (5% on each axis).
1606
+ tol_w = max(1, int(0.05 * max(ar - al, br - bl)))
1607
+ tol_h = max(1, int(0.05 * max(ab - at, bb - bt)))
1608
+ bboxes_match = (
1609
+ abs(al - bl) <= tol_w
1610
+ and abs(at - bt) <= tol_h
1611
+ and abs(ar - br) <= tol_w
1612
+ and abs(ab - bb) <= tol_h
1613
+ )
1614
+
1615
+ # "matched": near-identical bbox AND heavy overlap. Almost
1616
+ # certainly a duplicate / copy-paste bug.
1617
+ if bboxes_match and overlap_pct > 0.80:
1618
+ return "matched", min(1.0, 0.85 + 0.15 * overlap_pct)
1619
+
1620
+ # "incidental": one shape fully contains the other and they aren't
1621
+ # the same size — the card-on-panel pattern.
1622
+ fully_contained = (
1623
+ _bbox_contains(bbox_a, bbox_b) or _bbox_contains(bbox_b, bbox_a)
1624
+ )
1625
+ if fully_contained and size_ratio < 0.9:
1626
+ # Full containment + small size_ratio → very low score.
1627
+ score = 0.5 * size_ratio + 0.1 * (1.0 - overlap_pct)
1628
+ return "incidental", max(0.0, min(1.0, score))
1629
+
1630
+ # "partial": neither contains the other, scored on size_ratio and
1631
+ # overlap_pct. Two similarly-sized shapes overlapping a lot is
1632
+ # suspicious; two very-different sizes barely touching is not.
1633
+ score = 0.4 * size_ratio + 0.6 * min(1.0, overlap_pct)
1634
+ return "partial", max(0.0, min(1.0, score))
1635
+
1636
+
1637
+ def _layers_consistent(
1638
+ lower: tuple[str | None, str | None],
1639
+ upper: tuple[str | None, str | None],
1640
+ ) -> bool:
1641
+ """Return True if a pair declares a *satisfied* layer relationship.
1642
+
1643
+ Both arguments are ``(layer, layer_above)`` pairs. *lower* is the
1644
+ shape painted first and *upper* the one painted over it, so callers
1645
+ must pass them in ``spTree`` order. The relationship holds when the
1646
+ shape that is actually on top is the one that claimed to be: *upper*
1647
+ declares ``layer_above`` naming *lower*'s ``layer``.
1648
+
1649
+ The mirror case — the shape claiming to sit above is drawn
1650
+ underneath — is deliberately *not* handled here. That is the
1651
+ violation, and :func:`_check_layer_order` reports it.
1652
+ """
1653
+ lower_name, _ = lower
1654
+ _, upper_above = upper
1655
+ return bool(lower_name) and upper_above == lower_name
1656
+
1657
+
1658
+ def _check_layer_order(
1659
+ shapes: Sequence[BaseShape],
1660
+ *,
1661
+ bbox_fn=None,
1662
+ ) -> list[LintIssue]:
1663
+ """Return LayerOrderViolation issues for contradicted layer hints.
1664
+
1665
+ A shape that declares ``layer_above = "card"`` asserts it is painted
1666
+ on top of every shape whose ``layer`` is ``"card"`` that it overlaps.
1667
+ When the shape tree says otherwise — the declaring shape comes
1668
+ *earlier* in ``spTree`` and is therefore drawn underneath — the
1669
+ declaration and the drawing order contradict each other. The
1670
+ declaration is taken as the author's intent, so the z-order is
1671
+ reported as the bug.
1672
+
1673
+ Only overlapping pairs are considered: a layer declaration between
1674
+ shapes that never touch is inert, not wrong.
1675
+ """
1676
+ bbox_fn = bbox_fn or _shape_bbox
1677
+ layers = [_shape_lint_layer(s) for s in shapes]
1678
+ # Nothing declared anywhere — skip the O(n^2) walk entirely.
1679
+ if not any(name or above for name, above in layers):
1680
+ return []
1681
+
1682
+ bboxes = [bbox_fn(s) for s in shapes]
1683
+ issues: list[LintIssue] = []
1684
+ for i in range(len(shapes)):
1685
+ _name_i, above_i = layers[i]
1686
+ if not above_i:
1687
+ continue
1688
+ for j in range(i + 1, len(shapes)):
1689
+ name_j, _above_j = layers[j]
1690
+ if not name_j or name_j != above_i:
1691
+ continue
1692
+ # ``i`` claims to sit above layer ``name_j``, but ``j`` is
1693
+ # painted later and therefore covers it.
1694
+ _area, pct = _bbox_overlap(bboxes[i], bboxes[j])
1695
+ if pct < _COLLISION_THRESHOLD:
1696
+ continue
1697
+ issues.append(LayerOrderViolation(shapes[i], shapes[j], above_i))
1698
+ return issues
1699
+
1700
+
1701
+ def _check_collisions(
1702
+ shapes: Sequence[BaseShape],
1703
+ *,
1704
+ bbox_fn=None,
1705
+ ) -> list[LintIssue]:
1706
+ """Return ShapeCollision issues for pairs of overlapping shapes.
1707
+
1708
+ Shapes sharing a non-empty ``lint_group`` are treated as intentionally
1709
+ layered and never produce a collision warning — group suppression
1710
+ runs *before* scoring, since a tagged group is "intentional" by
1711
+ definition. Shapes with no group, or shapes in different groups,
1712
+ continue through to scoring + classification.
1713
+
1714
+ Two further intent declarations suppress a collision, both checked
1715
+ before scoring for the same reason:
1716
+
1717
+ * **Pairwise** — either shape has cleared the other via
1718
+ ``shape_a.allow_overlap_with(shape_b)``. Narrower than a
1719
+ ``lint_group``: it licenses exactly one pair rather than a whole
1720
+ tagged set.
1721
+ * **Layer hints** — the shapes declare a consistent
1722
+ ``layer`` / ``layer_above`` relationship (see
1723
+ :func:`_check_layer_order`, which reports the *inconsistent* case
1724
+ as a :class:`LayerOrderViolation` error).
1725
+
1726
+ *bbox_fn* picks the bbox provider; defaults to :func:`_shape_bbox`.
1727
+ Pass :func:`_effective_bbox` to inflate by shadow blur radius. When
1728
+ a non-default *bbox_fn* is supplied, collisions that would *not*
1729
+ fire on the raw bbox are emitted as the ``ShapeCollisionShadow``
1730
+ subclass so callers can opt them out separately via ``lint_skip``.
1731
+ """
1732
+ issues: list[LintIssue] = []
1733
+ bbox_fn = bbox_fn or _shape_bbox
1734
+ bboxes = [bbox_fn(s) for s in shapes]
1735
+ # Only compute raw bboxes when bleed is enabled — otherwise the
1736
+ # raw and effective bboxes are the same and there's nothing to
1737
+ # compare against.
1738
+ using_bleed = bbox_fn is not _shape_bbox
1739
+ raw_bboxes = [_shape_bbox(s) for s in shapes] if using_bleed else None
1740
+ groups = [_shape_lint_group(s) for s in shapes]
1741
+ allows = [_shape_lint_allow(s) for s in shapes]
1742
+ layers = [_shape_lint_layer(s) for s in shapes]
1743
+
1744
+ for i in range(len(shapes)):
1745
+ for j in range(i + 1, len(shapes)):
1746
+ # Suppress collisions inside a designer-tagged group *before*
1747
+ # scoring — a tagged group is "intentional" by definition.
1748
+ gi, gj = groups[i], groups[j]
1749
+ if gi is not None and gi == gj:
1750
+ continue
1751
+
1752
+ # Explicit pairwise allowance — one side naming the other is
1753
+ # enough. Also intent, so it too runs before scoring.
1754
+ if _overlap_allowed(shapes[i], shapes[j], allows[i], allows[j]):
1755
+ continue
1756
+
1757
+ # Layer hints: an overlap that agrees with a declared
1758
+ # layer relationship is intentional. Disagreement is *not*
1759
+ # silently allowed — it surfaces as a LayerOrderViolation
1760
+ # from _check_layer_order, so staying quiet here would drop
1761
+ # the pair entirely.
1762
+ if _layers_consistent(layers[i], layers[j]):
1763
+ continue
1764
+
1765
+ area, pct = _bbox_overlap(bboxes[i], bboxes[j])
1766
+ if pct < _COLLISION_THRESHOLD:
1767
+ continue
1768
+
1769
+ # Auto-suppress the "small shape stacked on top of a larger
1770
+ # backing card" pattern (badge-on-card, eyebrow-on-rectangle,
1771
+ # accent-bar-on-card). The combination of (a) the smaller
1772
+ # shape is *strictly* contained inside the larger (size
1773
+ # ratio < 0.9 — equal-bbox pairs are still classified as
1774
+ # ``matched`` so callers can audit them), and (b) the
1775
+ # smaller shape has a higher z-order — i.e. it's drawn
1776
+ # later in spTree (higher index in this iteration) — is the
1777
+ # canonical layered-design layout. See IMPROVEMENT_PLAN.md
1778
+ # item 12.
1779
+ ai_l, ai_t, ai_r, ai_b = bboxes[i]
1780
+ aj_l, aj_t, aj_r, aj_b = bboxes[j]
1781
+ area_i = max(1, (ai_r - ai_l) * (ai_b - ai_t))
1782
+ area_j = max(1, (aj_r - aj_l) * (aj_b - aj_t))
1783
+ size_ratio = min(area_i, area_j) / max(area_i, area_j)
1784
+ if size_ratio < 0.9:
1785
+ if _bbox_contains(bboxes[i], bboxes[j]) and area_j < area_i:
1786
+ # j is the smaller shape and it's drawn on top of i
1787
+ # (j > i in spTree order); skip.
1788
+ continue
1789
+ # Note: ``i contained in j with i < j`` would mean the
1790
+ # smaller shape is *under* the larger one — a
1791
+ # ZOrderAnomaly, not a layered-design pattern — so we
1792
+ # let collision detection proceed.
1793
+
1794
+ kind, score = _classify_collision(bboxes[i], bboxes[j], pct)
1795
+
1796
+ # Decide whether the inflated bbox is what triggered the
1797
+ # collision: if the raw bboxes don't intersect by at least
1798
+ # the threshold, this is bleed-only.
1799
+ cls: type[ShapeCollision] = ShapeCollision
1800
+ if using_bleed:
1801
+ assert raw_bboxes is not None # narrows type for mypy
1802
+ _, raw_pct = _bbox_overlap(raw_bboxes[i], raw_bboxes[j])
1803
+ if raw_pct < _COLLISION_THRESHOLD:
1804
+ cls = ShapeCollisionShadow
1805
+
1806
+ issues.append(
1807
+ cls(
1808
+ shapes[i],
1809
+ shapes[j],
1810
+ intersection_area=area,
1811
+ intersection_pct=pct,
1812
+ groups=(gi, gj),
1813
+ score=score,
1814
+ kind=kind,
1815
+ )
1816
+ )
1817
+
1818
+ return issues
1819
+
1820
+
1821
+ # ---------------------------------------------------------------------------
1822
+ # Min font size — flag any run below the legibility threshold (default 9pt).
1823
+ # ---------------------------------------------------------------------------
1824
+
1825
+ _DEFAULT_MIN_FONT_PT = 9.0
1826
+ _PT_TO_EMU = 12700
1827
+
1828
+
1829
+ def _check_min_font_size(
1830
+ shape: BaseShape, threshold_pt: float = _DEFAULT_MIN_FONT_PT
1831
+ ) -> list[LintIssue]:
1832
+ """Return a single MinFontSize issue if any run is below *threshold_pt*."""
1833
+ issues: list[LintIssue] = []
1834
+ if not shape.has_text_frame:
1835
+ return issues
1836
+ tf = shape.text_frame # type: ignore[attr-defined]
1837
+ smallest: float | None = None
1838
+ for paragraph in tf.paragraphs:
1839
+ for run in paragraph.runs:
1840
+ try:
1841
+ size = run.font.size
1842
+ except (AttributeError, ValueError):
1843
+ continue
1844
+ if size is None:
1845
+ continue
1846
+ pt = float(size) / _PT_TO_EMU
1847
+ if pt > 0 and (smallest is None or pt < smallest):
1848
+ smallest = pt
1849
+ if smallest is not None and smallest < threshold_pt:
1850
+ issues.append(MinFontSize(shape, smallest, threshold_pt))
1851
+ return issues
1852
+
1853
+
1854
+ # ---------------------------------------------------------------------------
1855
+ # Off-grid drift — find shapes whose edge is slightly off a grid line that
1856
+ # at least three siblings hit cleanly.
1857
+ # ---------------------------------------------------------------------------
1858
+
1859
+ # A shape is "on" a grid line if it's within this much of the cluster center
1860
+ # (1/100"). Anything further is potential drift.
1861
+ _GRID_TIGHT_TOLERANCE_EMU = 45720 # ~0.05" (was 0.01"; see IMPROVEMENT_PLAN item 10)
1862
+ # Drift candidates must be within this much of a cluster (else they're just
1863
+ # unrelated edges).
1864
+ _GRID_LOOSE_TOLERANCE_EMU = 91440 # ~0.10"
1865
+ # A grid line needs at least this many shapes on it before we trust it.
1866
+ _GRID_MIN_CLUSTER = 3
1867
+
1868
+
1869
+ def _cluster_edges(values: list[int], tol: int) -> list[tuple[int, int]]:
1870
+ """Return list of (cluster_center_emu, member_count) for clusters of values
1871
+ that lie within *tol* of each other.
1872
+
1873
+ Greedy single-pass clustering; values are sorted, then any gap larger
1874
+ than *tol* breaks the cluster.
1875
+ """
1876
+ if not values:
1877
+ return []
1878
+ sorted_v = sorted(values)
1879
+ clusters: list[list[int]] = [[sorted_v[0]]]
1880
+ for v in sorted_v[1:]:
1881
+ if v - clusters[-1][-1] <= tol:
1882
+ clusters[-1].append(v)
1883
+ else:
1884
+ clusters.append([v])
1885
+ # ``+ 0.5`` is round-half-up for the always-non-negative cluster centers;
1886
+ # behaves identically to ``round()`` here but avoids any banker's-rounding
1887
+ # edge case at exact half-EMU boundaries.
1888
+ return [(int(sum(c) / len(c) + 0.5), len(c)) for c in clusters]
1889
+
1890
+
1891
+ def _check_off_grid_drift(shapes: Sequence[BaseShape]) -> list[LintIssue]:
1892
+ """Return OffGridDrift issues for shapes whose edges are slightly off a grid."""
1893
+ issues: list[LintIssue] = []
1894
+ if len(shapes) < _GRID_MIN_CLUSTER + 1:
1895
+ return issues
1896
+
1897
+ bboxes = [_shape_bbox(s) for s in shapes]
1898
+
1899
+ for axis_name, edge_idx in (("left", 0), ("top", 1)):
1900
+ edges = [b[edge_idx] for b in bboxes]
1901
+ clusters = _cluster_edges(edges, _GRID_TIGHT_TOLERANCE_EMU)
1902
+ # Only clusters with enough members are "grid lines".
1903
+ grid_lines = [center for center, n in clusters if n >= _GRID_MIN_CLUSTER]
1904
+ if not grid_lines:
1905
+ continue
1906
+ for shape, edge in zip(shapes, edges):
1907
+ # Skip shapes that already sit on a grid line.
1908
+ on_grid = any(
1909
+ abs(edge - g) <= _GRID_TIGHT_TOLERANCE_EMU for g in grid_lines
1910
+ )
1911
+ if on_grid:
1912
+ continue
1913
+ # Find the closest grid line; if it's within the loose tolerance,
1914
+ # this is drift.
1915
+ closest = min(grid_lines, key=lambda g: abs(edge - g))
1916
+ drift = abs(edge - closest)
1917
+ if (
1918
+ _GRID_TIGHT_TOLERANCE_EMU < drift <= _GRID_LOOSE_TOLERANCE_EMU
1919
+ ):
1920
+ issues.append(OffGridDrift(shape, axis_name, drift, closest))
1921
+ return issues
1922
+
1923
+
1924
+ # ---------------------------------------------------------------------------
1925
+ # Low contrast — compare text RGB against shape fill RGB (or, if absent,
1926
+ # slide background RGB) and warn when the ratio is below WCAG AA (4.5:1).
1927
+ # Skips silently when colors can't be resolved (theme color, gradient, etc.).
1928
+ # ---------------------------------------------------------------------------
1929
+
1930
+ _CONTRAST_THRESHOLD = 4.5
1931
+
1932
+
1933
+ def _relative_luminance(rgb) -> float:
1934
+ """Return WCAG relative luminance of an ``RGBColor``."""
1935
+ r, g, b = (int(rgb[0]) / 255.0, int(rgb[1]) / 255.0, int(rgb[2]) / 255.0)
1936
+
1937
+ def _ch(c: float) -> float:
1938
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
1939
+
1940
+ return 0.2126 * _ch(r) + 0.7152 * _ch(g) + 0.0722 * _ch(b)
1941
+
1942
+
1943
+ def _contrast_ratio(rgb_a, rgb_b) -> float:
1944
+ """Return WCAG contrast ratio between two ``RGBColor`` values."""
1945
+ la = _relative_luminance(rgb_a)
1946
+ lb = _relative_luminance(rgb_b)
1947
+ light, dark = (la, lb) if la >= lb else (lb, la)
1948
+ return (light + 0.05) / (dark + 0.05)
1949
+
1950
+
1951
+ def _resolve_solid_rgb(fill):
1952
+ """Return RGB of *fill* if it's an explicit solid RGB; ``None`` otherwise.
1953
+
1954
+ We deliberately don't resolve theme colors or gradients here — getting
1955
+ that right requires walking the theme + clrMap. Skipping silently keeps
1956
+ the lint check noise-free.
1957
+ """
1958
+ if fill is None:
1959
+ return None
1960
+ try:
1961
+ from pptx2.enum.dml import MSO_FILL_TYPE
1962
+
1963
+ if fill.type != MSO_FILL_TYPE.SOLID:
1964
+ return None
1965
+ return fill.fore_color.rgb
1966
+ except Exception:
1967
+ return None
1968
+
1969
+
1970
+ def _slide_background_rgb(slide: Slide):
1971
+ """Best-effort RGB extraction from the slide's explicit background fill.
1972
+
1973
+ Returns ``None`` if the background inherits from the master/layout or is
1974
+ not a solid RGB color.
1975
+ """
1976
+ try:
1977
+ bg = slide._element.bg # pyright: ignore[reportPrivateUsage]
1978
+ if bg is None:
1979
+ return None
1980
+ # Read the existing ``<p:bgPr>`` non-destructively. ``get_or_add_bgPr``
1981
+ # would mutate the slide (drop a ``<p:bgRef>`` style reference and
1982
+ # synthesize a noFill ``<p:bgPr>``), and ``slide.lint()`` must never
1983
+ # mutate slide XML.
1984
+ bgPr = bg.bgPr
1985
+ if bgPr is None:
1986
+ return None
1987
+ from pptx2.dml.fill import FillFormat
1988
+
1989
+ return _resolve_solid_rgb(FillFormat.from_fill_parent(bgPr))
1990
+ except Exception:
1991
+ return None
1992
+
1993
+
1994
+ def _check_low_contrast(shape: BaseShape, slide: Slide) -> list[LintIssue]:
1995
+ """Return a LowContrast issue if shape's text has poor contrast against its fill."""
1996
+ issues: list[LintIssue] = []
1997
+ if not shape.has_text_frame:
1998
+ return issues
1999
+ tf = shape.text_frame # type: ignore[attr-defined]
2000
+ if not tf.text.strip():
2001
+ return issues
2002
+
2003
+ # Pick the first run's font color (best-effort).
2004
+ text_rgb = None
2005
+ try:
2006
+ for paragraph in tf.paragraphs:
2007
+ for run in paragraph.runs:
2008
+ try:
2009
+ rgb = run.font.color.rgb
2010
+ except (AttributeError, ValueError):
2011
+ rgb = None
2012
+ if rgb is not None:
2013
+ text_rgb = rgb
2014
+ break
2015
+ if text_rgb is not None:
2016
+ break
2017
+ except Exception:
2018
+ return issues
2019
+ if text_rgb is None:
2020
+ return issues
2021
+
2022
+ # Find a background to compare against: prefer shape fill, then slide bg.
2023
+ bg_rgb = None
2024
+ try:
2025
+ bg_rgb = _resolve_solid_rgb(shape.fill) # type: ignore[attr-defined]
2026
+ except Exception:
2027
+ pass
2028
+ if bg_rgb is None:
2029
+ bg_rgb = _slide_background_rgb(slide)
2030
+ if bg_rgb is None:
2031
+ return issues
2032
+
2033
+ ratio = _contrast_ratio(text_rgb, bg_rgb)
2034
+ if ratio < _CONTRAST_THRESHOLD:
2035
+ issues.append(LowContrast(shape, ratio, _CONTRAST_THRESHOLD))
2036
+ return issues
2037
+
2038
+
2039
+ # ---------------------------------------------------------------------------
2040
+ # Z-order anomaly — a filled shape A is drawn above shape B that A visually
2041
+ # contains. A's fill would hide B at render time.
2042
+ # ---------------------------------------------------------------------------
2043
+
2044
+
2045
+ def _bbox_contains(outer: tuple[int, int, int, int], inner: tuple[int, int, int, int]) -> bool:
2046
+ """True if *inner* sits fully inside *outer* (with a small tolerance)."""
2047
+ tol = 2 # EMU; tolerance for floating-point round trips
2048
+ return (
2049
+ outer[0] - tol <= inner[0]
2050
+ and outer[1] - tol <= inner[1]
2051
+ and outer[2] + tol >= inner[2]
2052
+ and outer[3] + tol >= inner[3]
2053
+ and (inner[2] - inner[0]) > 0
2054
+ and (inner[3] - inner[1]) > 0
2055
+ )
2056
+
2057
+
2058
+ def _shape_has_opaque_fill(shape: BaseShape) -> bool:
2059
+ """Return ``True`` if *shape* has an opaque (solid) fill."""
2060
+ try:
2061
+ from pptx2.enum.dml import MSO_FILL_TYPE
2062
+
2063
+ return shape.fill.type == MSO_FILL_TYPE.SOLID # type: ignore[attr-defined]
2064
+ except Exception:
2065
+ return False
2066
+
2067
+
2068
+ def _check_z_order_anomalies(shapes: Sequence[BaseShape]) -> list[LintIssue]:
2069
+ """Find filled shapes drawn above shapes they visually contain."""
2070
+ issues: list[LintIssue] = []
2071
+ bboxes = [_shape_bbox(s) for s in shapes]
2072
+ # Document order = draw order; later shapes are drawn on top.
2073
+ for j in range(len(shapes)):
2074
+ # j is the candidate "container" drawn above earlier shapes
2075
+ if not _shape_has_opaque_fill(shapes[j]):
2076
+ continue
2077
+ for i in range(j):
2078
+ if not _bbox_contains(bboxes[j], bboxes[i]):
2079
+ continue
2080
+ # Tolerate identical bboxes — those are layered groups, not
2081
+ # anomalies; the drift case is when j strictly contains i.
2082
+ if bboxes[j] == bboxes[i]:
2083
+ continue
2084
+ issues.append(ZOrderAnomaly(container=shapes[j], contained=shapes[i]))
2085
+ return issues
2086
+
2087
+
2088
+ # ---------------------------------------------------------------------------
2089
+ # Master-placeholder collision — a non-placeholder shape whose bbox closely
2090
+ # matches a layout placeholder. Caller likely meant to populate the
2091
+ # placeholder rather than redraw it.
2092
+ # ---------------------------------------------------------------------------
2093
+
2094
+ # Tolerance for deciding "same position" — 1/20".
2095
+ _PH_POS_TOLERANCE_EMU = 45720
2096
+
2097
+
2098
+ def _placeholder_bboxes(slide: Slide) -> list[tuple[int, int, int, int, int]]:
2099
+ """Return (left, top, right, bottom, idx) for each layout placeholder.
2100
+
2101
+ Only inheritable placeholders that the slide doesn't already use are
2102
+ returned.
2103
+ """
2104
+ out: list[tuple[int, int, int, int, int]] = []
2105
+ try:
2106
+ layout = slide.slide_layout
2107
+ except Exception:
2108
+ return out
2109
+ used_idxs: set[int] = set()
2110
+ try:
2111
+ for ph in slide.placeholders:
2112
+ used_idxs.add(int(ph.placeholder_format.idx))
2113
+ except Exception:
2114
+ pass
2115
+ try:
2116
+ for ph in layout.placeholders:
2117
+ try:
2118
+ idx = int(ph.placeholder_format.idx)
2119
+ except Exception:
2120
+ continue
2121
+ if idx in used_idxs:
2122
+ continue
2123
+ l, t, r, b = _shape_bbox(ph)
2124
+ if r - l <= 0 or b - t <= 0:
2125
+ continue
2126
+ out.append((l, t, r, b, idx))
2127
+ except Exception:
2128
+ return out
2129
+ return out
2130
+
2131
+
2132
+ def _check_master_placeholder_collision(
2133
+ slide: Slide, shapes: Sequence[BaseShape]
2134
+ ) -> list[LintIssue]:
2135
+ """Find shapes whose bbox lines up with an unused layout placeholder."""
2136
+ issues: list[LintIssue] = []
2137
+ ph_bboxes = _placeholder_bboxes(slide)
2138
+ if not ph_bboxes:
2139
+ return issues
2140
+ for shape in shapes:
2141
+ if shape.is_placeholder:
2142
+ continue
2143
+ sl, st, sr, sb = _shape_bbox(shape)
2144
+ if sr - sl <= 0 or sb - st <= 0:
2145
+ continue
2146
+ for pl, pt, pr, pb, idx in ph_bboxes:
2147
+ if (
2148
+ abs(sl - pl) <= _PH_POS_TOLERANCE_EMU
2149
+ and abs(st - pt) <= _PH_POS_TOLERANCE_EMU
2150
+ and abs(sr - pr) <= _PH_POS_TOLERANCE_EMU
2151
+ and abs(sb - pb) <= _PH_POS_TOLERANCE_EMU
2152
+ ):
2153
+ issues.append(MasterPlaceholderCollision(shape, idx))
2154
+ break
2155
+ return issues
2156
+
2157
+
2158
+ def lint_slide(
2159
+ slide: Slide,
2160
+ *,
2161
+ include_effect_bleed: bool = False,
2162
+ disable: Sequence[str] = (),
2163
+ min_severity: str | LintSeverity = LintSeverity.INFO,
2164
+ ) -> SlideLintReport:
2165
+ """Inspect *slide* for geometric and typographic issues.
2166
+
2167
+ *include_effect_bleed* is opt-in (default ``False``): when ``True``
2168
+ the :class:`OffSlide` and :class:`ShapeCollision` detectors widen
2169
+ each shape's bbox by its shadow blur radius before checking
2170
+ geometry. Bleed-only triggers come back as :class:`OffSlideShadow`
2171
+ / :class:`ShapeCollisionShadow` so callers can opt them out
2172
+ separately via ``shape.lint_skip``.
2173
+
2174
+ *disable* is an iterable of issue ``code`` values to skip entirely
2175
+ — e.g. ``disable=["ShapeCollision", "OffGridDrift"]`` silences both
2176
+ rules deck-wide. ``ShapeCollisionShadow`` and ``OffSlideShadow``
2177
+ are *not* implied by their non-shadow base codes; pass them
2178
+ explicitly.
2179
+
2180
+ *min_severity* drops issues below the named threshold from the
2181
+ report. Accepts a :class:`LintSeverity` member or a case-insensitive
2182
+ string (``"info"``, ``"warning"``, ``"error"``). The default
2183
+ ``"info"`` keeps everything.
2184
+
2185
+ Returns a :class:`SlideLintReport` with the detected issues.
2186
+ """
2187
+ if isinstance(min_severity, str):
2188
+ try:
2189
+ min_severity_enum = LintSeverity(min_severity.lower())
2190
+ except ValueError:
2191
+ raise ValueError(
2192
+ f"min_severity must be one of "
2193
+ f"{[s.value for s in LintSeverity]}, got {min_severity!r}"
2194
+ )
2195
+ else:
2196
+ min_severity_enum = min_severity
2197
+ disabled = frozenset(disable)
2198
+
2199
+ slide_w, slide_h = _slide_dimensions(slide)
2200
+ issues: list[LintIssue] = []
2201
+ shapes = list(slide.shapes)
2202
+ bbox_fn = _effective_bbox if include_effect_bleed else _shape_bbox
2203
+
2204
+ for shape in shapes:
2205
+ if slide_w is not None and slide_h is not None:
2206
+ issues.extend(
2207
+ _check_off_slide(shape, slide_w, slide_h, bbox_fn=bbox_fn)
2208
+ )
2209
+ issues.extend(_check_text_overflow(shape))
2210
+ issues.extend(_check_min_font_size(shape))
2211
+ issues.extend(_check_low_contrast(shape, slide))
2212
+
2213
+ issues.extend(_check_collisions(shapes, bbox_fn=bbox_fn))
2214
+ issues.extend(_check_off_grid_drift(shapes))
2215
+ issues.extend(_check_z_order_anomalies(shapes))
2216
+ issues.extend(_check_layer_order(shapes, bbox_fn=bbox_fn))
2217
+ issues.extend(_check_master_placeholder_collision(slide, shapes))
2218
+
2219
+ # Per-shape opt-out: drop issues whose code is silenced on *every*
2220
+ # target shape via ``shape.lint_skip``. Cross-shape issues
2221
+ # (ShapeCollision, ZOrderAnomaly) are only suppressed when *both*
2222
+ # shapes opt out — a one-sided opt-out keeps the warning, since the
2223
+ # other shape might still want to know.
2224
+ skip_cache: dict[int, frozenset[str]] = {}
2225
+
2226
+ def _skipped(issue: LintIssue) -> bool:
2227
+ if not issue.shapes:
2228
+ return False
2229
+ for shape in issue.shapes:
2230
+ key = id(shape._element) # pyright: ignore[reportPrivateUsage]
2231
+ if key not in skip_cache:
2232
+ skip_cache[key] = _shape_lint_skip(shape)
2233
+ if issue.code not in skip_cache[key]:
2234
+ return False
2235
+ return True
2236
+
2237
+ _order = {LintSeverity.ERROR: 0, LintSeverity.WARNING: 1, LintSeverity.INFO: 2}
2238
+ threshold = _order[min_severity_enum]
2239
+
2240
+ issues = [
2241
+ i for i in issues
2242
+ if not _skipped(i)
2243
+ and i.code not in disabled
2244
+ and _order[i.severity] <= threshold
2245
+ ]
2246
+
2247
+ # Sort: errors → warnings → info
2248
+ issues.sort(key=lambda x: _order[x.severity])
2249
+
2250
+ return SlideLintReport(
2251
+ slide,
2252
+ issues,
2253
+ include_effect_bleed=include_effect_bleed,
2254
+ disable=tuple(disabled),
2255
+ min_severity=min_severity_enum,
2256
+ )