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