figkit 0.1.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.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/audit.py
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Static checks on a finished figure — the "did I make a mess?" pass.
|
|
2
|
+
|
|
3
|
+
The point of :func:`audit` is to answer, without rendering and looking, the
|
|
4
|
+
questions you would otherwise only notice by eye: is a label sticking out of
|
|
5
|
+
its box, is something covering something else, is that text invisible against
|
|
6
|
+
its background, does an arrow run straight through an unrelated box.
|
|
7
|
+
|
|
8
|
+
The hard part is *not* finding overlaps — it is not reporting the intentional
|
|
9
|
+
ones. A figure is full of deliberate overlap: labels sit inside boxes, panels
|
|
10
|
+
sit behind their contents, arrows touch the things they connect, decorative
|
|
11
|
+
shapes fan across each other. So the checks lean on structure rather than
|
|
12
|
+
guesswork:
|
|
13
|
+
|
|
14
|
+
* anything with ``z < 0`` is a declared backdrop and is expected to be under
|
|
15
|
+
things;
|
|
16
|
+
* full containment is nesting, not collision;
|
|
17
|
+
* an element never collides with its own ancestors or descendants;
|
|
18
|
+
* line-like elements are tested by their actual path, never by the bounding
|
|
19
|
+
box of a diagonal;
|
|
20
|
+
* a connector never collides with the elements it connects;
|
|
21
|
+
* by default only overlaps that can *hide content* are reported — two bare
|
|
22
|
+
decorative polygons overlapping is your business, a box covering a label is
|
|
23
|
+
a bug.
|
|
24
|
+
|
|
25
|
+
Every rule has an escape hatch: ``Element(audit=False)`` opts an element out
|
|
26
|
+
entirely, and each check can be switched off in the call.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
from .colors import luminance, parse_color
|
|
35
|
+
from .connectors import Connector
|
|
36
|
+
from .core import Element, Group
|
|
37
|
+
from .geom import BBox, Point
|
|
38
|
+
from .shapes import Line, Polyline, Shape
|
|
39
|
+
from .text import Text
|
|
40
|
+
|
|
41
|
+
__all__ = ["audit", "Finding", "Report", "paint_order"]
|
|
42
|
+
|
|
43
|
+
#: Elements whose geometry has no area, so a bounding box says little.
|
|
44
|
+
_LINE_LIKE = (Connector, Line)
|
|
45
|
+
|
|
46
|
+
_SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ==========================================================================
|
|
50
|
+
# Results
|
|
51
|
+
# ==========================================================================
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Finding:
|
|
55
|
+
"""One thing that looks wrong."""
|
|
56
|
+
|
|
57
|
+
kind: str
|
|
58
|
+
message: str
|
|
59
|
+
severity: str = "warning"
|
|
60
|
+
where: Point | None = None
|
|
61
|
+
elements: tuple = ()
|
|
62
|
+
detail: dict = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
at = f" at ({self.where.x:.0f}, {self.where.y:.0f})" if self.where else ""
|
|
66
|
+
return f"{self.kind:<10} {self.message}{at}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Report:
|
|
70
|
+
"""The result of an :func:`audit`. Falsy when the figure looks clean.
|
|
71
|
+
|
|
72
|
+
>>> report = fig.audit()
|
|
73
|
+
>>> print(report)
|
|
74
|
+
>>> if report: ...
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, findings=(), checked: int = 0):
|
|
78
|
+
self.findings = sorted(
|
|
79
|
+
findings, key=lambda f: (_SEVERITY_ORDER.get(f.severity, 3), f.kind))
|
|
80
|
+
self.checked = checked
|
|
81
|
+
|
|
82
|
+
def __bool__(self) -> bool:
|
|
83
|
+
return bool(self.findings)
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return len(self.findings)
|
|
87
|
+
|
|
88
|
+
def __iter__(self):
|
|
89
|
+
return iter(self.findings)
|
|
90
|
+
|
|
91
|
+
def __getitem__(self, i):
|
|
92
|
+
return self.findings[i]
|
|
93
|
+
|
|
94
|
+
def by_kind(self, kind: str) -> list:
|
|
95
|
+
return [f for f in self.findings if f.kind == kind]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def errors(self) -> list:
|
|
99
|
+
return [f for f in self.findings if f.severity == "error"]
|
|
100
|
+
|
|
101
|
+
def raise_if_any(self) -> "Report":
|
|
102
|
+
"""Turn the report into an exception — handy in tests."""
|
|
103
|
+
if self.findings:
|
|
104
|
+
raise AssertionError(str(self))
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def summary(self) -> str:
|
|
108
|
+
if not self.findings:
|
|
109
|
+
return f"figkit audit: no issues ({self.checked} elements)"
|
|
110
|
+
counts: dict = {}
|
|
111
|
+
for f in self.findings:
|
|
112
|
+
counts[f.kind] = counts.get(f.kind, 0) + 1
|
|
113
|
+
parts = ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
|
|
114
|
+
return (f"figkit audit: {len(self.findings)} issue"
|
|
115
|
+
f"{'s' if len(self.findings) != 1 else ''} "
|
|
116
|
+
f"({parts}) in {self.checked} elements")
|
|
117
|
+
|
|
118
|
+
def __str__(self) -> str:
|
|
119
|
+
if not self.findings:
|
|
120
|
+
return self.summary()
|
|
121
|
+
lines = [self.summary()]
|
|
122
|
+
lines += [f" {f}" for f in self.findings]
|
|
123
|
+
return "\n".join(lines)
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return f"<Report {len(self.findings)} findings>"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ==========================================================================
|
|
130
|
+
# Helpers
|
|
131
|
+
# ==========================================================================
|
|
132
|
+
|
|
133
|
+
def describe(el) -> str:
|
|
134
|
+
"""A short, recognisable name for an element."""
|
|
135
|
+
kind = type(el).__name__
|
|
136
|
+
if getattr(el, "name", None):
|
|
137
|
+
return f"{kind}({el.name!r})"
|
|
138
|
+
text = getattr(el, "text", None)
|
|
139
|
+
if isinstance(text, str) and text.strip():
|
|
140
|
+
flat = " ".join(text.split())
|
|
141
|
+
if len(flat) > 24:
|
|
142
|
+
flat = flat[:21] + "…"
|
|
143
|
+
return f"{kind}({flat!r})"
|
|
144
|
+
bb = el.bbox
|
|
145
|
+
return f"{kind}@({bb.x:.0f},{bb.y:.0f})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def paint_order(root) -> list:
|
|
149
|
+
"""Every element in the order it is painted (later = on top)."""
|
|
150
|
+
out: list = []
|
|
151
|
+
|
|
152
|
+
def walk(node):
|
|
153
|
+
kids = getattr(node, "_children", None)
|
|
154
|
+
if kids is None:
|
|
155
|
+
out.append(node)
|
|
156
|
+
label = _label_of(node)
|
|
157
|
+
if label is not None and label is not node:
|
|
158
|
+
out.append(label) # a label paints right after its shape
|
|
159
|
+
return
|
|
160
|
+
out.append(node)
|
|
161
|
+
for child in sorted([c for c in kids if c.visible],
|
|
162
|
+
key=lambda c: (getattr(c, "z", 0) or 0)):
|
|
163
|
+
walk(child)
|
|
164
|
+
|
|
165
|
+
for child in sorted([c for c in getattr(root, "_children", []) if c.visible],
|
|
166
|
+
key=lambda c: (getattr(c, "z", 0) or 0)):
|
|
167
|
+
walk(child)
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_ancestor(a, b) -> bool:
|
|
172
|
+
node = getattr(b, "parent", None)
|
|
173
|
+
while node is not None:
|
|
174
|
+
if node is a:
|
|
175
|
+
return True
|
|
176
|
+
node = getattr(node, "parent", None)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _related(a, b) -> bool:
|
|
181
|
+
return a is b or _is_ancestor(a, b) or _is_ancestor(b, a)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _contains(outer: BBox, inner: BBox, tol: float = 0.5) -> bool:
|
|
185
|
+
return (outer.x0 - tol <= inner.x0 and outer.y0 - tol <= inner.y0
|
|
186
|
+
and outer.x1 + tol >= inner.x1 and outer.y1 + tol >= inner.y1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _label_of(el):
|
|
190
|
+
"""The text an element carries, if any."""
|
|
191
|
+
if isinstance(el, Text):
|
|
192
|
+
return el
|
|
193
|
+
if isinstance(el, Shape):
|
|
194
|
+
return el.label
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _carries_content(el) -> bool:
|
|
199
|
+
"""True when covering this element would hide something meaningful."""
|
|
200
|
+
from .image import Image
|
|
201
|
+
if isinstance(el, (Text, Image)):
|
|
202
|
+
return True
|
|
203
|
+
label = _label_of(el)
|
|
204
|
+
return label is not None and bool(str(label.text).strip())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_content(el) -> bool:
|
|
208
|
+
"""True when the element *is* the readable thing, not merely its container.
|
|
209
|
+
|
|
210
|
+
A labelled box counts as content-bearing for routing purposes, but for
|
|
211
|
+
overlap the label is audited as an item in its own right — so treating the
|
|
212
|
+
box as content too would flag anything grazing its edge, far from any
|
|
213
|
+
glyph.
|
|
214
|
+
"""
|
|
215
|
+
from .image import Image
|
|
216
|
+
return isinstance(el, (Text, Image))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _endpoint_elements(conn: Connector, candidates=()) -> set:
|
|
220
|
+
"""Elements a connector legitimately touches: its endpoints and kin.
|
|
221
|
+
|
|
222
|
+
Anchors and elements name their target directly. Raw coordinates do not,
|
|
223
|
+
so an endpoint that lands inside an element counts as connecting to it —
|
|
224
|
+
otherwise ``arrow((0, 9), (label.bbox.cx, label.bbox.cy))`` gets reported
|
|
225
|
+
as passing through the very thing it points at.
|
|
226
|
+
"""
|
|
227
|
+
out = set()
|
|
228
|
+
for ref in (conn.start_ref, conn.end_ref, *conn.waypoints):
|
|
229
|
+
el = getattr(ref, "element", None) or (ref if isinstance(ref, Element)
|
|
230
|
+
else None)
|
|
231
|
+
if el is None:
|
|
232
|
+
continue
|
|
233
|
+
for descendant in el.descendants(): # the endpoint and what it holds
|
|
234
|
+
out.add(id(descendant))
|
|
235
|
+
node = getattr(el, "parent", None) # …and the groups holding it, but
|
|
236
|
+
while node is not None: # NOT their other children
|
|
237
|
+
out.add(id(node))
|
|
238
|
+
node = getattr(node, "parent", None)
|
|
239
|
+
try:
|
|
240
|
+
_d, start, _sd, end, _ed = conn.geometry()
|
|
241
|
+
except Exception:
|
|
242
|
+
return out
|
|
243
|
+
for el in candidates:
|
|
244
|
+
bb = el.bbox
|
|
245
|
+
if bb.contains(start) or bb.contains(end):
|
|
246
|
+
out.add(id(el))
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _segment_hits_rect(p: Point, q: Point, bb: BBox) -> bool:
|
|
251
|
+
"""Liang-Barsky: does segment p->q cross the interior of ``bb``?"""
|
|
252
|
+
dx, dy = q.x - p.x, q.y - p.y
|
|
253
|
+
t0, t1 = 0.0, 1.0
|
|
254
|
+
for num, den in ((p.x - bb.x0, -dx), (bb.x1 - p.x, dx),
|
|
255
|
+
(p.y - bb.y0, -dy), (bb.y1 - p.y, dy)):
|
|
256
|
+
if den == 0:
|
|
257
|
+
if num < 0:
|
|
258
|
+
return False
|
|
259
|
+
continue
|
|
260
|
+
t = num / den
|
|
261
|
+
if den < 0:
|
|
262
|
+
if t > t1:
|
|
263
|
+
return False
|
|
264
|
+
t0 = max(t0, t)
|
|
265
|
+
else:
|
|
266
|
+
if t < t0:
|
|
267
|
+
return False
|
|
268
|
+
t1 = min(t1, t)
|
|
269
|
+
return t0 <= t1
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _polyline(el) -> list:
|
|
273
|
+
"""The drawn path of a line-like element, as points."""
|
|
274
|
+
if isinstance(el, Connector):
|
|
275
|
+
return el.polyline(20)
|
|
276
|
+
if isinstance(el, Polyline):
|
|
277
|
+
return list(el.points)
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _hits(el, bb: BBox) -> bool:
|
|
282
|
+
"""Does the element's actual ink reach into ``bb``?"""
|
|
283
|
+
if isinstance(el, _LINE_LIKE) or (isinstance(el, Polyline) and not el.closed):
|
|
284
|
+
pts = _polyline(el)
|
|
285
|
+
return any(_segment_hits_rect(pts[i], pts[i + 1], bb)
|
|
286
|
+
for i in range(len(pts) - 1))
|
|
287
|
+
return el.bbox.intersection(bb) is not None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _solid_fill(el):
|
|
291
|
+
"""The element's fill colour, if it actually paints a solid area."""
|
|
292
|
+
if isinstance(el, (Text, Connector)) or isinstance(el, Polyline) and not el.closed:
|
|
293
|
+
return None
|
|
294
|
+
fill = el.prop("fill", None)
|
|
295
|
+
if fill in (None, "none") or isinstance(fill, dict):
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
parsed = parse_color(fill)
|
|
299
|
+
except ValueError:
|
|
300
|
+
return None
|
|
301
|
+
if parsed is None or parsed[3] < 0.55:
|
|
302
|
+
return None
|
|
303
|
+
opacity = el.prop("fill_opacity", None)
|
|
304
|
+
if opacity is not None and float(opacity) < 0.55:
|
|
305
|
+
return None
|
|
306
|
+
return fill
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ==========================================================================
|
|
310
|
+
# The checks
|
|
311
|
+
# ==========================================================================
|
|
312
|
+
|
|
313
|
+
def audit(figure, *, overlap="content", overflow: bool = True,
|
|
314
|
+
contrast: bool = True, crossing: bool = True,
|
|
315
|
+
degenerate: bool = True, offscreen: bool = True,
|
|
316
|
+
min_overlap: float = 4.0, min_contrast: float = 3.0,
|
|
317
|
+
ignore=()) -> Report:
|
|
318
|
+
"""Check a figure for the mistakes you would otherwise spot by eye.
|
|
319
|
+
|
|
320
|
+
``overlap`` is ``"content"`` (default — only overlaps that can hide text or
|
|
321
|
+
an image), ``"all"`` (any partial overlap between drawn elements) or
|
|
322
|
+
``False``. ``ignore`` is a collection of elements to skip entirely.
|
|
323
|
+
"""
|
|
324
|
+
skip = {id(e) for e in ignore}
|
|
325
|
+
findings: list = []
|
|
326
|
+
|
|
327
|
+
elements = [e for e in figure.descendants()
|
|
328
|
+
if e is not figure and e.visible
|
|
329
|
+
and getattr(e, "audit_enabled", True) and id(e) not in skip]
|
|
330
|
+
leaves = [e for e in elements if not isinstance(e, Group)]
|
|
331
|
+
# A shape's label is not in the element tree, but covering it is exactly
|
|
332
|
+
# the bug we care most about — so audit it as an item in its own right.
|
|
333
|
+
for shape in list(leaves):
|
|
334
|
+
label = _label_of(shape)
|
|
335
|
+
if label is not None and label is not shape \
|
|
336
|
+
and str(label.text).strip() and getattr(shape, "audit_enabled", True):
|
|
337
|
+
leaves.append(label)
|
|
338
|
+
# Anything deliberately pushed behind is a backdrop; it is *supposed* to
|
|
339
|
+
# sit under other things.
|
|
340
|
+
foreground = [e for e in leaves if (getattr(e, "z", 0) or 0) >= 0]
|
|
341
|
+
|
|
342
|
+
order = {id(el): i for i, el in enumerate(paint_order(figure))}
|
|
343
|
+
if overlap:
|
|
344
|
+
findings += _check_overlaps(foreground, min_overlap,
|
|
345
|
+
content_only=(overlap != "all"),
|
|
346
|
+
order=order)
|
|
347
|
+
if crossing:
|
|
348
|
+
findings += _check_crossings(figure, foreground)
|
|
349
|
+
if overflow:
|
|
350
|
+
findings += _check_overflow(elements)
|
|
351
|
+
if contrast:
|
|
352
|
+
findings += _check_contrast(figure, min_contrast)
|
|
353
|
+
if degenerate:
|
|
354
|
+
findings += _check_degenerate(leaves)
|
|
355
|
+
if offscreen:
|
|
356
|
+
findings += _check_offscreen(figure, leaves)
|
|
357
|
+
|
|
358
|
+
return Report(_dedupe(findings), checked=len(elements))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _owner(el):
|
|
362
|
+
"""A shape's label reports as the shape, so one problem yields one finding."""
|
|
363
|
+
parent = getattr(el, "parent", None)
|
|
364
|
+
if parent is not None and _label_of(parent) is el:
|
|
365
|
+
return parent
|
|
366
|
+
return el
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _dedupe(findings) -> list:
|
|
370
|
+
"""Collapse findings that describe the same problem via a label and its shape."""
|
|
371
|
+
seen: dict = {}
|
|
372
|
+
for f in findings:
|
|
373
|
+
key = (f.kind, frozenset(id(_owner(e)) for e in f.elements))
|
|
374
|
+
current = seen.get(key)
|
|
375
|
+
if current is None or _SEVERITY_ORDER.get(f.severity, 3) < \
|
|
376
|
+
_SEVERITY_ORDER.get(current.severity, 3):
|
|
377
|
+
seen[key] = f
|
|
378
|
+
return list(seen.values())
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _obscures(el) -> bool:
|
|
382
|
+
"""Would painting this element on top actually hide what is underneath?"""
|
|
383
|
+
return _solid_fill(el) is not None or _carries_content(el)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _check_overlaps(items, min_overlap: float, content_only: bool,
|
|
387
|
+
order: dict = None) -> list:
|
|
388
|
+
order = order or {}
|
|
389
|
+
out = []
|
|
390
|
+
for i, a in enumerate(items):
|
|
391
|
+
if isinstance(a, Connector):
|
|
392
|
+
continue # handled by the crossing check
|
|
393
|
+
abox = a.bbox
|
|
394
|
+
for b in items[i + 1:]:
|
|
395
|
+
if isinstance(b, Connector) or _related(a, b):
|
|
396
|
+
continue
|
|
397
|
+
bbox = b.bbox
|
|
398
|
+
inter = abox.intersection(bbox)
|
|
399
|
+
if inter is None:
|
|
400
|
+
continue
|
|
401
|
+
area = inter.w * inter.h
|
|
402
|
+
if area < min_overlap:
|
|
403
|
+
continue # touching edges is not a collision
|
|
404
|
+
lower, upper = (a, b) if order.get(id(a), 0) <= order.get(id(b), 0) \
|
|
405
|
+
else (b, a)
|
|
406
|
+
if _contains(abox, bbox) or _contains(bbox, abox):
|
|
407
|
+
# Nesting is normal — but only when the container is the one
|
|
408
|
+
# underneath. A filled box painted *over* something it encloses
|
|
409
|
+
# hides it completely, which is the worst case, not an exempt one.
|
|
410
|
+
container = a if _contains(abox, bbox) else b
|
|
411
|
+
inner = b if container is a else a
|
|
412
|
+
if container is upper and _obscures(container) \
|
|
413
|
+
and _is_content(inner):
|
|
414
|
+
out.append(Finding(
|
|
415
|
+
"overlap",
|
|
416
|
+
f"{describe(container)} is painted over "
|
|
417
|
+
f"{describe(inner)}, hiding it completely",
|
|
418
|
+
severity="error", where=inter.center,
|
|
419
|
+
elements=(container, inner),
|
|
420
|
+
detail={"area": area, "hidden": 1.0}))
|
|
421
|
+
continue
|
|
422
|
+
hidden = (inter.w * inter.h) / max(1.0, lower.bbox.w * lower.bbox.h)
|
|
423
|
+
if content_only:
|
|
424
|
+
# Two ways an overlap is worth reporting:
|
|
425
|
+
# 1. something readable is being covered up, at any size, or
|
|
426
|
+
# 2. a large slice of an unrelated element is buried — which
|
|
427
|
+
# is what a mis-placed box looks like.
|
|
428
|
+
# Shapes drawn together under one parent are exempt from (2):
|
|
429
|
+
# a fan of wedges or a wireframe overlaps itself on purpose.
|
|
430
|
+
covers_content = _is_content(lower) and _obscures(upper)
|
|
431
|
+
buries = (hidden > 0.25 and _obscures(upper)
|
|
432
|
+
and a.parent is not b.parent)
|
|
433
|
+
if not (covers_content or buries):
|
|
434
|
+
continue
|
|
435
|
+
if not (_hits(a, inter) and _hits(b, inter)):
|
|
436
|
+
continue # bounding boxes met, the ink did not
|
|
437
|
+
out.append(Finding(
|
|
438
|
+
"overlap",
|
|
439
|
+
f"{describe(upper)} covers {describe(lower)} "
|
|
440
|
+
f"({area:.0f}px², {hidden:.0%} of it)",
|
|
441
|
+
severity="error" if hidden > 0.5 else "warning",
|
|
442
|
+
where=inter.center, elements=(upper, lower),
|
|
443
|
+
detail={"area": area, "hidden": hidden}))
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _check_crossings(figure, items) -> list:
|
|
448
|
+
"""Arrows running through elements they do not connect."""
|
|
449
|
+
out = []
|
|
450
|
+
connectors = [e for e in items if isinstance(e, Connector)]
|
|
451
|
+
targets = [e for e in items
|
|
452
|
+
if not isinstance(e, Connector) and _carries_content(e)]
|
|
453
|
+
owners = {id(e) for e in targets}
|
|
454
|
+
targets = [e for e in targets # a label and its shape are
|
|
455
|
+
if id(_owner(e)) not in owners or _owner(e) is e] # one target
|
|
456
|
+
for conn in connectors:
|
|
457
|
+
allowed = _endpoint_elements(conn, targets)
|
|
458
|
+
# A perfectly horizontal or vertical connector has a zero-area bbox,
|
|
459
|
+
# which would never "intersect" anything; pad it for the prefilter.
|
|
460
|
+
cbox = conn.bbox.expand(1.0)
|
|
461
|
+
pts = conn.polyline(24)
|
|
462
|
+
if len(pts) < 2:
|
|
463
|
+
continue
|
|
464
|
+
for el in targets:
|
|
465
|
+
if id(el) in allowed or _related(conn, el):
|
|
466
|
+
continue
|
|
467
|
+
bb = el.bbox
|
|
468
|
+
if cbox.intersection(bb) is None:
|
|
469
|
+
continue
|
|
470
|
+
inner = bb.shrink(1.0) # touching an edge is fine
|
|
471
|
+
if inner.w <= 0 or inner.h <= 0:
|
|
472
|
+
continue
|
|
473
|
+
if any(_segment_hits_rect(pts[i], pts[i + 1], inner)
|
|
474
|
+
for i in range(len(pts) - 1)):
|
|
475
|
+
out.append(Finding(
|
|
476
|
+
"crossing",
|
|
477
|
+
f"{describe(conn)} passes through {describe(el)}, "
|
|
478
|
+
f"which it does not connect",
|
|
479
|
+
severity="warning", where=bb.center, elements=(conn, el)))
|
|
480
|
+
return out
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _check_overflow(elements, tolerance: float = 1.0) -> list:
|
|
484
|
+
"""Labels that stick out of the shape they belong to.
|
|
485
|
+
|
|
486
|
+
Measured against the shape's own box rather than its padding box: padding
|
|
487
|
+
is a layout hint, and optical centring deliberately lets a line box sit a
|
|
488
|
+
hair outside it. Text escaping the *shape* is the actual defect.
|
|
489
|
+
"""
|
|
490
|
+
from .components import Panel
|
|
491
|
+
out = []
|
|
492
|
+
for el in elements:
|
|
493
|
+
if not isinstance(el, Shape) or isinstance(el, Panel):
|
|
494
|
+
continue # a Panel places its label outside on purpose
|
|
495
|
+
label = el.label
|
|
496
|
+
if label is None or not str(label.text).strip():
|
|
497
|
+
continue
|
|
498
|
+
box = el.bbox
|
|
499
|
+
lb = label.bbox
|
|
500
|
+
# vertically, judge by the cap band: descender space below the last
|
|
501
|
+
# baseline is empty, and counting it would flag every centred label
|
|
502
|
+
band = label.optical_bbox
|
|
503
|
+
dx = max(0.0, box.x0 - lb.x0) + max(0.0, lb.x1 - box.x1)
|
|
504
|
+
dy = max(0.0, box.y0 - band.y0) + max(0.0, band.y1 - box.y1)
|
|
505
|
+
if dx > tolerance or dy > tolerance:
|
|
506
|
+
bits = []
|
|
507
|
+
if dx > tolerance:
|
|
508
|
+
bits.append(f"{dx:.0f}px horizontally")
|
|
509
|
+
if dy > tolerance:
|
|
510
|
+
bits.append(f"{dy:.0f}px vertically")
|
|
511
|
+
out.append(Finding(
|
|
512
|
+
"overflow",
|
|
513
|
+
f"label of {describe(el)} sticks out of it by "
|
|
514
|
+
f"{' and '.join(bits)}",
|
|
515
|
+
severity="error", where=box.center, elements=(el,),
|
|
516
|
+
detail={"dx": dx, "dy": dy}))
|
|
517
|
+
return out
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _check_contrast(figure, min_contrast: float) -> list:
|
|
521
|
+
"""Text that will be hard or impossible to read on its backdrop."""
|
|
522
|
+
out = []
|
|
523
|
+
order = paint_order(figure)
|
|
524
|
+
index = {id(el): i for i, el in enumerate(order)}
|
|
525
|
+
painters = [(i, el) for i, el in enumerate(order) if _solid_fill(el)]
|
|
526
|
+
page = figure.background or figure.prop("background", "#ffffff") or "#ffffff"
|
|
527
|
+
|
|
528
|
+
for el in order:
|
|
529
|
+
label = _label_of(el)
|
|
530
|
+
if label is None or not str(label.text).strip():
|
|
531
|
+
continue
|
|
532
|
+
if not getattr(el, "audit_enabled", True):
|
|
533
|
+
continue
|
|
534
|
+
colour = label.prop("color")
|
|
535
|
+
if colour is None:
|
|
536
|
+
continue
|
|
537
|
+
lb = label.bbox
|
|
538
|
+
pos = index.get(id(el), 0)
|
|
539
|
+
backdrop = page
|
|
540
|
+
for i, other in painters:
|
|
541
|
+
if i > pos:
|
|
542
|
+
continue
|
|
543
|
+
# `other is el` is deliberately allowed: a box's label sits on that
|
|
544
|
+
# box's own fill, which is the commonest backdrop of all.
|
|
545
|
+
if _contains(other.bbox, lb, tol=-0.5):
|
|
546
|
+
backdrop = _solid_fill(other)
|
|
547
|
+
try:
|
|
548
|
+
ratio = _contrast_ratio(colour, backdrop)
|
|
549
|
+
except ValueError:
|
|
550
|
+
continue
|
|
551
|
+
if ratio < min_contrast - 1e-9:
|
|
552
|
+
out.append(Finding(
|
|
553
|
+
"contrast",
|
|
554
|
+
f"{describe(el)}: text {colour} on {backdrop} has contrast "
|
|
555
|
+
f"{_floor2(ratio)}:1 (want {min_contrast:.2f}:1)",
|
|
556
|
+
severity="error" if ratio < 1.6 else "warning",
|
|
557
|
+
where=lb.center, elements=(el,),
|
|
558
|
+
detail={"ratio": ratio, "color": colour, "background": backdrop}))
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _floor2(value: float) -> str:
|
|
563
|
+
"""Round *down* to 2dp, so a failing ratio never prints as the threshold."""
|
|
564
|
+
return f"{math.floor(value * 100) / 100:.2f}"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _contrast_ratio(fg, bg) -> float:
|
|
568
|
+
lf, lb = luminance(fg), luminance(bg)
|
|
569
|
+
hi, lo = max(lf, lb), min(lf, lb)
|
|
570
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _check_degenerate(leaves) -> list:
|
|
574
|
+
"""Zero-size shapes and zero-length arrows: almost always a mistake."""
|
|
575
|
+
out = []
|
|
576
|
+
for el in leaves:
|
|
577
|
+
if isinstance(el, Connector):
|
|
578
|
+
if el.length < 1.0:
|
|
579
|
+
out.append(Finding(
|
|
580
|
+
"degenerate", f"{describe(el)} is {el.length:.2f}px long",
|
|
581
|
+
severity="error", where=el.bbox.center, elements=(el,)))
|
|
582
|
+
continue
|
|
583
|
+
if isinstance(el, (Line, Polyline)):
|
|
584
|
+
continue # a horizontal line has zero height
|
|
585
|
+
bb = el.bbox
|
|
586
|
+
if bb.w <= 0 or bb.h <= 0:
|
|
587
|
+
out.append(Finding(
|
|
588
|
+
"degenerate", f"{describe(el)} has zero size "
|
|
589
|
+
f"({bb.w:.0f}x{bb.h:.0f})",
|
|
590
|
+
severity="error", where=bb.center, elements=(el,)))
|
|
591
|
+
return out
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _check_offscreen(figure, leaves) -> list:
|
|
595
|
+
"""Content outside a pinned viewbox. Auto-sized figures cannot have any."""
|
|
596
|
+
if figure._viewbox is None and figure._fixed_w is None \
|
|
597
|
+
and figure._fixed_h is None:
|
|
598
|
+
return []
|
|
599
|
+
view = figure.viewbox()
|
|
600
|
+
out = []
|
|
601
|
+
for el in leaves:
|
|
602
|
+
bb = el.ink_bbox
|
|
603
|
+
if view.intersection(bb) is None:
|
|
604
|
+
out.append(Finding(
|
|
605
|
+
"offscreen", f"{describe(el)} lies entirely outside the canvas",
|
|
606
|
+
severity="error", where=bb.center, elements=(el,)))
|
|
607
|
+
elif not _contains(view, bb):
|
|
608
|
+
out.append(Finding(
|
|
609
|
+
"offscreen", f"{describe(el)} is clipped by the canvas edge",
|
|
610
|
+
severity="warning", where=bb.center, elements=(el,)))
|
|
611
|
+
return out
|