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/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