codefig 0.17.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.
codefig/lint.py ADDED
@@ -0,0 +1,1737 @@
1
+ """Publication-style lint: check a laid-out Figure against the codefig figure rules.
2
+
3
+ The rules (``RULES``) were derived from measuring ~100 figures of well-regarded
4
+ systems / ML papers plus the NeurIPS, ICLR, ICML, MLSys, ACM and USENIX templates
5
+ (see ``docs/lint-rules.md``). Use::
6
+
7
+ report = fig.lint(venue="icml", width="column")
8
+ print(report) # human table
9
+ report.ok # no errors?
10
+ report.to_json() # for agents / CI
11
+ report.raise_for_errors()
12
+
13
+ Everything is measured at **final print size**: a physical (pt) figure is checked as
14
+ is; a legacy px figure is checked as if scaled to the venue width; ``scale=`` is the
15
+ LaTeX ``\\includegraphics[scale=…]`` factor on top.
16
+
17
+ Suppress per element with ``Box(..., lint_ignore=["CF-TXT-006"])`` (applies to the
18
+ node and everything drawn inside it), per call with ``ignore=[...]``, or globally
19
+ with ``codefig.lint_config.disable = [...]``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import math
26
+ import re
27
+ import xml.etree.ElementTree as ET
28
+ from dataclasses import asdict, dataclass, field
29
+ from typing import Union, Any, Dict, Iterable, List, Optional, Sequence, Tuple
30
+
31
+ SEVERITIES = ("error", "warn", "info")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Rule:
36
+ id: str
37
+ title: str
38
+ severity: str # strongest severity the rule can emit
39
+ threshold: str
40
+ fix: str
41
+ implemented: bool = True
42
+
43
+
44
+ def _r(id, title, sev, thr, fix, impl=True):
45
+ return Rule(id, title, sev, thr, fix, impl)
46
+
47
+
48
+ RULES: Dict[str, Rule] = {r.id: r for r in [
49
+ _r("CF-TXT-001", "Minimum font size at final width", "error", "warn < 6 pt, error < 5 pt",
50
+ "use the theme size roles (6/7/8 pt); widen the figure (width='text') or shorten labels"),
51
+ _r("CF-TXT-002", "At most three font sizes", "warn", "> 3 sizes (0.5 pt bins) or max/median > 1.6",
52
+ "drop free font_size= values; let sizes snap to the theme roles"),
53
+ _r("CF-TXT-003", "Figure text no larger than body text", "info", "> body + 1 pt",
54
+ "use the 7 pt label role; body text is 9–10 pt"),
55
+ _r("CF-TXT-004", "One text family", "warn", "> 1 family (mono for code allowed)",
56
+ "remove family=/typeface overrides; use mono=True only for code"),
57
+ _r("CF-TXT-005", "No text overlap / text crossed by edges", "error",
58
+ "any text–text, text–edge, label–box overlap",
59
+ "increase gap=, move the label (label_side=, label_at=) or allow_overlap() if intended"),
60
+ _r("CF-TXT-006", "Short labels", "warn", "> 4 words/line, > 2 lines, > 24 chars/line in a "
61
+ "Box / shape label (Text, edge labels, titles and notes are not checked)",
62
+ "shorten the label; move detail into the LaTeX caption"),
63
+ _r("CF-TXT-007", "Text padding inside boxes", "error",
64
+ "overflow = error; pad < 0.4 em (h) or < 0.2 em (v) = warn",
65
+ "drop fixed w=/h= or enlarge the box; keep the theme box padding"),
66
+ _r("CF-TXT-008", "Emphasis discipline", "info", "> 2 text colours; bold outside titles",
67
+ "black text plus one accent colour; bold only for titles and panel tags"),
68
+ _r("CF-TXT-010", "Drawn text is not covered, struck through or overlapped", "error",
69
+ "opaque fill painted over ≥ 12 % of a text run, a stroke through its middle band, "
70
+ "or two text runs overlapping = error; a stroke grazing the glyph box = warn",
71
+ "give the label room (gap=, pad=, label_side=), move the covering box, or route the "
72
+ "line around it; allow with lint_ignore=['CF-TXT-010'] on the owner if intended"),
73
+ _r("CF-ANN-001", "Guides avoid captions", "warn", "guide segment touches a panel or group caption",
74
+ "move the caption or guide endpoint; use Guide(avoid_text=True) for automatic detours"),
75
+ _r("CF-TXT-011", "No silently dropped or truncated labels", "warn",
76
+ "a label the engine shortened (…) or did not draw because it had no room",
77
+ "widen the element / figure, shorten the text, or pass the documented fallback "
78
+ "(e.g. value_pos='outside')"),
79
+ _r("CF-COL-001", "Hue budget", "warn", "5 hues = info, > 5 = warn",
80
+ "merge categories; use category= (the 6 palette fills) plus neutral grey"),
81
+ _r("CF-COL-002", "Colour-vision-deficiency safety", "error",
82
+ "deutan/protan ΔE < 10 warn, < 5 error (Machado 2009, CIE76)",
83
+ "use category= (theme palette, 6 CVD-safe fills) instead of hand-picked fills, with "
84
+ "fig.color(name) for chart series / edges keyed to the same thing and one "
85
+ "Legend(source='figure') as the key"),
86
+ _r("CF-COL-003", "Grey-scale distinguishable", "warn", "ΔL* < 8 without hatch/dash cue",
87
+ "use the paper palette (lightness-staggered) or add pattern=Hatch()"),
88
+ _r("CF-COL-004", "Meaningful colour needs a key", "warn",
89
+ "≥ 2 categories without a Legend (or direct labels)",
90
+ "add Legend() to the panel, or label categories directly"),
91
+ _r("CF-COL-005", "Pastel fills, dark text", "info", "text/background ΔL* < 50",
92
+ "lighter fill (L* ≥ 62) behind black text, or white text on dark fills"),
93
+ _r("CF-COL-006", "No red–green-only contrast", "warn", "normal ΔE > 30 but deutan ΔE < 10",
94
+ "replace the red/green pair with vermilion/blue (Okabe-Ito)"),
95
+ _r("CF-STR-001", "Minimum stroke width", "error", "warn < 0.5 pt, error < 0.25 pt",
96
+ "use theme stroke roles (0.6 / 0.75 / 1.2 pt); avoid shrinking the figure"),
97
+ _r("CF-STR-002", "At most 3 stroke widths, 2 dash patterns", "warn",
98
+ "> 3 widths (0.1 pt bins) or > 2 dash patterns", "drop free stroke_width=/dash values"),
99
+ _r("CF-STR-003", "Dashes mean something", "info", "dashed edge without legend",
100
+ "give the dashed edge legend='control' (and add a Legend) or make it solid"),
101
+ _r("CF-ARR-001", "Arrow consistency", "warn", "> 2 arrowhead styles or > 1 head size",
102
+ "use the theme head (stealth 4×3 pt) everywhere"),
103
+ _r("CF-ARR-002", "Edge routing", "warn", "edge passes through a box; crossings (info)",
104
+ "re-order nodes, use ports (a.right → b.left) or route='ortho'"),
105
+ _r("CF-ARR-003", "Edges attach to visible elements", "warn",
106
+ "endpoint on an invisible Row / Col / Stack / Grid holding ≥ 2 boxes: warn on blank "
107
+ "space, info when it touches a child (name that child's port)",
108
+ "use the port of the box the reader sees (e.g. top_prob.port('right'), not "
109
+ "Col([top_prob, rest]).port('right'))"),
110
+ _r("CF-ARR-004", "No small interior edge jogs", "warn", "interior segment < 4 pt",
111
+ "use jog_tol or align the endpoint nodes"),
112
+ _r("CF-ARR-005", "Return edges stay near the diagram", "warn", "return edge expands bounds > 24 pt",
113
+ "use loop_side or leave a routing channel"),
114
+ _r("CF-REG-001", "Regions contain only their members", "warn",
115
+ "a non-member box has > 15 % of its area inside a Region outline",
116
+ "keep Region(exclude='auto') (push / carve), add the box to members=, or move it"),
117
+ _r("CF-LAY-001", "Alignment grid", "warn", "aligned fraction < 0.8 info, < 0.5 warn (1 pt)",
118
+ "build with Row/Col/Grid; same_width()/align() constraints"),
119
+ _r("CF-LAY-002", "Consistent gaps", "warn", "sibling-gap CV > 0.25",
120
+ "use one gap= per Row/Col (theme default)"),
121
+ _r("CF-LAY-003", "One corner radius", "warn", "> 1 radius among boxes",
122
+ "drop per-box radius=; use the theme radius"),
123
+ _r("CF-LAY-004", "Aspect ratio for the slot", "info", "text width 2–4.5, column 1.1–2.5",
124
+ "re-arrange panels (cols=, grid=) for the slot"),
125
+ _r("CF-LAY-005", "No unintended box overlap", "error", "partial overlap of boxes",
126
+ "increase gap= or mark allow_overlap()"),
127
+ _r("CF-LAY-009", "Legend obscures content", "warn", "corner legend overlaps content",
128
+ "try a clear corner or external placement"),
129
+ _r("CF-LAY-008", "Requested aspect ratio", "warn", "target aspect differs by more than 8%",
130
+ "Remove a row, use fit='scale', or relax aspect."),
131
+ _r("CF-LAY-007", "Panels use their cell", "warn",
132
+ "content covers < 55 % of the panel's inner area = info, < 40 % = warn",
133
+ "rebalance Figure(grid=...) so short panels share a row, or lay the content out "
134
+ "horizontally (Row) to match the cell"),
135
+ _r("CF-EXP-001", "Designed at venue width, scale 1", "warn",
136
+ "width ≠ venue ±2 %, or shrink pushes fonts below the minimum",
137
+ "Figure(venue=..., width='column'|'text') and \\includegraphics at scale 1; see docs/cookbook/30_compact_column.py"),
138
+ _r("CF-EXP-002", "Vector output, embedded fonts", "error", "Type 3 / unembedded fonts",
139
+ "save as .pdf or .svg (cairosvg embeds TrueType subsets)"),
140
+ _r("CF-EXP-003", "Raster resolution", "warn", "embedded image < 300 dpi at final size",
141
+ "use a higher-resolution or vector image"),
142
+ _r("CF-EXP-004", "No in-figure title", "info", "Figure(title=...) set",
143
+ "put the title in the LaTeX caption"),
144
+ _r("CF-EXP-005", "Alt text", "info", "no alt text", "fig.save(..., alt='...')"),
145
+ ]}
146
+
147
+
148
+ class LintError(Exception):
149
+ """Raised by :meth:`LintReport.raise_for_errors` / ``save(lint="error")``.
150
+
151
+ The message lists every error with its elements and fix hint; its **last line** is a
152
+ one-line summary (each error: rule, element, fix) plus the preview path and how to
153
+ keep iterating without raising (``fig.preview()`` or ``fig.save(path, lint="warn")``).
154
+ Attributes: ``report`` (the :class:`LintReport`), ``preview`` (path or ``None``).
155
+ """
156
+
157
+ def __init__(self, report: "Union[LintReport, str]", *, preview: Any = None,
158
+ target: Any = None) -> None:
159
+ self.preview = preview
160
+ if isinstance(report, str):
161
+ self.report = None
162
+ super().__init__(report)
163
+ return
164
+ self.report = report
165
+ super().__init__(self._message(report, preview, target))
166
+
167
+ @staticmethod
168
+ def _short_el(f: "Finding") -> str:
169
+ if not f.elements:
170
+ return ""
171
+ el = ", ".join(f.elements[:2]) + (" …" if len(f.elements) > 2 else "")
172
+ return f" [{el}]"
173
+
174
+ @classmethod
175
+ def _message(cls, report: "LintReport", preview: Any, target: Any) -> str:
176
+ errs = [f for f in report.findings if f.severity == "error"]
177
+ warns = [f for f in report.findings if f.severity == "warn"]
178
+ name = getattr(target, "name", None) or (str(target) if target else None)
179
+ head = (f"{len(errs)} lint error(s), {len(warns)} warning(s)"
180
+ + (f": nothing was written to {name}" if name else "") + ":")
181
+ rows = [head]
182
+ for f in errs + warns:
183
+ rows.append(" " + f.line())
184
+ if f.fix:
185
+ rows.append(" fix: " + f.fix)
186
+ if preview is not None:
187
+ rows.append(f" preview: {preview} (errors outlined in red, warnings in orange)")
188
+ summ = "; ".join(f"{f.rule_id}{cls._short_el(f)}: {f.message[:90]}"
189
+ + (f" -> fix: {f.fix[:110]}" if f.fix else "") for f in errs)
190
+ tail = f"LINT REFUSED SAVE ({len(errs)} error(s)): {summ}"
191
+ if preview is not None:
192
+ tail += f" | preview: {preview}"
193
+ tail += (" | keep iterating without raising: fig.preview() or "
194
+ "fig.save(path, lint=\"warn\")")
195
+ rows.append(tail)
196
+ return "\n".join(rows)
197
+
198
+
199
+ class LintWarning(UserWarning):
200
+ """Emitted per finding by ``save(lint="warn")``."""
201
+
202
+
203
+ class CaptionWarning(UserWarning):
204
+ """v0.14.0: a panel title that will not be drawn (``caption="none"``)."""
205
+
206
+
207
+ @dataclass
208
+ class LintConfig:
209
+ """Global settings: ``codefig.lint_config.disable = ["CF-EXP-005"]``;
210
+ ``thresholds = {"CF-TXT-001": {"warn": 6.5}}``."""
211
+
212
+ disable: List[str] = field(default_factory=list)
213
+ thresholds: Dict[str, Dict[str, float]] = field(default_factory=dict)
214
+
215
+
216
+ lint_config = LintConfig()
217
+
218
+
219
+ @dataclass
220
+ class Finding:
221
+ rule_id: str
222
+ severity: str
223
+ message: str
224
+ elements: List[str] = field(default_factory=list)
225
+ measured: Optional[float] = None
226
+ threshold: Optional[float] = None
227
+ fix: str = ""
228
+ boxes: List[List[float]] = field(default_factory=list) # v0.12: SVG-root bboxes
229
+
230
+ def line(self) -> str:
231
+ el = f" [{', '.join(self.elements[:3])}{' …' if len(self.elements) > 3 else ''}]" if self.elements else ""
232
+ return f"{self.severity.upper():5s} {self.rule_id} {self.message}{el}"
233
+
234
+
235
+ @dataclass
236
+ class LintReport:
237
+ findings: List[Finding]
238
+ venue: Optional[str] = None
239
+ width_pt: Optional[float] = None
240
+ factor: float = 1.0 # authoring unit → printed pt
241
+ checked: List[str] = field(default_factory=list)
242
+ image: Optional[str] = None # set by Figure.preview(): the figure written
243
+ preview: Optional[str] = None # set by Figure.preview(): the outlined lint preview
244
+
245
+ @property
246
+ def ok(self) -> bool:
247
+ """True when there are no ``error`` findings."""
248
+ return not any(f.severity == "error" for f in self.findings)
249
+
250
+ def by_severity(self, sev: str) -> List[Finding]:
251
+ return [f for f in self.findings if f.severity == sev]
252
+
253
+ @property
254
+ def errors(self) -> List[Finding]:
255
+ return self.by_severity("error")
256
+
257
+ @property
258
+ def warnings(self) -> List[Finding]:
259
+ return self.by_severity("warn")
260
+
261
+ def to_dict(self) -> dict:
262
+ return {"ok": self.ok, "venue": self.venue, "width_pt": self.width_pt,
263
+ "factor": self.factor, "checked": self.checked,
264
+ "findings": [asdict(f) for f in self.findings]}
265
+
266
+ def to_json(self, **kw: Any) -> str:
267
+ """JSON for agents / CI: ``{"ok", "venue", "width_pt", "findings": [...]}``."""
268
+ kw.setdefault("indent", 1)
269
+ return json.dumps(self.to_dict(), ensure_ascii=False, **kw)
270
+
271
+ def raise_for_errors(self) -> "LintReport":
272
+ if not self.ok:
273
+ raise LintError(self)
274
+ return self
275
+
276
+ def __str__(self) -> str:
277
+ n = {s: len(self.by_severity(s)) for s in SEVERITIES}
278
+ head = (f"codefig lint ({self.venue or 'no venue'}"
279
+ f"{f', {self.width_pt:.0f} pt' if self.width_pt else ''}): "
280
+ f"{n['error']} error(s), {n['warn']} warning(s), {n['info']} info")
281
+ rows = [head] + [" " + f.line() + (f"\n fix: {f.fix}" if f.fix and f.severity != "info" else "")
282
+ for f in self.findings]
283
+ if self.image:
284
+ rows.append(f" wrote {self.image}"
285
+ + (f"; findings outlined in {self.preview}" if self.preview else ""))
286
+ return "\n".join(rows)
287
+
288
+ __repr__ = __str__
289
+
290
+
291
+ # ---- colour science (pure python) --------------------------------------------------
292
+ _DEUT = ((0.367322, 0.860646, -0.227968), (0.280085, 0.672501, 0.047413), (-0.011820, 0.042940, 0.968881))
293
+ _PROT = ((0.152286, 1.052583, -0.204868), (0.114503, 0.786281, 0.099216), (-0.003882, -0.048116, 1.051998))
294
+ _NAMED = {"white": "#ffffff", "black": "#000000", "red": "#ff0000", "green": "#008000",
295
+ "blue": "#0000ff", "gray": "#808080", "grey": "#808080", "yellow": "#ffff00",
296
+ "orange": "#ffa500", "purple": "#800080", "none": None, "transparent": None}
297
+
298
+
299
+ def parse_color(c: Any) -> Optional[Tuple[float, float, float]]:
300
+ """``#rgb`` / ``#rrggbb`` / ``rgb(r,g,b)`` / a few names → sRGB 0..1 (None if not a colour)."""
301
+ if c is None or not isinstance(c, str):
302
+ return None
303
+ c = c.strip().lower()
304
+ if c in _NAMED:
305
+ c = _NAMED[c]
306
+ if c is None:
307
+ return None
308
+ if c.startswith("url("):
309
+ return None
310
+ m = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", c)
311
+ if m:
312
+ h = m.group(1)
313
+ if len(h) == 3:
314
+ h = "".join(ch * 2 for ch in h)
315
+ return tuple(int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
316
+ m = re.fullmatch(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", c)
317
+ if m:
318
+ return tuple(int(m.group(i)) / 255.0 for i in (1, 2, 3)) # type: ignore[return-value]
319
+ return None
320
+
321
+
322
+ def _lin(v: float) -> float:
323
+ return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
324
+
325
+
326
+ def _lab_from_lin(r: float, g: float, b: float) -> Tuple[float, float, float]:
327
+ x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
328
+ y = 0.2126 * r + 0.7152 * g + 0.0722 * b
329
+ z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
330
+
331
+ def f(t: float) -> float:
332
+ return t ** (1 / 3) if t > 216 / 24389 else (24389 / 27 * t + 16) / 116
333
+
334
+ fx, fy, fz = f(x), f(y), f(z)
335
+ return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
336
+
337
+
338
+ def lab(rgb: Tuple[float, float, float]) -> Tuple[float, float, float]:
339
+ """sRGB (0..1) → CIELAB (D65)."""
340
+ return _lab_from_lin(*(_lin(v) for v in rgb))
341
+
342
+
343
+ def cvd_lab(rgb: Tuple[float, float, float], kind: str = "deutan") -> Tuple[float, float, float]:
344
+ """CIELAB of ``rgb`` as seen with deuteranopia / protanopia (Machado 2009, severity 1)."""
345
+ M = _DEUT if kind == "deutan" else _PROT
346
+ lr = [_lin(v) for v in rgb]
347
+ sim = [min(1.0, max(0.0, sum(M[i][j] * lr[j] for j in range(3)))) for i in range(3)]
348
+ return _lab_from_lin(*sim)
349
+
350
+
351
+ def delta_e(a: Sequence[float], b: Sequence[float]) -> float:
352
+ return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
353
+
354
+
355
+ def _hex(rgb: Tuple[float, float, float]) -> str:
356
+ return "#%02X%02X%02X" % tuple(round(v * 255) for v in rgb)
357
+
358
+
359
+ # ---- SVG scan ----------------------------------------------------------------------
360
+ _MARK = "cf-node:"
361
+
362
+
363
+ def _affine(tr: str) -> Tuple[float, float, float, float, float, float]:
364
+ """Parse an SVG transform list → affine (a, b, c, d, e, f)."""
365
+ m = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
366
+
367
+ def mul(p, q):
368
+ a1, b1, c1, d1, e1, f1 = p
369
+ a2, b2, c2, d2, e2, f2 = q
370
+ return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2,
371
+ a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1)
372
+
373
+ for name, args in re.findall(r"(\w+)\s*\(([^)]*)\)", tr or ""):
374
+ v = [float(x) for x in re.split(r"[\s,]+", args.strip()) if x]
375
+ if name == "translate":
376
+ q = (1, 0, 0, 1, v[0], v[1] if len(v) > 1 else 0.0)
377
+ elif name == "scale":
378
+ q = (v[0], 0, 0, v[1] if len(v) > 1 else v[0], 0, 0)
379
+ elif name == "rotate":
380
+ a = math.radians(v[0])
381
+ ca, sa = math.cos(a), math.sin(a)
382
+ if len(v) == 3:
383
+ cx, cy = v[1], v[2]
384
+ q = (ca, sa, -sa, ca, cx - ca * cx + sa * cy, cy - sa * cx - ca * cy)
385
+ else:
386
+ q = (ca, sa, -sa, ca, 0, 0)
387
+ elif name == "matrix" and len(v) == 6:
388
+ q = tuple(v) # type: ignore[assignment]
389
+ else:
390
+ continue
391
+ m = mul(m, q) # type: ignore[arg-type]
392
+ return m
393
+
394
+
395
+ def _scale_of(m) -> float:
396
+ return math.sqrt(abs(m[0] * m[3] - m[1] * m[2]))
397
+
398
+
399
+ @dataclass
400
+ class _Prim:
401
+ tag: str
402
+ owner: Any # innermost codefig node (or None)
403
+ chain: Tuple[Any, ...]
404
+ scale: float
405
+ attrs: Dict[str, str]
406
+ text: str = ""
407
+ font_size: float = 0.0
408
+ family: str = ""
409
+ weight: str = ""
410
+ in_defs: bool = False
411
+ script: bool = False # sub/superscript run (allowed down to theme.script_min)
412
+ m: Tuple[float, ...] = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) # v0.12: full affine to root
413
+ text_id: int = -1 # v0.12: enclosing <text> element (runs of one line share it)
414
+ clip: str = "" # v0.12: enclosing clip-path id (knockout holes)
415
+
416
+
417
+ def _style_attrs(el: ET.Element) -> Dict[str, str]:
418
+ d = dict(el.attrib)
419
+ st = d.pop("style", "")
420
+ for part in st.split(";"):
421
+ if ":" in part:
422
+ k, v = part.split(":", 1)
423
+ d[k.strip()] = v.strip()
424
+ return d
425
+
426
+
427
+ def _scan_svg(svg: str, id_map: Dict[str, Any]) -> List[_Prim]:
428
+ parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
429
+ root = ET.fromstring(svg, parser=parser)
430
+ prims: List[_Prim] = []
431
+ stack: List[Any] = []
432
+ inherit_keys = ("data-cf-icon", "data-cf-glyph", "font-size", "font-family", "font-weight", "fill", "stroke", "stroke-width",
433
+ "stroke-dasharray", "y", "text-anchor", "font-style", "opacity", "fill-opacity")
434
+ text_seq = [0]
435
+ pen_y = [0.0]
436
+
437
+ def _num(v: Any) -> float:
438
+ try:
439
+ return float(re.sub(r"[a-z%]+$", "", str(v or "0")) or 0)
440
+ except ValueError:
441
+ return 0.0
442
+
443
+ cur_text = [-1]
444
+ text_fs = [0.0] # font size of the enclosing <text> (scripts nest fallback-font tspans)
445
+ cur_clip = [""]
446
+
447
+ def local(tag: Any) -> str:
448
+ return tag.split("}", 1)[-1] if isinstance(tag, str) else ""
449
+
450
+ def visit(el: ET.Element, m, inh: Dict[str, str], in_defs: bool) -> None:
451
+ if el.tag is ET.Comment:
452
+ txt = (el.text or "").strip()
453
+ if txt.startswith(_MARK + "push "):
454
+ stack.append(id_map.get(txt.split()[-1]))
455
+ elif txt.startswith(_MARK + "pop") and stack:
456
+ stack.pop()
457
+ return
458
+ tag = local(el.tag)
459
+ a = _style_attrs(el)
460
+ if "transform" in a:
461
+ m2 = _affine(a["transform"])
462
+ a1, b1, c1, d1, e1, f1 = m
463
+ a2, b2, c2, d2, e2, f2 = m2
464
+ m = (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2,
465
+ a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1)
466
+ merged = dict(inh)
467
+ for k in inherit_keys:
468
+ if k in a:
469
+ merged[k] = a[k]
470
+ if tag == "text":
471
+ text_seq[0] += 1
472
+ cur_text[0] = text_seq[0]
473
+ pen_y[0] = _num(a.get("y"))
474
+ try:
475
+ text_fs[0] = float(re.sub(r"[a-z]+$", "", merged.get("font-size", "0")) or 0)
476
+ except ValueError:
477
+ text_fs[0] = 0.0
478
+ if tag in ("text", "tspan"):
479
+ # v0.13.0: track the SVG pen (absolute y, relative dy) so script runs
480
+ # positioned with dy are measured where they are drawn
481
+ if tag == "tspan" and "y" in a:
482
+ pen_y[0] = _num(a.get("y"))
483
+ if "dy" in a:
484
+ pen_y[0] += _num(a.get("dy"))
485
+ merged["y"] = f"{pen_y[0]:.3f}"
486
+ prev_clip = cur_clip[0]
487
+ if "clip-path" in a:
488
+ mm = re.search(r"#([^)]+)", a["clip-path"])
489
+ if mm:
490
+ cur_clip[0] = mm.group(1)
491
+ defs = in_defs or tag in ("defs", "clipPath", "marker", "pattern", "mask")
492
+ if tag in ("text", "tspan"):
493
+ own = "".join(t for t in [el.text] if t) .strip()
494
+ if tag == "text" and not own:
495
+ own = ""
496
+ if own:
497
+ try:
498
+ fs = float(re.sub(r"[a-z]+$", "", merged.get("font-size", "0")) or 0)
499
+ except ValueError:
500
+ fs = 0.0
501
+ try:
502
+ pfs = float(re.sub(r"[a-z]+$", "", inh.get("font-size", "0")) or 0)
503
+ except ValueError:
504
+ pfs = 0.0
505
+ script = tag == "tspan" and 0 < fs and (fs < pfs - 1e-6 or fs < text_fs[0] - 1e-6)
506
+ prims.append(_Prim("text", stack[-1] if stack else None, tuple(stack), _scale_of(m),
507
+ {**a, **{k: merged[k] for k in merged if k not in ("x",)},
508
+ **({"x": a["x"]} if "x" in a else {})}, own, fs,
509
+ merged.get("font-family", ""), merged.get("font-weight", ""),
510
+ defs, script, m, cur_text[0], cur_clip[0]))
511
+ elif tag in ("rect", "line", "path", "polyline", "polygon", "circle", "ellipse", "image"):
512
+ prims.append(_Prim(tag, stack[-1] if stack else None, tuple(stack), _scale_of(m),
513
+ {**{k: merged[k] for k in merged}, **a}, in_defs=defs, m=m,
514
+ clip=cur_clip[0]))
515
+ for ch in list(el):
516
+ visit(ch, m, merged, defs)
517
+ # tspan tails belong to the parent text run
518
+ if local(ch.tag) == "tspan" and ch.tail and ch.tail.strip() and tag == "text":
519
+ try:
520
+ fs = float(re.sub(r"[a-z]+$", "", merged.get("font-size", "0")) or 0)
521
+ except ValueError:
522
+ fs = 0.0
523
+ prims.append(_Prim("text", stack[-1] if stack else None, tuple(stack), _scale_of(m),
524
+ {**merged, "y": f"{pen_y[0]:.3f}"}, ch.tail.strip(), fs, merged.get("font-family", ""),
525
+ merged.get("font-weight", ""), defs, False, m, cur_text[0],
526
+ cur_clip[0]))
527
+ cur_clip[0] = prev_clip
528
+
529
+ visit(root, (1.0, 0.0, 0.0, 1.0, 0.0, 0.0), {}, False)
530
+ return prims
531
+
532
+
533
+ def _instrumented_svg(fig: Any) -> Tuple[str, Dict[str, Any]]:
534
+ """Render with ``<!--cf-node:push N-->`` markers around every node / edge draw."""
535
+ from .constraints import walk
536
+ from .edges import Diagram
537
+
538
+ id_map: Dict[str, Any] = {}
539
+ patched: List[Any] = []
540
+
541
+ def wrap(obj: Any) -> None:
542
+ if "draw" in obj.__dict__ or not hasattr(obj, "draw"):
543
+ return
544
+ key = str(len(id_map))
545
+ id_map[key] = obj
546
+ orig = obj.draw
547
+
548
+ def draw(buf, *a, **kw):
549
+ buf.append(f"<!--{_MARK}push {key}-->")
550
+ try:
551
+ return orig(buf, *a, **kw)
552
+ finally:
553
+ buf.append(f"<!--{_MARK}pop-->")
554
+
555
+ obj.__dict__["draw"] = draw
556
+ patched.append(obj)
557
+
558
+ fig.layout()
559
+ for p in fig._panels.values():
560
+ trees = [p.content] if p.content is not None else []
561
+ trees += list(p._overlays)
562
+ for t in trees:
563
+ for n in walk(t):
564
+ wrap(n)
565
+ if isinstance(n, Diagram):
566
+ for e in n.edges:
567
+ wrap(e)
568
+ from . import notices
569
+
570
+ notices.reset()
571
+ try:
572
+ svg = fig.to_svg()
573
+ finally:
574
+ for o in patched:
575
+ o.__dict__.pop("draw", None)
576
+ return svg, id_map
577
+
578
+
579
+ # ---- helpers -----------------------------------------------------------------------
580
+ def _name(n: Any) -> str:
581
+ from .overlaps import edge_name
582
+ from .constraints import node_name
583
+
584
+ if n is None:
585
+ return "figure"
586
+ if hasattr(n, "src") and hasattr(n, "dst"):
587
+ return edge_name(n)
588
+ return node_name(n)
589
+
590
+
591
+ def _ignored(chain: Iterable[Any], rule: str) -> bool:
592
+ for n in chain:
593
+ ig = getattr(n, "lint_ignore", None) or ()
594
+ if isinstance(ig, str):
595
+ ig = (ig,)
596
+ # exact id, a prefix ("CF-TXT" silences every text rule) or "all" / "*"
597
+ if any(x in ("all", "*") or rule == x or rule.startswith(str(x).rstrip("-") + "-") for x in ig):
598
+ return True
599
+ return False
600
+
601
+
602
+ def _ancestors(n: Any) -> List[Any]:
603
+ out = []
604
+ while n is not None:
605
+ out.append(n)
606
+ n = getattr(n, "parent", None)
607
+ return out
608
+
609
+
610
+ def _bins(vals: Iterable[float], step: float) -> List[float]:
611
+ return sorted({round(v / step) * step for v in vals})
612
+
613
+
614
+ # ---- the linter --------------------------------------------------------------------
615
+ class _Ctx:
616
+ def __init__(self, fig, venue, width, scale, ignore, thresholds, pdf, alt):
617
+ from .venues import get_venue
618
+
619
+ self.fig = fig
620
+ self.venue = get_venue(venue) if venue is not None else fig.venue
621
+ vw = width if width is not None else (fig.venue_width if venue is None else None)
622
+ self.width_arg = vw or "text"
623
+ self.target = self.venue.width_pt(self.width_arg) if self.venue else None
624
+ self.scale = float(scale)
625
+ self.ignore = set(ignore or ()) | set(lint_config.disable)
626
+ self.th = {k: dict(v) for k, v in lint_config.thresholds.items()}
627
+ for k, v in (thresholds or {}).items():
628
+ self.th.setdefault(k, {}).update(v)
629
+ self.pdf = pdf
630
+ self.alt = alt
631
+ self.findings: List[Finding] = []
632
+
633
+ def t(self, rule: str, key: str, default: float) -> float:
634
+ return float(self.th.get(rule, {}).get(key, default))
635
+
636
+ def add(self, rule: str, sev: str, msg: str, elements: Sequence[str] = (),
637
+ measured: Optional[float] = None, threshold: Optional[float] = None,
638
+ boxes: Sequence[Sequence[float]] = ()) -> None:
639
+ if rule in self.ignore:
640
+ return
641
+ self.findings.append(Finding(rule, sev, msg, list(elements),
642
+ None if measured is None else round(float(measured), 3),
643
+ None if threshold is None else round(float(threshold), 3),
644
+ RULES[rule].fix,
645
+ [[round(float(v), 2) for v in b] for b in boxes]))
646
+
647
+
648
+ def lint_figure(fig: Any, venue: Optional[str] = None, width: Any = None, scale: float = 1.0,
649
+ ignore: Optional[Sequence[str]] = None,
650
+ thresholds: Optional[Dict[str, Dict[str, float]]] = None,
651
+ pdf: Any = None, alt: Optional[str] = None) -> LintReport:
652
+ """Implementation of :meth:`codefig.Figure.lint` (see module docstring)."""
653
+ import warnings
654
+
655
+ from .overlaps import OverlapWarning
656
+
657
+ for r in list(ignore or ()):
658
+ if r not in RULES:
659
+ raise ValueError(f"unknown lint rule {r!r}")
660
+ cx = _Ctx(fig, venue, width, scale, ignore, thresholds, pdf, alt)
661
+ with warnings.catch_warnings():
662
+ warnings.simplefilter("ignore", OverlapWarning)
663
+ with fig._ctx():
664
+ svg, id_map = _instrumented_svg(fig)
665
+ overlaps = []
666
+ for letter in fig._order:
667
+ overlaps.extend(fig._panels[letter].check_overlaps())
668
+ prims = _scan_svg(svg, id_map)
669
+ cx.prims = prims
670
+ # authoring unit → printed pt
671
+ if cx.target is not None:
672
+ cx.factor = cx.target / fig.width * cx.scale
673
+ else:
674
+ cx.factor = cx.scale
675
+ _check_text(cx, prims)
676
+ _check_text_geometry(cx, prims, svg)
677
+ _check_strokes(cx, prims)
678
+ _check_colour(cx, prims)
679
+ _check_overlaps(cx, overlaps)
680
+ for panel in fig._panels.values():
681
+ for legend in panel.__dict__.get('_corner_legends', []):
682
+ if getattr(legend, '_corner_conflict', 0) > 0:
683
+ clear = ', '.join(legend._clear_corners) or 'none; use external placement'
684
+ cx.add('CF-LAY-009', 'warn', f'Legend obscures content; clear corners: {clear}')
685
+ _check_scene(cx)
686
+ _check_export(cx, prims)
687
+ order = {s: i for i, s in enumerate(SEVERITIES)}
688
+ cx.findings.sort(key=lambda f: (order[f.severity], f.rule_id))
689
+ return LintReport(cx.findings, cx.venue.name if cx.venue else None,
690
+ cx.target, round(cx.factor, 4),
691
+ [r for r in RULES if r not in cx.ignore and RULES[r].implemented])
692
+
693
+
694
+ # ---- text --------------------------------------------------------------------------
695
+ def _short(t: str, n: int = 22) -> str:
696
+ t = " ".join((t or "").split())
697
+ return t if len(t) <= n else t[: n - 1] + "…"
698
+
699
+
700
+ def _check_text_geometry(cx: _Ctx, prims: List[_Prim], svg: str) -> None:
701
+ """CF-TXT-010 (drawn text covered / struck / overlapping) and CF-TXT-011 (dropped)."""
702
+ from .lint_geom import check_text_geometry
703
+
704
+ if "CF-TXT-010" not in cx.ignore:
705
+ res = check_text_geometry(prims, svg, _ignored)
706
+
707
+ def who(p: Any) -> str:
708
+ return _name(p.owner) if getattr(p, "owner", None) is not None else "figure"
709
+
710
+ groups = (("covered", "error", "painted over by"), ("struck", "error", "struck through by"),
711
+ ("text", "error", "overlaps text"), ("grazed", "warn", "touched by a line of"),
712
+ ("gapped", "warn", "crossed (gap cut behind the text) by"))
713
+ for key, sev, verb in groups:
714
+ items = res[key]
715
+ if not items:
716
+ continue
717
+ descs, els, boxes = [], [], []
718
+ for r, other, box in items:
719
+ if key == "text":
720
+ d = f"'{_short(r.text)}' {verb} '{_short(other.text)}'"
721
+ els.append(who(r.p))
722
+ else:
723
+ d = f"'{_short(r.text)}' ({who(r.p)}) {verb} {who(other)}"
724
+ els.append(who(r.p))
725
+ descs.append(d)
726
+ boxes.append(r.core)
727
+ more = f" (+{len(descs) - 3} more)" if len(descs) > 3 else ""
728
+ cx.add("CF-TXT-010", sev, f"{len(descs)} drawn label(s) {verb.split(' by')[0]}: "
729
+ + "; ".join(descs[:3]) + more, list(dict.fromkeys(els)), len(descs), 0, boxes)
730
+ if "CF-TXT-011" not in cx.ignore:
731
+ from .notices import drained
732
+
733
+ notes = [d for d in drained() if not _ignored(_ancestors(d[0]), "CF-TXT-011")]
734
+ for lvl, head in (("warn", "shortened or not drawn"), ("info", "moved to a fallback spot")):
735
+ items = [d for d in notes if d[3] == lvl]
736
+ if not items:
737
+ continue
738
+ descs = [f"{_name(o)}: '{_short(t)}' {why}" for o, t, why, _l in items]
739
+ more = f" (+{len(descs) - 3} more)" if len(descs) > 3 else ""
740
+ cx.add("CF-TXT-011", lvl, f"{len(descs)} label(s) {head}: " + "; ".join(descs[:3]) + more,
741
+ list(dict.fromkeys(_name(o) for o, _t, _w, _l in items)), len(descs), 0)
742
+
743
+
744
+ def _is_panel_tag(p: _Prim) -> bool:
745
+ return p.owner is None and re.match(r"^\([a-z0-9]+\)", p.text or "") is not None
746
+
747
+
748
+ def _check_text(cx: _Ctx, prims: List[_Prim]) -> None:
749
+ texts = [p for p in prims if p.tag == "text" and not p.in_defs and p.font_size > 0]
750
+ if not texts:
751
+ return
752
+ sizes = []
753
+ worst: Dict[str, Tuple[float, List[str]]] = {}
754
+ warn_t, err_t = cx.t("CF-TXT-001", "warn", 6.0), cx.t("CF-TXT-001", "error", 5.0)
755
+ if cx.venue is not None:
756
+ warn_t = max(warn_t, cx.venue.min_font_pt) if "warn" not in cx.th.get("CF-TXT-001", {}) else warn_t
757
+ small_err, small_warn = [], []
758
+ script_t = min(warn_t, cx.t("CF-TXT-001", "script", 5.5))
759
+ for p in texts:
760
+ eff = p.font_size * p.scale * cx.factor
761
+ if not p.script:
762
+ sizes.append((eff, p))
763
+ if _ignored(p.chain, "CF-TXT-001"):
764
+ continue
765
+ if eff < err_t - 0.03:
766
+ small_err.append((eff, p))
767
+ elif eff < (script_t if p.script else warn_t) - 0.03:
768
+ small_warn.append((eff, p))
769
+ from .lint_geom import text_runs
770
+
771
+ run_box = {id(r.p): r.core for r in text_runs(texts)}
772
+ for lst, sev, thr in ((small_err, "error", err_t), (small_warn, "warn", warn_t)):
773
+ if lst:
774
+ mn = min(e for e, _ in lst)
775
+ els = sorted({f"text:{p.text[:20]!r}" for _, p in lst})
776
+ cx.add("CF-TXT-001", sev, f"{len(lst)} text run(s) below {thr:g} pt at final size "
777
+ f"(smallest {mn:.2f} pt)", els, mn, thr,
778
+ [run_box[id(p)] for _, p in lst if id(p) in run_box])
779
+ # distinct sizes (panel tags excluded)
780
+ body = [(e, p) for e, p in sizes if not _is_panel_tag(p) and not _ignored(p.chain, "CF-TXT-002")]
781
+ if body:
782
+ bins = _bins([e for e, _ in body], 0.5)
783
+ vals = sorted(e for e, _ in body)
784
+ med = vals[len(vals) // 2]
785
+ ratio = max(vals) / med if med else 1.0
786
+ max_n = cx.t("CF-TXT-002", "max_sizes", 3)
787
+ max_r = cx.t("CF-TXT-002", "max_ratio", 1.6)
788
+ if len(bins) > max_n:
789
+ cx.add("CF-TXT-002", "warn", f"{len(bins)} distinct font sizes "
790
+ f"({', '.join(f'{b:g}' for b in bins)} pt)", [], len(bins), max_n)
791
+ elif ratio > max_r:
792
+ cx.add("CF-TXT-002", "warn", f"largest/median font size = {ratio:.2f}", [], ratio, max_r)
793
+ # vs body text
794
+ if cx.venue is not None:
795
+ lim = cx.venue.body_pt + cx.t("CF-TXT-003", "margin", 1.0)
796
+ big = [(e, p) for e, p in body if e > lim + 1e-6]
797
+ if big:
798
+ cx.add("CF-TXT-003", "info", f"{len(big)} text run(s) larger than body text + 1 pt "
799
+ f"(max {max(e for e, _ in big):.1f} pt)",
800
+ sorted({f"text:{p.text[:20]!r}" for _, p in big}), max(e for e, _ in big), lim)
801
+ # families
802
+ fams = set()
803
+ for p in texts:
804
+ fam = (p.family.split(",")[0].strip().strip("'\"") if p.family else "")
805
+ if not fam or "mono" in fam.lower() or "courier" in fam.lower() or "consol" in fam.lower():
806
+ continue
807
+ if _ignored(p.chain, "CF-TXT-004"):
808
+ continue
809
+ if p.text and _all_fallback_glyphs(p.text):
810
+ continue # per-glyph fallback (⋯, ⊕, ✓) for glyphs the theme face lacks
811
+ fams.add(fam)
812
+ if len(fams) > 1:
813
+ cx.add("CF-TXT-004", "warn", f"{len(fams)} text families: {', '.join(sorted(fams))}",
814
+ sorted(fams), len(fams), 1)
815
+ # emphasis: colours and bold
816
+ cols = set()
817
+ for p in texts:
818
+ c = parse_color(p.attrs.get("fill", "#000000"))
819
+ if c is None or _ignored(p.chain, "CF-TXT-008") or type(p.owner).__name__ == "CodeBlock":
820
+ continue
821
+ L = lab(c)[0]
822
+ if L > 90: # white text on dark fills is a contrast device, not emphasis
823
+ continue
824
+ if L < 30 and max(c) - min(c) < 0.08: # near-black / dark grey = one "black"
825
+ continue
826
+ cols.add(_hex(c))
827
+ if len(cols) + 1 > cx.t("CF-TXT-008", "max_colors", 2):
828
+ cx.add("CF-TXT-008", "info", f"{len(cols) + 1} text colours (black + {', '.join(sorted(cols))})",
829
+ sorted(cols), len(cols) + 1, 2)
830
+ bold = [p for p in texts if str(p.weight) in ("bold", "700", "800", "900")
831
+ and not _is_panel_tag(p) and p.owner is not None and not _ignored(p.chain, "CF-TXT-008")
832
+ and "data-cf-glyph" not in p.attrs # step badges ①②③
833
+ and type(p.owner).__name__ not in ("Group", "Lanes", "Lane", "Legend", "Text", "Timeline",
834
+ "Container", "Row", "Col", "Stack", "Grid", "Table",
835
+ "Matrix", "Brace", "TokenStrip", "CodeBlock")]
836
+ if bold:
837
+ cx.add("CF-TXT-008", "info", f"{len(bold)} bold label(s) outside titles",
838
+ sorted({f"text:{p.text[:20]!r}" for p in bold})[:6], len(bold), 0)
839
+
840
+
841
+ # ---- strokes -----------------------------------------------------------------------
842
+ def _all_fallback_glyphs(text: str) -> bool:
843
+ from .textutil import _lacks_glyph
844
+
845
+ chars = [c for c in text if not c.isspace()]
846
+ return bool(chars) and all(_lacks_glyph(c) for c in chars)
847
+
848
+
849
+ def _check_strokes(cx: _Ctx, prims: List[_Prim]) -> None:
850
+ widths: List[float] = []
851
+ owners_by_w: Dict[float, set] = {}
852
+ dashes = set()
853
+ thin_err, thin_warn = [], []
854
+ warn_t, err_t = cx.t("CF-STR-001", "warn", 0.5), cx.t("CF-STR-001", "error", 0.25)
855
+ for p in prims:
856
+ if p.tag in ("text", "image") or p.in_defs:
857
+ continue
858
+ stroke = p.attrs.get("stroke")
859
+ if stroke in (None, "none") or parse_color(stroke) is None and not str(stroke).startswith("url"):
860
+ continue
861
+ if float(p.attrs.get("stroke-opacity", "1") or 1) == 0:
862
+ continue
863
+ try:
864
+ sw = float(re.sub(r"[a-z]+$", "", p.attrs.get("stroke-width", "1")) or 1)
865
+ except ValueError:
866
+ continue
867
+ if sw <= 0:
868
+ continue
869
+ eff = sw * p.scale * cx.factor
870
+ if _is_hatch(p):
871
+ continue
872
+ try:
873
+ op = float(p.attrs.get("opacity", p.attrs.get("stroke-opacity", "1")) or 1)
874
+ except ValueError:
875
+ op = 1.0
876
+ # pictogram strokes (Icon glyphs, icon badges) scale with the icon like text does,
877
+ # so they are not part of the figure's line-weight vocabulary
878
+ icon_glyph = "data-cf-icon" in p.attrs
879
+ if op >= 0.6 and not icon_glyph: # translucent underlays (bundle buses) are not weights
880
+ widths.append(eff)
881
+ owners_by_w.setdefault(round(eff, 1), set()).add(_name(p.owner))
882
+ da = p.attrs.get("stroke-dasharray")
883
+ if da and da != "none":
884
+ dashes.add(tuple(round(float(x) * p.scale * cx.factor * 2) / 2
885
+ for x in re.split(r"[\s,]+", da.strip()) if x))
886
+ if _ignored(p.chain, "CF-STR-001"):
887
+ continue
888
+ if eff < err_t - 1e-6:
889
+ thin_err.append((eff, p))
890
+ elif eff < warn_t - 1e-6:
891
+ thin_warn.append((eff, p))
892
+ for lst, sev, thr in ((thin_err, "error", err_t), (thin_warn, "warn", warn_t)):
893
+ if lst:
894
+ mn = min(e for e, _ in lst)
895
+ els = sorted({_name(p.owner) for _, p in lst})
896
+ cx.add("CF-STR-001", sev, f"{len(lst)} stroke(s) thinner than {thr:g} pt "
897
+ f"(thinnest {mn:.2f} pt)", els[:8], mn, thr)
898
+ if widths:
899
+ bins = _bins(widths, 0.1)
900
+ # merge bins within 0.15 pt (rounding noise)
901
+ merged: List[float] = []
902
+ for b in bins:
903
+ if not merged or b - merged[-1] > 0.15:
904
+ merged.append(b)
905
+ if len(merged) > cx.t("CF-STR-002", "max_widths", 3):
906
+ rare = sorted(owners_by_w, key=lambda w: len(owners_by_w[w]))[:2]
907
+ els = [f"{w:g} pt: " + ", ".join(sorted(owners_by_w[w])[:3]) for w in rare]
908
+ cx.add("CF-STR-002", "warn", f"{len(merged)} stroke widths "
909
+ f"({', '.join(f'{b:.1f}' for b in merged)} pt)", els, len(merged), 3)
910
+ if len(dashes) > cx.t("CF-STR-002", "max_dashes", 2):
911
+ cx.add("CF-STR-002", "warn", f"{len(dashes)} dash patterns", [str(d) for d in sorted(dashes)][:5],
912
+ len(dashes), 2)
913
+
914
+
915
+ def _is_hatch(p: _Prim) -> bool:
916
+ # hatch / pattern strokes live in a clip group painted by Hatch.paint
917
+ return p.tag == "path" and "clip-path" in p.attrs and p.attrs.get("fill") in (None, "none")
918
+
919
+
920
+ # ---- colour ------------------------------------------------------------------------
921
+ def _category_fills(cx: _Ctx) -> Dict[str, Tuple[Tuple[float, float, float], List[Any]]]:
922
+ """Distinct fills of box-like leaf nodes (categorical colour), hex → (rgb, nodes)."""
923
+ from .constraints import walk
924
+ from .primitives import Box
925
+
926
+ out: Dict[str, Tuple[Tuple[float, float, float], List[Any]]] = {}
927
+ for p in cx.fig._panels.values():
928
+ if p.content is None:
929
+ continue
930
+ for n in walk(p.content):
931
+ if not isinstance(n, Box):
932
+ continue
933
+ rgb = parse_color(n.fill if isinstance(n.fill, str) else None)
934
+ if rgb is None:
935
+ continue
936
+ L, a, b = lab(rgb)
937
+ if L > 97.5 and math.hypot(a, b) < 4: # white-ish = no colour
938
+ continue
939
+ out.setdefault(_hex(rgb), (rgb, []))[1].append(n)
940
+ return out
941
+
942
+
943
+ def _hue_family(rgb) -> Optional[int]:
944
+ L, a, b = lab(rgb)
945
+ if math.hypot(a, b) < 8:
946
+ return None # neutral grey
947
+ return int(((math.degrees(math.atan2(b, a)) + 360 + 22.5) % 360) // 45)
948
+
949
+
950
+ def _check_colour(cx: _Ctx, prims: List[_Prim]) -> None:
951
+ fills = _category_fills(cx)
952
+ items = [(h, rgb, nodes) for h, (rgb, nodes) in fills.items()
953
+ if not all(_ignored(_ancestors(n), "CF-COL-002") for n in nodes)]
954
+ fams = {f for _, rgb, _ in items for f in [_hue_family(rgb)] if f is not None}
955
+ if len(fams) > cx.t("CF-COL-001", "warn", 5):
956
+ cx.add("CF-COL-001", "warn", f"{len(fams)} categorical hue families", [h for h, _, _ in items][:8],
957
+ len(fams), 5)
958
+ elif len(fams) >= cx.t("CF-COL-001", "info", 5):
959
+ cx.add("CF-COL-001", "info", f"{len(fams)} categorical hue families", [h for h, _, _ in items][:8],
960
+ len(fams), 5)
961
+ # pairwise CVD / grey / red-green
962
+ warn_t, err_t = cx.t("CF-COL-002", "warn", 10.0), cx.t("CF-COL-002", "error", 5.0)
963
+ gl_t = cx.t("CF-COL-003", "warn", 8.0)
964
+ for i in range(len(items)):
965
+ for j in range(i + 1, len(items)):
966
+ h1, c1, n1 = items[i]
967
+ h2, c2, n2 = items[j]
968
+ normal = delta_e(lab(c1), lab(c2))
969
+ if normal < 3: # same colour by eye
970
+ continue
971
+ d = min(delta_e(cvd_lab(c1, "deutan"), cvd_lab(c2, "deutan")),
972
+ delta_e(cvd_lab(c1, "protan"), cvd_lab(c2, "protan")))
973
+ pair = [h1, h2]
974
+ if d < err_t:
975
+ cx.add("CF-COL-002", "error", f"fills {h1} / {h2} are near-identical with CVD "
976
+ f"(ΔE {d:.1f})", pair, d, err_t)
977
+ elif d < warn_t:
978
+ cx.add("CF-COL-002", "warn", f"fills {h1} / {h2} hard to tell apart with CVD "
979
+ f"(ΔE {d:.1f})", pair, d, warn_t)
980
+ if normal > 30 and d < warn_t:
981
+ cx.add("CF-COL-006", "warn", f"{h1} / {h2} differ mainly on the red–green axis", pair,
982
+ d, warn_t)
983
+ dl = abs(lab(c1)[0] - lab(c2)[0])
984
+ if dl < gl_t and not _has_cue(n1, n2) and not all(
985
+ _ignored(_ancestors(n), "CF-COL-003") for n in n1 + n2):
986
+ cx.add("CF-COL-003", "warn", f"fills {h1} / {h2} have the same grey level "
987
+ f"(ΔL* {dl:.1f}) and no hatch/border cue", pair, dl, gl_t)
988
+ # key for meaning
989
+ cats = cx.fig.categories()
990
+ has_legend = _has_legend(cx)
991
+ if len(cats) >= 2 and not has_legend:
992
+ cx.add("CF-COL-004", "warn", f"{len(cats)} categories ({', '.join(cats[:5])}) but no Legend",
993
+ cats[:6], len(cats), 2)
994
+ elif not cats and len(items) >= 3 and not has_legend:
995
+ cx.add("CF-COL-004", "info", f"{len(items)} fill colours without category= or a legend — "
996
+ "if colour carries meaning add a key; if decorative, consider neutral grey",
997
+ [h for h, _, _ in items][:6], len(items), 3)
998
+ # text contrast on its background
999
+ _check_contrast(cx)
1000
+
1001
+
1002
+ def _has_cue(n1: List[Any], n2: List[Any]) -> bool:
1003
+ def cue(ns):
1004
+ n = ns[0]
1005
+ return (getattr(n, "pattern", None) is not None, str(getattr(n, "border", None) or "solid"))
1006
+
1007
+ return cue(n1) != cue(n2)
1008
+
1009
+
1010
+ def _has_legend(cx: _Ctx) -> bool:
1011
+ from .constraints import walk
1012
+ from .legend import Legend
1013
+
1014
+ for p in cx.fig._panels.values():
1015
+ trees = ([p.content] if p.content is not None else []) + list(p._overlays)
1016
+ for t in trees:
1017
+ if any(isinstance(n, Legend) for n in walk(t)):
1018
+ return True
1019
+ return False
1020
+
1021
+
1022
+ def _check_contrast(cx: _Ctx) -> None:
1023
+ from .constraints import walk
1024
+ from .primitives import Box
1025
+
1026
+ bad = []
1027
+ lim = cx.t("CF-COL-005", "min_dL", 50.0)
1028
+ for p in cx.fig._panels.values():
1029
+ if p.content is None:
1030
+ continue
1031
+ for n in walk(p.content):
1032
+ if not isinstance(n, Box) or not (n.text or getattr(n, "subtitle", None)):
1033
+ continue
1034
+ bg = parse_color(n.fill if isinstance(n.fill, str) else None)
1035
+ fg = parse_color(n.text_color if isinstance(n.text_color, str) else None)
1036
+ if bg is None or fg is None or _ignored(_ancestors(n), "CF-COL-005"):
1037
+ continue
1038
+ dl = abs(lab(bg)[0] - lab(fg)[0])
1039
+ if dl < lim:
1040
+ bad.append((dl, n))
1041
+ if bad:
1042
+ sev = "warn" if (cx.venue is not None and cx.venue.no_gray_text_bg) else "info"
1043
+ mn = min(d for d, _ in bad)
1044
+ cx.add("CF-COL-005", sev, f"{len(bad)} label(s) with low text/background contrast "
1045
+ f"(ΔL* {mn:.0f})", [_name(n) for _, n in bad][:6], mn, lim)
1046
+
1047
+
1048
+ # ---- overlaps ----------------------------------------------------------------------
1049
+ def _check_overlaps(cx: _Ctx, overlaps: List[Any]) -> None:
1050
+ from .primitives import Text
1051
+
1052
+ txt, box, route = [], [], []
1053
+ for o in overlaps:
1054
+ a, b = o.a, o.b
1055
+ chain = _ancestors(a) + _ancestors(b) if not hasattr(a, "src") else [a] + _ancestors(b)
1056
+ if o.kind == "node":
1057
+ is_text = isinstance(a, Text) or isinstance(b, Text)
1058
+ rule = "CF-TXT-005" if is_text else "CF-LAY-005"
1059
+ if _ignored(chain, rule):
1060
+ continue
1061
+ (txt if is_text else box).append(o)
1062
+ elif o.kind == "edge-node":
1063
+ rule = "CF-TXT-005" if isinstance(b, Text) else "CF-ARR-002"
1064
+ if _ignored(chain, rule):
1065
+ continue
1066
+ (txt if rule == "CF-TXT-005" else route).append(o)
1067
+ else:
1068
+ if _ignored(chain, "CF-TXT-005"):
1069
+ continue
1070
+ txt.append(o)
1071
+ for lst, rule, sev in ((txt, "CF-TXT-005", "error"), (box, "CF-LAY-005", "error"),
1072
+ (route, "CF-ARR-002", "warn")):
1073
+ if lst:
1074
+ worst = max((min(o.dx, o.dy) for o in lst), default=0.0)
1075
+ cx.add(rule, sev, f"{len(lst)} overlap(s): " + "; ".join(str(o) for o in lst[:3])
1076
+ + (" …" if len(lst) > 3 else ""),
1077
+ [f"{_name(o.a)} | {_name(o.b)}" for o in lst[:6]],
1078
+ worst * cx.factor, 0.0,
1079
+ _node_boxes(cx, [n for o in lst for n in (o.a, o.b)]))
1080
+
1081
+
1082
+ # ---- scene graph -------------------------------------------------------------------
1083
+ def _check_scene(cx: _Ctx) -> None:
1084
+ from .constraints import walk
1085
+ from .edges import Diagram, Edge
1086
+ from .compose import Stacked
1087
+ from .node import Col, Container, Grid, Row, Stack
1088
+ from .primitives import Box, CircleHighlight, Group
1089
+
1090
+ fig = cx.fig
1091
+ invisible_ends: List[Any] = []
1092
+ sparse_panels: List[Tuple[float, str]] = []
1093
+ region_cover: List[Tuple[Any, list, str]] = []
1094
+ long_lab, many_lines, sentence = [], [], []
1095
+ pad_err, pad_warn = [], []
1096
+ radii: Dict[str, set] = {}
1097
+ dashed_no_legend = []
1098
+ heads = set()
1099
+ head_sizes = set()
1100
+ align_frac = []
1101
+ gap_bad = []
1102
+ crossings = 0
1103
+ has_legend = _has_legend(cx)
1104
+ for letter in fig._order:
1105
+ p = fig._panels[letter]
1106
+ if p.content is None:
1107
+ continue
1108
+ scale = getattr(p, "_scale", 1.0) or 1.0
1109
+ boxes = []
1110
+ # children placed by relational constraints have intentional, solved gaps
1111
+ pinned = set()
1112
+ for n in walk(p.content):
1113
+ cs = getattr(n, "constraints", None)
1114
+ if isinstance(cs, list):
1115
+ try:
1116
+ for c in cs:
1117
+ pinned.update(id(x) for x in c.nodes())
1118
+ except Exception: # pragma: no cover
1119
+ pass
1120
+ for n in walk(p.content):
1121
+ if isinstance(n, Box):
1122
+ boxes.append(n)
1123
+ _label_checks(cx, n, long_lab, many_lines, sentence)
1124
+ _pad_check(cx, n, pad_err, pad_warn, scale)
1125
+ if n.rounded if hasattr(n, "rounded") else False:
1126
+ r = n.radius if getattr(n, "radius", None) is not None else n.theme.corner_radius
1127
+ if type(n).__name__ == "Box" and not _ignored(_ancestors(n), "CF-LAY-003"):
1128
+ radii.setdefault("Box", set()).add(round(r * scale * cx.factor, 1))
1129
+ if (isinstance(n, Container) and len(n.children()) >= 3
1130
+ # v0.12.0 (B7): a constraint on any descendant (not only a direct
1131
+ # child) can stretch the gaps, so those containers are skipped too
1132
+ and not any(id(d) in pinned for ch in n.children() for d in walk(ch))
1133
+ and not _ignored(_ancestors(n), "CF-LAY-002")):
1134
+ g = _gap_cv(n)
1135
+ if g is not None and g[0] > cx.t("CF-LAY-002", "max_cv", 0.25):
1136
+ gap_bad.append((g[0], n, g[1]))
1137
+ if isinstance(n, Diagram) and getattr(n, "regions", None):
1138
+ from .regions import diagram_boxes, region_others
1139
+
1140
+ n._layout_regions()
1141
+ pre = diagram_boxes(n)
1142
+ for r in n.regions:
1143
+ if _ignored([n], "CF-REG-001"):
1144
+ break
1145
+ bad = [o for o, rect in region_others(n, r, pre)
1146
+ if r.covers(rect)]
1147
+ if bad:
1148
+ region_cover.append((r, bad, letter))
1149
+ if isinstance(n, Diagram):
1150
+ segs = []
1151
+ for e in n.edges:
1152
+ if not isinstance(e, Edge):
1153
+ continue
1154
+ try:
1155
+ st = e.resolved()
1156
+ except Exception: # pragma: no cover
1157
+ continue
1158
+ for hd in (st.end_head, st.start_head):
1159
+ if hd is not None and getattr(hd, "visible", True) and hd.shape != "none":
1160
+ heads.add(hd.shape)
1161
+ head_sizes.add((round(hd.length * scale * cx.factor, 1),
1162
+ round(hd.width * scale * cx.factor, 1)))
1163
+ if (st.dash or st.style not in (None, "solid")) and not e.legend and not has_legend \
1164
+ and not _ignored([e], "CF-STR-003"):
1165
+ dashed_no_legend.append(e)
1166
+ for pt_ in (e.src, e.dst):
1167
+ nd_ = getattr(pt_, "node", None)
1168
+ if (isinstance(nd_, (Row, Grid)) or type(nd_) in (Col, Stack)) \
1169
+ and not _ignored([e], "CF-ARR-003"):
1170
+ invisible_ends.append((e, nd_, _lands_on_child(pt_, nd_)))
1171
+ pts = e._cached_pts or []
1172
+ _check_route_geometry(cx, n, e, pts, scale * cx.factor)
1173
+ if getattr(e, "_cf_guide_edge", False):
1174
+ _check_guide_captions(cx, p, n, e, pts)
1175
+ segs.append([(pts[i], pts[i + 1]) for i in range(len(pts) - 1)])
1176
+ crossings += _count_crossings(segs)
1177
+ frac = _alignment(boxes)
1178
+ if frac is not None:
1179
+ align_frac.append((frac, letter, len(boxes)))
1180
+ if len(fig._order) > 1 and not _ignored([p], "CF-LAY-007"):
1181
+ try:
1182
+ ix, iy, iw, ih = p._inner()
1183
+ bw, bh = p._bbox[2], p._bbox[3]
1184
+ s_ = getattr(p, "_scale", 1.0) or 1.0
1185
+ if iw > 1 and ih > 1 and bw > 1 and bh > 1:
1186
+ sparse_panels.append((min(1.0, bw * s_ * bh * s_ / (iw * ih)), letter))
1187
+ except Exception: # pragma: no cover
1188
+ pass
1189
+ # labels
1190
+ if long_lab:
1191
+ cx.add("CF-TXT-006", "warn", f"{len(long_lab)} label line(s) longer than 4 words / 24 chars",
1192
+ [f"{s!r}" for s in long_lab[:6]], None, None)
1193
+ if many_lines:
1194
+ cx.add("CF-TXT-006", "warn", f"{len(many_lines)} label(s) with more than 2 lines",
1195
+ [f"{s!r}" for s in many_lines[:6]], None, 2)
1196
+ if sentence:
1197
+ cx.add("CF-TXT-006", "info", f"{len(sentence)} sentence-like label(s) (> 8 words) — "
1198
+ "move to the caption", [f"{s!r}" for s in sentence[:4]], None, 8)
1199
+ if pad_err:
1200
+ cx.add("CF-TXT-007", "error", f"{len(pad_err)} label(s) overflow their box",
1201
+ [_name(n) for _, n in pad_err][:6], min(v for v, _ in pad_err), 0.0)
1202
+ if pad_warn:
1203
+ cx.add("CF-TXT-007", "warn", f"{len(pad_warn)} label(s) with tight padding "
1204
+ f"(< 0.4 em horizontal / 0.2 em vertical)", [_name(n) for _, n in pad_warn][:6],
1205
+ min(v for v, _ in pad_warn), 0.2)
1206
+ for role, rs in radii.items():
1207
+ if len(rs) > 1:
1208
+ cx.add("CF-LAY-003", "warn", f"{len(rs)} corner radii among {role}es "
1209
+ f"({', '.join(f'{r:g}' for r in sorted(rs))} pt)", [role], len(rs), 1)
1210
+ if len(heads) > cx.t("CF-ARR-001", "max_styles", 2):
1211
+ cx.add("CF-ARR-001", "warn", f"{len(heads)} arrowhead styles ({', '.join(sorted(heads))})",
1212
+ sorted(heads), len(heads), 2)
1213
+ if len(head_sizes) > cx.t("CF-ARR-001", "max_sizes", 1):
1214
+ cx.add("CF-ARR-001", "warn", f"{len(head_sizes)} arrowhead sizes "
1215
+ f"({', '.join(f'{a:g}×{b:g}' for a, b in sorted(head_sizes))} pt)", [], len(head_sizes), 1)
1216
+ if dashed_no_legend:
1217
+ cx.add("CF-STR-003", "info", f"{len(dashed_no_legend)} dashed edge(s) without a legend entry",
1218
+ [_name(e) for e in dashed_no_legend][:6], len(dashed_no_legend), 0)
1219
+ if crossings:
1220
+ cx.add("CF-ARR-002", "info", f"{crossings} edge–edge crossing(s)", [], crossings, 0)
1221
+ for e, nd_, child in invisible_ends:
1222
+ if child is None:
1223
+ cx.add("CF-ARR-003", "warn", f"{_name(e)} ends on an invisible {type(nd_).__name__} — "
1224
+ "the arrow lands on the container's bounding box, not on a drawn element",
1225
+ [_name(e)], None, None)
1226
+ else: # it touches a child: fine if that child is the intended end, say so explicitly
1227
+ cx.add("CF-ARR-003", "info", f"{_name(e)} uses a {type(nd_).__name__} port that lands on "
1228
+ f"{_name(child)} — use that element's port if it is the intended end",
1229
+ [_name(e)], None, None)
1230
+ for r, bad, letter in region_cover:
1231
+ cx.add("CF-REG-001", "warn", f"panel ({letter}): Region {r.label or ''!r} covers "
1232
+ f"non-member(s) {', '.join(_name(o) for o in bad[:4])}",
1233
+ [_name(o) for o in bad[:6]], len(bad), 0)
1234
+ for fill, letter in sparse_panels:
1235
+ if fill < cx.t("CF-LAY-007", "warn", 0.40):
1236
+ cx.add("CF-LAY-007", "warn", f"panel ({letter}): content covers only {fill:.0%} of its "
1237
+ "cell", [f"panel:{letter}"], fill, 0.40)
1238
+ elif fill < cx.t("CF-LAY-007", "info", 0.55):
1239
+ cx.add("CF-LAY-007", "info", f"panel ({letter}): content covers {fill:.0%} of its cell",
1240
+ [f"panel:{letter}"], fill, 0.55)
1241
+ for frac, letter, nb in align_frac:
1242
+ if frac < cx.t("CF-LAY-001", "warn", 0.5):
1243
+ cx.add("CF-LAY-001", "warn", f"panel ({letter}): only {frac:.0%} of {nb} boxes share an "
1244
+ "edge/centre line", [f"panel:{letter}"], frac, 0.5)
1245
+ elif frac < cx.t("CF-LAY-001", "info", 0.8):
1246
+ cx.add("CF-LAY-001", "info", f"panel ({letter}): {frac:.0%} of {nb} boxes aligned",
1247
+ [f"panel:{letter}"], frac, 0.8)
1248
+ for cv, n, gaps in gap_bad:
1249
+ cx.add("CF-LAY-002", "warn", f"uneven gaps in {_name(n)} (CV {cv:.2f}: "
1250
+ f"{', '.join(f'{g * cx.factor:.1f}' for g in gaps[:6])} pt)", [_name(n)], cv, 0.25)
1251
+
1252
+
1253
+ def _check_guide_captions(cx, panel, diagram, edge, pts):
1254
+ """Check continuous guide paths against group titles and panel captions."""
1255
+ from .edges import _poly_hits_rect
1256
+ from .textutil import measure_text
1257
+ if not pts:
1258
+ pts = dict(diagram.layout_edges()["routed"]).get(edge, [])
1259
+ boxes = diagram._title_boxes()
1260
+ if not panel._no_caption():
1261
+ g, th = panel.geom, panel.theme
1262
+ pt, pr, pb, pl = panel.padding
1263
+ fs = th.panel_title_size
1264
+ lines = panel._title_lines()
1265
+ y = (g.y + pt + fs * .9 if panel.caption == "top" else
1266
+ g.y + g.h - pb - fs * .25 - panel._sub_h() - fs * 1.2 * (len(lines) - 1))
1267
+ ox, oy = panel._offset
1268
+ k = panel._scale
1269
+ for i, text in enumerate(lines):
1270
+ w, h = measure_text(text, fs, weight="bold")
1271
+ boxes.append(((g.x + pl - ox) / k, (y + fs * 1.2 * i - fs - oy) / k, w / k, h / k))
1272
+ if any(_poly_hits_rect(pts, box) for box in boxes) and not _ignored([edge, diagram], "CF-ANN-001"):
1273
+ cx.add("CF-ANN-001", "warn", "guide touches a caption", [_name(edge)], None, None)
1274
+
1275
+
1276
+ def _check_route_geometry(cx, diagram, edge, pts, factor):
1277
+ """Check routed interior segments and feedback excursions in printed points."""
1278
+ lengths = [math.hypot(b[0] - a[0], b[1] - a[1]) * factor
1279
+ for a, b in zip(pts, pts[1:])]
1280
+ short = [v for v in lengths[1:-1] if 0 < v < 4]
1281
+ if short and not _ignored([edge, diagram], "CF-ARR-004"):
1282
+ cx.add("CF-ARR-004", "warn",
1283
+ "Edge has a small interior jog; use jog_tol or align the endpoint nodes",
1284
+ [_name(edge)], min(short), 4)
1285
+ if pts and diagram._is_back_edge(edge):
1286
+ g = diagram.content.geom
1287
+ spill = max(0, g.x - min(x for x, y in pts),
1288
+ max(x for x, y in pts) - (g.x + g.w),
1289
+ g.y - min(y for x, y in pts),
1290
+ max(y for x, y in pts) - (g.y + g.h)) * factor
1291
+ if spill > 24 and not _ignored([edge, diagram], "CF-ARR-005"):
1292
+ cx.add("CF-ARR-005", "warn",
1293
+ "Return edge expands diagram bounds; use loop_side or leave a routing channel",
1294
+ [_name(edge)], spill, 24)
1295
+
1296
+
1297
+ def _lands_on_child(port: Any, container: Any, tol: float = 1.0) -> Any:
1298
+ """The drawn child a container port's point touches (e.g. the top of a stretched Col
1299
+ is the top edge of its first box), or None when it lands on blank space."""
1300
+ from .compose import Stacked
1301
+ from .primitives import Box, CircleHighlight, Group
1302
+
1303
+ try:
1304
+ x, y, _d = port.resolve()
1305
+ except Exception: # pragma: no cover
1306
+ return None
1307
+ for k in container.children():
1308
+ if not isinstance(k, (Box, Group, CircleHighlight, Stacked)):
1309
+ continue
1310
+ g = k.geom
1311
+ if g.x - tol <= x <= g.x + g.w + tol and g.y - tol <= y <= g.y + g.h + tol:
1312
+ return k
1313
+ return None
1314
+
1315
+
1316
+ _WORD = re.compile(r"[^\s]*\w[^\s]*") # tokens with a letter/digit: "↔", "·", "+" are not words
1317
+
1318
+
1319
+ def _plain(s: str) -> str:
1320
+ from .textutil import parse_rich
1321
+
1322
+ try:
1323
+ lines = parse_rich(s)
1324
+ return "\n".join("".join(r.text for r in ln) for ln in lines)
1325
+ except Exception: # pragma: no cover
1326
+ return s
1327
+
1328
+
1329
+ def _label_checks(cx, n, long_lab, many_lines, sentence) -> None:
1330
+ if not isinstance(getattr(n, "text", None), str) or not n.text:
1331
+ return
1332
+ chain = _ancestors(n)
1333
+ if _ignored(chain, "CF-TXT-006"):
1334
+ return
1335
+ txt = re.sub(r"[_^]\{([^}]*)\}", r"\1", _plain(n.text))
1336
+ lines = [ln for ln in txt.split("\n")]
1337
+ words = len(_WORD.findall(txt))
1338
+ mw = cx.t("CF-TXT-006", "max_words", 4)
1339
+ mc = cx.t("CF-TXT-006", "max_chars", 24)
1340
+ if any(len(_WORD.findall(ln)) > mw or len(ln) > mc for ln in lines):
1341
+ long_lab.append(txt.replace("\n", " / ")[:40])
1342
+ if len(lines) > cx.t("CF-TXT-006", "max_lines", 2):
1343
+ many_lines.append(txt.replace("\n", " / ")[:40])
1344
+ if words > 8:
1345
+ sentence.append(txt.replace("\n", " ")[:40])
1346
+
1347
+
1348
+ def _pad_check(cx, n, pad_err, pad_warn, scale) -> None:
1349
+ if not (getattr(n, "text", None) or getattr(n, "subtitle", None)):
1350
+ return
1351
+ if _ignored(_ancestors(n), "CF-TXT-007"):
1352
+ return
1353
+ g = n.geom
1354
+ if g.w <= 0 or g.h <= 0:
1355
+ return
1356
+ try:
1357
+ tw, th_ = n._content_size()
1358
+ tx, ty, tW, tH = n._text_rect(g.x, g.y, g.w, g.h)
1359
+ except Exception: # pragma: no cover
1360
+ return
1361
+ fs = n._fs()
1362
+ ph, pv = (tW - tw) / 2.0, (tH - th_) / 2.0
1363
+ tol = 0.5 / max(1e-6, scale * cx.factor)
1364
+ if ph < -tol or pv < -tol:
1365
+ pad_err.append((min(ph, pv) / fs, n))
1366
+ return
1367
+ if type(n).__name__ != "Box": # shapes use their own inscribed text rect
1368
+ return
1369
+ hmin = cx.t("CF-TXT-007", "min_h_em", 0.4)
1370
+ vmin = cx.t("CF-TXT-007", "min_v_em", 0.2)
1371
+ if ph < hmin * fs - 1e-6 or pv < vmin * fs - 1e-6:
1372
+ pad_warn.append((min(ph / fs, pv / fs), n))
1373
+
1374
+
1375
+ def _node_boxes(cx: Any, nodes: Sequence[Any]) -> List[Tuple[float, float, float, float]]:
1376
+ """v0.12.0 (A7): SVG-root bounding boxes of what ``nodes`` drew (for the lint
1377
+ preview). Edges are boxed by their drawn path; nodes by their shapes and text."""
1378
+ from .lint_geom import _apply, _geometry, text_runs
1379
+
1380
+ prims = getattr(cx, "prims", None) or []
1381
+ want = {id(n): n for n in nodes if n is not None}
1382
+ acc: Dict[int, List[float]] = {}
1383
+
1384
+ def grow(k: int, x0: float, y0: float, x1: float, y1: float) -> None:
1385
+ b = acc.setdefault(k, [x0, y0, x1, y1])
1386
+ b[0], b[1], b[2], b[3] = min(b[0], x0), min(b[1], y0), max(b[2], x1), max(b[3], y1)
1387
+
1388
+ def owner_key(p: Any) -> Optional[int]:
1389
+ for n in (p.owner,) + tuple(p.chain or ()):
1390
+ if n is not None and id(n) in want:
1391
+ return id(n)
1392
+ return None
1393
+
1394
+ for p in prims:
1395
+ if p.in_defs or p.tag == "text":
1396
+ continue
1397
+ k = owner_key(p)
1398
+ if k is None:
1399
+ continue
1400
+ try:
1401
+ for pts, _closed in _geometry(p.tag, p.attrs):
1402
+ for x, y in pts:
1403
+ X, Y = _apply(p.m, x, y)
1404
+ grow(k, X, Y, X, Y)
1405
+ except Exception: # pragma: no cover
1406
+ continue
1407
+ for r in text_runs(prims):
1408
+ k = owner_key(r.p)
1409
+ if k is not None:
1410
+ grow(k, *r.core)
1411
+ return [tuple(b) for b in acc.values()]
1412
+
1413
+
1414
+ def _edge_spill(content: Any) -> List[str]:
1415
+ """v0.13.0: Diagrams whose edges / edge labels stick out sideways past their
1416
+ nodes (that margin is part of the panel width but no node 'owns' it)."""
1417
+ from .constraints import walk
1418
+ from .edges import Diagram
1419
+
1420
+ out = []
1421
+ for d in walk(content):
1422
+ if not isinstance(d, Diagram):
1423
+ continue
1424
+ try:
1425
+ l, _t, r, _b = d._margins()
1426
+ except Exception: # pragma: no cover
1427
+ continue
1428
+ if l + r < 4.0:
1429
+ continue
1430
+ g = d.content.geom
1431
+ worst = None
1432
+ try:
1433
+ for e, _p, ((x, _y, w, _h), _r, _a) in d.layout_edges()["labels"]:
1434
+ sp = max(g.x - x, x + w - g.right)
1435
+ if sp > 1.0 and (worst is None or sp > worst[0]):
1436
+ worst = (sp, e, "right" if x + w - g.right >= g.x - x else "left")
1437
+ except Exception: # pragma: no cover
1438
+ pass
1439
+ msg = f"{_name(d)} edges/labels stick out {l:.0f} pt left + {r:.0f} pt right of its nodes"
1440
+ if worst is not None:
1441
+ lab = str(getattr(worst[1], "label", "") or "").replace("\n", " ")
1442
+ msg += (f" (widest: label '{lab[:28]}' of {_name(worst[1])}, {worst[0]:.0f} pt past the "
1443
+ f"{worst[2]} side — try label_side= / label_pos= or a shorter label)")
1444
+ out.append(msg)
1445
+ return out
1446
+
1447
+
1448
+ def _width_budget(fig: Any) -> str:
1449
+ """v0.13.0: break the natural width into panels, fixed overhead (gaps, margins,
1450
+ pads), captions / subtitles, widths= stretching and edge-label spill."""
1451
+ b = getattr(fig, "_wbudget", None)
1452
+ if not b:
1453
+ return ""
1454
+ try:
1455
+ place, needs, used = b["place"], b["needs"], b["used"]
1456
+ gap, margin = b["gap"], b["margin"]
1457
+ col_nat, col_w = b["col_nat"], b["col_w"]
1458
+ nat_sum = sum(col_nat[c] for c in used)
1459
+ fixed = gap * (len(used) - 1) + 2 * margin
1460
+ parts = []
1461
+ for k in fig._order:
1462
+ pn = fig._panels[k]
1463
+ wp = getattr(pn, "_wparts", None)
1464
+ if wp is None:
1465
+ continue
1466
+ item = f"({k}) {needs[k][0]:.0f}"
1467
+ if wp["caption"] > wp["content"] + 0.5:
1468
+ item += f" [title/subtitle {wp['caption']:.0f} > content {wp['content']:.0f}]"
1469
+ parts.append(item)
1470
+ out = (f"; width budget: panels {' + '.join(parts)} pt (each = content + pad"
1471
+ f"), columns sum {nat_sum:.0f} pt + fixed {fixed:.0f} pt "
1472
+ f"({len(used) - 1} panel gap(s) × {gap:g} + 2 × {margin:g} margin)")
1473
+ if getattr(fig, "widths", None):
1474
+ stretched = sum(col_w[c] for c in used)
1475
+ cl = getattr(fig, "_widths_clamped", [])
1476
+ if stretched > nat_sum + 0.5:
1477
+ ws = fig.widths
1478
+ worst = max(range(len(ws)), key=lambda c: col_nat[c] / ws[c] if ws[c] else 0)
1479
+ out += (f"; widths={[round(w, 2) for w in ws]} stretches the columns to "
1480
+ f"{stretched:.0f} pt because column {worst + 1} needs {col_nat[worst]:.0f} pt "
1481
+ f"but gets only a {ws[worst] / sum(ws):.0%} share")
1482
+ elif cl:
1483
+ out += (f"; widths= spare-room hint cannot be applied: the natural column widths alone exceed the slot"
1484
+ f" (widths= only shares spare room, it cannot make content narrower)")
1485
+ for k in fig._order:
1486
+ pn = fig._panels[k]
1487
+ if pn.content is not None:
1488
+ for sp in _edge_spill(pn.content):
1489
+ out += f"; panel ({k}): {sp}"
1490
+ return out
1491
+ except Exception: # pragma: no cover - diagnostics must never break lint
1492
+ return ""
1493
+
1494
+
1495
+ def _wrap_hint(fig: Any) -> str:
1496
+ """v0.14.0: the labels that still set the width (after the auto-wrap pass)."""
1497
+ try:
1498
+ from .autowrap import width_setters
1499
+
1500
+ ws = width_setters(fig)
1501
+ except Exception: # pragma: no cover
1502
+ return ""
1503
+ if not ws:
1504
+ return ""
1505
+ return ("; labels setting the width (shorten them, break them with \"\\n\" or stack them in "
1506
+ "a Col): " + ", ".join(ws))
1507
+
1508
+
1509
+ def _widest_chain(fig: Any) -> str:
1510
+ """v0.12.0 (A6): name what makes a shrunk figure wide — the widest panel, then
1511
+ down through its widest child until a Row (whose widest members are listed)."""
1512
+ try:
1513
+ from .node import Container
1514
+
1515
+ widths = getattr(fig, "widths", None)
1516
+ grid = getattr(fig, "grid", None)
1517
+
1518
+ def share(k: str) -> float: # the panel's share of the width under Figure(widths=)
1519
+ if not widths or not grid:
1520
+ return 1.0
1521
+ for row in grid:
1522
+ cols = [i for i, ch in enumerate(row) if ch == k]
1523
+ if cols:
1524
+ return sum(widths[i] for i in cols if i < len(widths)) / sum(widths)
1525
+ return 1.0
1526
+
1527
+ best = None
1528
+ for k in getattr(fig, "_order", []):
1529
+ p = fig._panels[k]
1530
+ if p.content is None:
1531
+ continue
1532
+ m = p.content.geom.w / max(share(k), 1e-6)
1533
+ if best is None or m > best[2]:
1534
+ best = (k, p.content, m)
1535
+ if best is None:
1536
+ return ""
1537
+ sh = share(best[0])
1538
+ parts = [f"panel ({best[0]}) {best[1].geom.w:.0f} pt"
1539
+ + (f" in a {sh:.0%} widths= share" if sh < 0.999 else "")]
1540
+ n = best[1]
1541
+ for _depth in range(12):
1542
+ kids = [c for c in n.children() if getattr(c, "geom", None) is not None
1543
+ and not getattr(c, "_cf_guide", False)] if hasattr(n, "children") else []
1544
+ if not kids:
1545
+ break
1546
+ if isinstance(n, Container) and getattr(n, "direction", "") == "row" and len(kids) > 1:
1547
+ top = sorted(kids, key=lambda c: -c.geom.w)[:3]
1548
+ gap = float(getattr(n, "gap", 0.0) or 0.0) if not callable(getattr(n, "gap", None)) else 0.0
1549
+ if parts and parts[-1].startswith(_name(n) + " "):
1550
+ parts.pop()
1551
+ parts.append(f"{_name(n)} of {len(kids)} side by side (gap {gap:g}; widest: "
1552
+ + ", ".join(f"{_name(c)} {c.geom.w:.0f}" for c in top) + ")")
1553
+ break
1554
+ w = max(kids, key=lambda c: c.geom.w)
1555
+ if w.geom.w < 0.6 * n.geom.w and len(kids) > 1:
1556
+ parts.append(f"{_name(n)} {n.geom.w:.0f} pt")
1557
+ break
1558
+ n = w
1559
+ parts.append(f"{_name(n)} {n.geom.w:.0f} pt")
1560
+ return "; widest chain: " + " → ".join(parts)
1561
+ except Exception: # pragma: no cover - diagnostics must never break lint
1562
+ return ""
1563
+
1564
+
1565
+ def _gap_cv(n) -> Optional[Tuple[float, List[float]]]:
1566
+ kids = [k for k in n.children() if k.geom.w > 0 and k.geom.h > 0]
1567
+ if len(kids) < 3 or getattr(n, "justify", "start") not in ("start", "center", "end"):
1568
+ return None
1569
+ horiz = getattr(n, "direction", "column") == "row"
1570
+ ks = sorted(kids, key=lambda k: k.geom.x if horiz else k.geom.y)
1571
+ gaps = []
1572
+ for a, b in zip(ks, ks[1:]):
1573
+ g = (b.geom.x - a.geom.right) if horiz else (b.geom.y - a.geom.bottom)
1574
+ gaps.append(g)
1575
+ if any(g < -0.5 for g in gaps):
1576
+ return None
1577
+ mean = sum(gaps) / len(gaps)
1578
+ if mean <= 1.0:
1579
+ return None
1580
+ sd = math.sqrt(sum((g - mean) ** 2 for g in gaps) / len(gaps))
1581
+ return sd / mean, gaps
1582
+
1583
+
1584
+ def _alignment(boxes: List[Any]) -> Optional[float]:
1585
+ bs = [b for b in boxes if b.geom.w > 0 and b.geom.h > 0]
1586
+ if len(bs) < 3:
1587
+ return None
1588
+ tol = 1.0
1589
+ ok = 0
1590
+ for b in bs:
1591
+ g = b.geom
1592
+ xs = (g.x, g.cx, g.right)
1593
+ ys = (g.y, g.cy, g.bottom)
1594
+ hit = False
1595
+ for o in bs:
1596
+ if o is b:
1597
+ continue
1598
+ h = o.geom
1599
+ if any(abs(a - c) <= tol for a, c in zip(xs, (h.x, h.cx, h.right))) or \
1600
+ any(abs(a - c) <= tol for a, c in zip(ys, (h.y, h.cy, h.bottom))):
1601
+ hit = True
1602
+ break
1603
+ ok += hit
1604
+ return ok / len(bs)
1605
+
1606
+
1607
+ def _count_crossings(edges: List[List[Tuple[Tuple[float, float], Tuple[float, float]]]]) -> int:
1608
+ def cross(p1, p2, p3, p4) -> bool:
1609
+ def o(a, b, c):
1610
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
1611
+
1612
+ d1, d2 = o(p3, p4, p1), o(p3, p4, p2)
1613
+ d3, d4 = o(p1, p2, p3), o(p1, p2, p4)
1614
+ return (d1 * d2 < -1e-6) and (d3 * d4 < -1e-6)
1615
+
1616
+ n = 0
1617
+ for i in range(len(edges)):
1618
+ for j in range(i + 1, len(edges)):
1619
+ if any(cross(a, b, c, d) for a, b in edges[i] for c, d in edges[j]):
1620
+ n += 1
1621
+ return n
1622
+
1623
+
1624
+ # ---- export ------------------------------------------------------------------------
1625
+ def _check_export(cx: _Ctx, prims: List[_Prim]) -> None:
1626
+ from .constraints import walk
1627
+ fig = cx.fig
1628
+ W, H = fig.width, fig.height
1629
+ if cx.venue is not None and cx.target is not None:
1630
+ if fig.physical:
1631
+ if abs(W - cx.target) > 0.02 * cx.target:
1632
+ cx.add("CF-EXP-001", "warn", f"figure is {W:.0f} pt wide, {cx.venue.name} "
1633
+ f"{cx.width_arg} width is {cx.target:.0f} pt (LaTeX will rescale it ×"
1634
+ f"{cx.target / W:.2f})", [], W, cx.target)
1635
+ else:
1636
+ cx.add("CF-EXP-001", "info", f"px figure ({W:.0f} px) checked as scaled ×{cx.factor:.3f} "
1637
+ f"to {cx.target:.0f} pt; Figure(venue=...) lays out at print size", [], cx.factor, 1.0)
1638
+ if getattr(fig, "global_scale", 1.0) < 0.999:
1639
+ nat = getattr(fig, "natural_width", W)
1640
+ cx.add("CF-EXP-001", "warn" if fig.global_scale < 0.98 else "info", f"content is {nat:.0f} pt wide but the slot is {W:.0f} pt: "
1641
+ f"everything shrinks ×{fig.global_scale:.2f} (fonts {fig.theme.small_size * fig.global_scale:.1f}"
1642
+ f"–{fig.theme.title_size * fig.global_scale:.1f} pt) — use width='text', cols=, or fewer/shorter boxes"
1643
+ + f"; attempted autowrap ({len(getattr(fig, '_autowrapped', []))} labels), gap scale {fig._layout_report.get('gap_scale_used', 1.0):.3f}"
1644
+ + _width_budget(fig) + _widest_chain(fig) + _wrap_hint(fig)
1645
+ + " (docs/guides/fit-a-column.md; docs/cookbook/30_compact_column.py)",
1646
+ [], fig.global_scale, 1.0)
1647
+ if any(n.__dict__.get("_slot_width") for p in fig._panels.values()
1648
+ if p.content is not None for n in walk(p.content)):
1649
+ cx.add("CF-EXP-001", "info", "reflowed overflowing rows before shrinking" + _widest_chain(fig)
1650
+ + " (docs/guides/fit-a-column.md; docs/cookbook/30_compact_column.py)", [], None, None)
1651
+ aw = getattr(fig, "_autowrapped", None) or []
1652
+ if aw:
1653
+ cx.add("CF-EXP-001", "info", f"auto-wrapped {len(aw)} label(s) onto two lines to fit the "
1654
+ f"{W:.0f} pt slot instead of shrinking: "
1655
+ + ", ".join(f"{n} → '{new.replace(chr(10), '⏎')}'" for n, _old, new in aw[:6])
1656
+ + (" …" if len(aw) > 6 else "")
1657
+ + " (put your own \\n in a label to choose the break; Figure(auto_wrap=False) "
1658
+ "or auto_wrap=False on a node / Edge to keep one line)", [], len(aw), None)
1659
+ if cx.scale != 1.0:
1660
+ cx.add("CF-EXP-001", "warn", f"LaTeX scale {cx.scale:g} ≠ 1 (sizes checked after scaling)",
1661
+ [], cx.scale, 1.0)
1662
+ if cx.venue is None and not fig.physical:
1663
+ cx.add("CF-EXP-001", "info", "no venue: px figure checked as 1 px = 1 pt; pass venue= for a "
1664
+ "real check", [], None, None)
1665
+ # aspect
1666
+ if cx.venue is not None and H > 0:
1667
+ asp = W / H
1668
+ lo, hi = (1.1, 2.5) if cx.width_arg == "column" else (2.0, 4.5)
1669
+ if not (lo <= asp <= hi) and "CF-LAY-004" not in cx.ignore:
1670
+ cx.add("CF-LAY-004", "info", f"aspect {asp:.2f} outside {lo}–{hi} for a "
1671
+ f"{cx.width_arg}-width figure", [], asp, lo if asp < lo else hi)
1672
+ target_aspect = getattr(fig, "target_aspect", None)
1673
+ if target_aspect and H > 0 and getattr(fig, "fit", "none") != "none":
1674
+ actual = W / H
1675
+ if abs(actual / target_aspect - 1) > 0.08 and "CF-LAY-008" not in cx.ignore:
1676
+ cx.add("CF-LAY-008", "warn",
1677
+ f"aspect {actual:.2f} misses target {target_aspect:g}; remove a row, "
1678
+ "use fit='scale', or relax aspect", [], actual, target_aspect)
1679
+ # fonts (PDF)
1680
+ if cx.pdf is not None:
1681
+ from .export import pdf_fonts
1682
+
1683
+ rows = pdf_fonts(cx.pdf)
1684
+ bad = [r for r in rows if r["type"].startswith("Type 3") or r["type"] == "Type3" or not r["embedded"]]
1685
+ if bad:
1686
+ cx.add("CF-EXP-002", "error", f"{len(bad)} Type 3 / unembedded font(s) in {cx.pdf}",
1687
+ [r["name"] for r in bad], len(bad), 0)
1688
+ elif cx.venue is not None and cx.venue.font_rule == "type1" and rows:
1689
+ cx.add("CF-EXP-002", "info", f"{cx.venue.name} asks for Type 1 fonts; the PDF embeds "
1690
+ "TrueType subsets (accepted by the template checkers in practice)",
1691
+ [r["name"] for r in rows][:4], None, None)
1692
+ # raster images
1693
+ for p in prims:
1694
+ if p.tag != "image" or p.in_defs:
1695
+ continue
1696
+ dpi = _image_dpi(p, cx.factor)
1697
+ if dpi is not None and dpi < cx.t("CF-EXP-003", "min_dpi", 300) and not _ignored(p.chain, "CF-EXP-003"):
1698
+ cx.add("CF-EXP-003", "warn", f"embedded image at {dpi:.0f} dpi", [_name(p.owner)], dpi, 300)
1699
+ if fig.title:
1700
+ sev = "info"
1701
+ extra = f" ({cx.venue.name}: no title inside the figure)" if cx.venue is not None and cx.venue.no_title else ""
1702
+ cx.add("CF-EXP-004", sev, "figure has an in-figure title" + extra, [fig.title[:30]], None, None)
1703
+ if not cx.alt:
1704
+ cx.add("CF-EXP-005", "info", "no alt text (save(..., alt='...'))", [], None, None)
1705
+
1706
+
1707
+ def _image_dpi(p: _Prim, factor: float) -> Optional[float]:
1708
+ import base64
1709
+ import io
1710
+
1711
+ href = p.attrs.get("{http://www.w3.org/1999/xlink}href") or p.attrs.get("href") or ""
1712
+ try:
1713
+ w = float(p.attrs.get("width", "0"))
1714
+ except ValueError:
1715
+ return None
1716
+ if not href.startswith("data:image/") or w <= 0:
1717
+ return None
1718
+ if "svg" in href.split(";")[0]:
1719
+ return None
1720
+ try:
1721
+ from PIL import Image as _PIL
1722
+
1723
+ data = base64.b64decode(href.split(",", 1)[1])
1724
+ px = _PIL.open(io.BytesIO(data)).size[0]
1725
+ except Exception:
1726
+ return None
1727
+ inches = w * p.scale * factor / 72.0
1728
+ return px / inches if inches > 0 else None
1729
+
1730
+
1731
+ def rule_table_markdown() -> str:
1732
+ """The rule table as Markdown (used to generate ``docs/lint-rules.md``)."""
1733
+ rows = ["| id | rule | max severity | threshold | fix | status |", "|---|---|---|---|---|---|"]
1734
+ for r in RULES.values():
1735
+ rows.append(f"| `{r.id}` | {r.title} | {r.severity} | {r.threshold} | {r.fix} | "
1736
+ f"{'implemented' if r.implemented else 'deferred'} |")
1737
+ return "\n".join(rows)