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/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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)
|