figure-gate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
check_figure.py ADDED
@@ -0,0 +1,1759 @@
1
+ """Composition checks for a rendered matplotlib figure.
2
+
3
+ `check_palette.py` answers "are these colors legal". This answers "is this
4
+ figure composed". They fail on different things: a figure can clear every
5
+ palette, type-size and ornament gate and still land as a washed-out smudge with
6
+ its axis label sliced off.
7
+
8
+ Every check here is mechanical and reads the figure's own artists, so it runs in
9
+ a test rather than in a design review.
10
+
11
+ from check_figure import audit
12
+ ok, rows = audit(fig)
13
+ ok, rows = audit(fig, context_axes=[ax]) # contourf background is not ink
14
+ ok, rows = audit(fig, venue="neurips") # measure type against \textwidth
15
+
16
+ python check_figure.py # self-test on a deliberately bad figure
17
+ python check_figure.py --venues # the content widths it knows
18
+
19
+ Checks, in the order `audit` runs them
20
+ 1. Clipping - no text extends past the canvas
21
+ 2. Text collision - no two text bounding boxes overlap
22
+ 3. Text readability - no data ink crosses a label; text clears WCAG on the
23
+ backdrop it actually got
24
+ 4. Contrast stack - something is at full opacity; alpha levels are few
25
+ 5. Mark ratio - largest / smallest mark area within MARK_RATIO_MAX
26
+ 6. Overplotting - scatter marks do not merge into an unreadable mass
27
+ 7. Axis redundancy - shared-axis panels do not repeat a tick label column
28
+ 8. Type size - every rendered string clears the floor once scaled
29
+ 9. Line weight - every stroke clears LINE_FLOOR_PT once scaled
30
+ 10. Ink coverage - the data region is neither empty nor packed
31
+ 11. Series color - the hues in each panel separate under color blindness
32
+ 12. Dual axis - no second y scale carrying data of its own
33
+ 13. Form - no pie, no 3D, no truncated bar baseline
34
+ 14. Identity channel - series are not told apart by color alone
35
+ 15. Label attribution - each label is nearest the curve it names
36
+ 16. Style sheet - figure.mplstyle is the one actually in effect
37
+ 17. Contour dash - dashing is not spent on a signed contour's negatives
38
+ 18. Fonts - Type 42 embedding; the named face is installed
39
+ 19. Alt text - the figure carries a description
40
+ """
41
+
42
+ import itertools
43
+ from collections import Counter
44
+ from pathlib import Path
45
+
46
+ MARK_RATIO_MAX = 5.0 # area ratio of largest to smallest data mark
47
+ ALPHA_LEVELS_MAX = 3 # distinct transparency levels in one figure
48
+ INK_MIN, INK_MAX = 0.02, 0.55 # fraction of the axes area carrying data ink
49
+
50
+ # The theme has six categorical slots and the guide's claim is that there is no
51
+ # seventh: a generated hue is indistinguishable from an existing slot under
52
+ # simulated color blindness. Until now that claim was prose only.
53
+ MAX_SERIES_HUES = 6
54
+
55
+ # Ink and furniture from `figure.mplstyle`. These land in `ax.lines` and
56
+ # `ax.patches` beside the data - reference rules, annotation boxes, spine-colored
57
+ # strokes - and none of them carries a categorical identity, so counting them as
58
+ # a data hue would fail figures for the color of their own furniture.
59
+ INK_TOKENS = {"#000000", "#52514e", "#777570", # ink primary/secondary/muted
60
+ "#898781", # muted, pre-4.5:1 spelling
61
+ "#e1e0d9", "#c3c2b7", "#ffffff"} # grid, axis, surface
62
+
63
+ # Two axes are the same frame when their bounds agree this closely. An inset or
64
+ # a colorbar never does; `twinx`/`twiny` always does.
65
+ FRAME_TOL = 1e-3
66
+
67
+ # A label is correctly attributed when its own curve is at least this many times
68
+ # closer than the next nearest curve. A ratio rather than an absolute distance
69
+ # because the judgement the reader makes is comparative: "is this label nearer
70
+ # that curve or this one?"
71
+ LABEL_MARGIN = 2.0
72
+
73
+ # --- text readability --------------------------------------------------------
74
+ # Data ink inside a label's box, as a fraction of that box. Calibrated against
75
+ # a lw=1.6 curve at 150 dpi: crossing a label horizontally is ~14% of the box,
76
+ # a single vertical spike ~4%, and a label on clean ground with gridlines behind
77
+ # it measures 0.
78
+ TEXT_CLUTTER_MAX = 0.03
79
+ # Distance, in 0-255 RGB, at which a pixel stops being explainable as a blend of
80
+ # the label's surface with the figure's furniture. Well above subpixel and gamma
81
+ # noise, well below any two distinguishable hues.
82
+ TEXT_BLEND_TOL = 26.0
83
+ # A mark is an edge; ground is whatever varies slowly. These set where the line
84
+ # between the two falls: how wide a neighbourhood a pixel is compared against,
85
+ # and how far it has to sit from that neighbourhood's average to be a mark. The
86
+ # window is wider than a 1.6pt hairline at 150 dpi (~3px) so a hairline stands
87
+ # clear of its own average, and narrow enough that a viridis ramp does not.
88
+ TEXT_EDGE_WINDOW = 9
89
+ TEXT_EDGE_TOL = 14.0
90
+ # A backdrop color has to cover at least this much of a label's box before it
91
+ # can set the contrast verdict. Without a floor, one stray antialiased pixel on
92
+ # a field decides whether the label is legible.
93
+ TEXT_BACKDROP_MIN_SHARE = 0.10
94
+ # Below this many pixels a text box is a glyph or two and the fractions computed
95
+ # off it are noise.
96
+ TEXT_FOOTPRINT_MIN_PX = 60
97
+ # WCAG 2.1 text thresholds. A glyph stem is thinner than a mark, so text does
98
+ # not get the 3:1 the mark gates use; large text (>=18pt, or >=14pt bold, ON
99
+ # PAGE) does.
100
+ TEXT_CONTRAST_MIN = 4.5
101
+ TEXT_CONTRAST_MIN_LARGE = 3.0
102
+
103
+ # Type floor, in points ON THE PAGE, after the figure is scaled to fit.
104
+ #
105
+ # Stricter than every journal that publishes a number: Nature's floor is 5pt,
106
+ # Science asks 5-7pt for labels and 6-8pt for axes, PNAS 6-8pt with nothing
107
+ # under 2mm printed. Those are the sizes at which a string is still *possible*
108
+ # to read. 7.5 is the size at which it is comfortable, and it is cheap to hold
109
+ # because the fix is nearly always cutting words rather than shrinking type.
110
+ TYPE_FLOOR_PT = 7.5
111
+
112
+ # Stroke floor, in points ON THE PAGE. SIAM's instructions for authors: "lines
113
+ # one point or thicker; thinner lines may break up or disappear."
114
+ LINE_FLOOR_PT = 1.0
115
+
116
+ # SET THIS PER PROJECT: the usable width, in points, of the page the figure
117
+ # lands in. Render one page, place a full-width figure, measure it. Within ~5%
118
+ # is fine, since it only sets the type floor.
119
+ #
120
+ # Set it once and the scale is derived per figure from that figure's own width,
121
+ # which is the part people get wrong: a wide figure shrinks much harder than a
122
+ # narrow one, so the same 8pt label is comfortable in one and illegible in the
123
+ # other.
124
+ #
125
+ # None means "assume scale 1.0" - correct when you author each figure at the
126
+ # width it is actually placed at, which makes points authored equal points
127
+ # printed and this whole calculation disappear.
128
+ CONTENT_WIDTH_PT = None
129
+
130
+ # `\the\textwidth` and `\the\columnwidth`, read out of the class and style files
131
+ # these venues ship. The measure-it-yourself instruction above is the honest
132
+ # general answer and it is also the step people skip, so the common cases are
133
+ # here already. Pass one as `venue=` rather than editing CONTENT_WIDTH_PT.
134
+ #
135
+ # VERIFY BEFORE TRUSTING for anything that matters: put `\the\textwidth` in your
136
+ # own document and read the log. Style files get revised between years, a
137
+ # `geometry` package call in the preamble silently overrides all of this, and a
138
+ # figure certified against the wrong width is certified at the wrong type size.
139
+ VENUE_WIDTH_PT = {
140
+ "neurips": 397.48, # \textwidth, neurips_*.sty (5.5in)
141
+ "iclr": 397.48, # \textwidth, iclr*_conference.sty
142
+ "icml": 487.82, # \textwidth, icml*.sty (two-column page)
143
+ "icml-column": 234.88, # \columnwidth
144
+ "acl": 455.24, # \textwidth, acl.sty (16cm)
145
+ "acl-column": 219.08, # \columnwidth (7.7cm)
146
+ "ieee": 516.0, # \textwidth, IEEEtran
147
+ "ieee-column": 252.0, # \columnwidth, IEEEtran
148
+ "nature": 518.74, # double column, 183mm
149
+ "nature-column": 252.28, # single column, 89mm
150
+ "article-letter": 345.0, # \textwidth, article 10pt letterpaper
151
+ "article-a4": 418.25, # \textwidth, article 10pt a4paper
152
+ }
153
+
154
+
155
+ def content_width_pt(venue=None):
156
+ """The usable page width to measure against, in points."""
157
+ if venue is None:
158
+ return CONTENT_WIDTH_PT
159
+ try:
160
+ return VENUE_WIDTH_PT[venue]
161
+ except KeyError:
162
+ raise KeyError(
163
+ f"unknown venue {venue!r}. Known: "
164
+ f"{', '.join(sorted(VENUE_WIDTH_PT))}. For anything else, put "
165
+ "\\the\\textwidth in the document, read the log, and set "
166
+ "CONTENT_WIDTH_PT to what it says.") from None
167
+
168
+
169
+ def page_scale(fig, placed_frac=1.0, venue=None):
170
+ """Scale from authored inches to points on the page.
171
+
172
+ `placed_frac` is the fraction of the content width the figure is placed at,
173
+ so it reads like the call site: `\\includegraphics[width=0.48\\textwidth]`
174
+ is `placed_frac=0.48`. Without it every figure is measured as if it were
175
+ full width, and a half-width figure is certified at twice the type size it
176
+ actually ships at - which is the wrong direction for a legibility gate to
177
+ be wrong in.
178
+
179
+ `venue` names a row of `VENUE_WIDTH_PT` and overrides `CONTENT_WIDTH_PT` for
180
+ this call, which is the usual way in: the width is a property of the
181
+ document, not of the checkout.
182
+ """
183
+ width = content_width_pt(venue)
184
+ if width is None:
185
+ if placed_frac != 1.0:
186
+ raise ValueError(
187
+ "placed_frac requires a content width. With CONTENT_WIDTH_PT "
188
+ "None and no venue= the checker assumes you authored the "
189
+ "figure at the width it is placed at, which already makes the "
190
+ "scale 1.0; a fractional placement contradicts that. Pass "
191
+ "venue=, set CONTENT_WIDTH_PT, or author at the placed width "
192
+ "and drop placed_frac.")
193
+ return 1.0
194
+ return width * placed_frac / (fig.get_size_inches()[0] * 72)
195
+
196
+
197
+ def _renderer(fig):
198
+ """Return (renderer, canvas). Canvas is an Agg canvas with drawn buffer.
199
+
200
+ Text extents need a renderer that can measure. The SVG canvas cannot, so
201
+ swap in an Agg one when that is what the figure was built under.
202
+ Reused across checks so check_ink does not render a second time."""
203
+ if hasattr(fig.canvas, "get_renderer"):
204
+ fig.canvas.draw()
205
+ return fig.canvas.get_renderer(), fig.canvas
206
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
207
+ canvas = FigureCanvasAgg(fig)
208
+ canvas.draw()
209
+ return canvas.get_renderer(), canvas
210
+
211
+
212
+ def _texts(fig, r):
213
+ """Visible, non-empty Text artists with their window extents.
214
+
215
+ Matched on the concrete class, not on `hasattr(get_text)` - a ContourSet
216
+ also has a `get_text`, with a different signature.
217
+ """
218
+ from matplotlib.text import Text
219
+ out, seen = [], set()
220
+ for t in fig.findobj(match=lambda a: isinstance(a, Text)):
221
+ if id(t) in seen:
222
+ continue
223
+ seen.add(id(t))
224
+ if not t.get_visible() or not str(t.get_text()).strip():
225
+ continue
226
+ try:
227
+ bb = t.get_window_extent(renderer=r)
228
+ except Exception:
229
+ continue
230
+ if bb.width <= 0 or bb.height <= 0:
231
+ continue
232
+ out.append((t, bb))
233
+ return out
234
+
235
+
236
+ def _tick_texts(fig):
237
+ ids = set()
238
+ for ax in fig.axes:
239
+ for t in ax.get_xticklabels() + ax.get_yticklabels():
240
+ ids.add(id(t))
241
+ return ids
242
+
243
+
244
+ def _ghost_ticks(fig):
245
+ """Tick artists that exist on an axes but never reach the page: those on a
246
+ hidden axes (`ax.axis("off")`), and those at locations outside the current
247
+ view. Counting either as clipped text reports a defect that is not there.
248
+ """
249
+ ids = set()
250
+ for ax in fig.axes:
251
+ if not ax.axison:
252
+ ids.update(id(t) for t in
253
+ ax.get_xticklabels() + ax.get_yticklabels())
254
+ continue
255
+ for locs, labels, (lo, hi) in (
256
+ (ax.get_xticks(), ax.get_xticklabels(), ax.get_xlim()),
257
+ (ax.get_yticks(), ax.get_yticklabels(), ax.get_ylim()),
258
+ ):
259
+ lo, hi = min(lo, hi), max(lo, hi)
260
+ for pos, lab in zip(locs, labels):
261
+ if not (lo <= pos <= hi):
262
+ ids.add(id(lab))
263
+ return ids
264
+
265
+
266
+ def _polar_radial_ticks(fig):
267
+ """Radial tick labels on polar axes.
268
+
269
+ matplotlib places these inside the disc, because on a polar plot there is
270
+ no outside to place them in — `set_rlabel_position` moves them to a
271
+ different angle, never off the data. So a curve running under '0.8' is not
272
+ a composition mistake anybody made, and the advice this gate gives ("move
273
+ the label to clear ground") names a move that does not exist.
274
+
275
+ Both clauses, not just clutter. Exempting clutter alone still left the
276
+ contrast clause failing eight of eight ordinary polar plots built on this
277
+ project's own style sheet, at 2.0:1 against a curve the radial axis crosses
278
+ by construction. A gate the bundled sheet cannot satisfy is measuring the
279
+ projection, not the figure.
280
+
281
+ The count is reported rather than dropped, so the author is told these
282
+ strings went unjudged instead of assuming they passed.
283
+ """
284
+ ids = set()
285
+ for ax in fig.axes:
286
+ if getattr(ax, "name", "") == "polar":
287
+ ids.update(id(t) for t in ax.get_yticklabels())
288
+ return ids
289
+
290
+
291
+ def _overlap(a, b):
292
+ dx = min(a.x1, b.x1) - max(a.x0, b.x0)
293
+ dy = min(a.y1, b.y1) - max(a.y0, b.y0)
294
+ return dx > 0 and dy > 0
295
+
296
+
297
+ def check_clipping(fig, r):
298
+ w, h = fig.canvas.get_width_height()
299
+ ghosts = _ghost_ticks(fig)
300
+ bad = []
301
+ for t, bb in _texts(fig, r):
302
+ if id(t) in ghosts:
303
+ continue
304
+ if bb.x0 < -1 or bb.y0 < -1 or bb.x1 > w + 1 or bb.y1 > h + 1:
305
+ bad.append(str(t.get_text())[:32])
306
+ return (not bad,
307
+ "no text past the canvas" if not bad
308
+ else f"clipped: {bad} <- add constrained_layout or widen the figure")
309
+
310
+
311
+ def check_collisions(fig, r):
312
+ """Text-on-text overlap. Tick labels on a shared axis are exempt: matplotlib
313
+ lays those out itself and a 1px touch there is not a defect."""
314
+ ticks = _tick_texts(fig)
315
+ items = [(t, bb) for t, bb in _texts(fig, r) if id(t) not in ticks]
316
+ hits = []
317
+ for (ta, ba), (tb, bb) in itertools.combinations(items, 2):
318
+ if _overlap(ba, bb):
319
+ hits.append((str(ta.get_text())[:22], str(tb.get_text())[:22]))
320
+ return (not hits,
321
+ f"{len(items)} text objects, none overlapping" if not hits
322
+ else f"overlapping: {hits[:4]}")
323
+
324
+
325
+ def _halo(t):
326
+ """(color, linewidth) of a stroke path effect, when the text wears casing.
327
+
328
+ Casing — the cartographic term for a contrasting outline around a label —
329
+ is what lets a label sit over busy ground at all. Reading it off the artist
330
+ rather than guessing from the pixels keeps the two clauses below separable:
331
+ whether the halo *should* have worked, and whether it *did*.
332
+ """
333
+ from matplotlib import patheffects as pe
334
+ for effect in (t.get_path_effects() or ()):
335
+ if not isinstance(effect, pe.Stroke): # withStroke subclasses Stroke
336
+ continue
337
+ gc = getattr(effect, "_gc", {}) or {}
338
+ fg = gc.get("foreground")
339
+ if fg is not None:
340
+ return fg, float(gc.get("linewidth", 0.0) or 0.0)
341
+ return None, 0.0
342
+
343
+
344
+ def _furniture(fig):
345
+ """Colors a label is allowed to sit on, read off the figure that drew them.
346
+
347
+ Casing exists precisely so a gridline can pass behind a label; the axis rule
348
+ and the tick marks are the same kind of thing. Data ink is not furniture,
349
+ which is the whole distinction this list draws.
350
+
351
+ Harvested from the artists rather than from `INK_TOKENS` for two reasons.
352
+ The token set contains the text ink itself, and a black reference line
353
+ crossing a black label is a defect rather than furniture. And a project that
354
+ swapped the style sheet's grid color would otherwise have every gridline in
355
+ every figure counted as data passing through its labels — the check would
356
+ fire hardest on exactly the people who customised it.
357
+ """
358
+ from matplotlib.colors import to_rgb
359
+ out = {tuple(to_rgb(fig.get_facecolor()))}
360
+ for ax in fig.axes:
361
+ out.add(tuple(to_rgb(ax.get_facecolor())))
362
+ for axis in (ax.xaxis, ax.yaxis):
363
+ for line in axis.get_gridlines():
364
+ out.add(tuple(to_rgb(line.get_color())))
365
+ for tick in axis.get_ticklines():
366
+ out.add(tuple(to_rgb(tick.get_color())))
367
+ for spine in ax.spines.values():
368
+ out.add(tuple(to_rgb(spine.get_edgecolor())))
369
+ return [tuple(c * 255.0 for c in rgb) for rgb in out]
370
+
371
+
372
+ def _near_any(pixels, anchors, tol):
373
+ """Which of an (N, 3) block of pixels a blend of `anchors` explains.
374
+
375
+ Antialiasing puts pixels on the straight line between two colors that meet,
376
+ so the test is distance to the nearest *segment* joining a pair of anchors,
377
+ not distance to the nearest anchor. Without that, the halfway pixel where a
378
+ gridline meets the page reads as a third color and every gridline in the
379
+ figure counts as foreign ink.
380
+ """
381
+ import numpy as np
382
+ pix = pixels.astype(float)
383
+ best = np.full(len(pix), np.inf)
384
+ for a, b in itertools.combinations_with_replacement(anchors, 2):
385
+ a, b = np.asarray(a, float), np.asarray(b, float)
386
+ seg = b - a
387
+ span = float(seg @ seg)
388
+ if span < 1.0:
389
+ d = np.linalg.norm(pix - a, axis=1)
390
+ else:
391
+ u = np.clip(((pix - a) @ seg) / span, 0.0, 1.0)
392
+ d = np.linalg.norm(pix - (a + u[:, None] * seg), axis=1)
393
+ np.minimum(best, d, out=best)
394
+ return best <= tol
395
+
396
+
397
+ def _box_blur(field, size):
398
+ """Moving average over an (H, W, 3) block, edges extended.
399
+
400
+ `scipy.ndimage.uniform_filter` does this and is what the first version
401
+ called. Written out in numpy instead so `check_figure.py` stays a file you
402
+ can copy next to your figures with nothing but matplotlib installed —
403
+ the promise in the README is three files and no install, and a hard scipy
404
+ import quietly broke it. Separable and cumulative, so it costs two passes
405
+ regardless of the window.
406
+ """
407
+ import numpy as np
408
+ half = size // 2
409
+ out = field.astype(float)
410
+ for axis in (0, 1):
411
+ padded = np.concatenate(
412
+ [np.repeat(out.take([0], axis=axis), half, axis=axis),
413
+ out,
414
+ np.repeat(out.take([-1], axis=axis), half, axis=axis)],
415
+ axis=axis)
416
+ cum = np.cumsum(padded, axis=axis)
417
+ zero = np.zeros_like(cum.take([0], axis=axis))
418
+ cum = np.concatenate([zero, cum], axis=axis)
419
+ n = out.shape[axis]
420
+ hi = cum.take(range(size, size + n), axis=axis)
421
+ lo = cum.take(range(0, n), axis=axis)
422
+ out = (hi - lo) / size
423
+ return out
424
+
425
+
426
+ def _foreign_ink(block, furniture, tol):
427
+ """Fraction of a backdrop patch that is a mark rather than ground.
428
+
429
+ Ground is whatever varies slowly: the page, a flat fill, a viridis field.
430
+ A mark is an *edge* — a curve, a marker, an isoline — so the test is each
431
+ pixel against a local average of its neighbours rather than against the
432
+ patch's dominant color. Testing against the dominant color was the obvious
433
+ first version and it failed every annotation on a heatmap, because a smooth
434
+ ramp differs from its own mode everywhere while being, to a reader, one
435
+ surface.
436
+
437
+ Furniture is exempt at the second step rather than the first: a gridline IS
438
+ an edge, and casing exists precisely so it can pass behind a label.
439
+ """
440
+ import numpy as np
441
+
442
+ field = block.astype(float)
443
+ local = _box_blur(field, TEXT_EDGE_WINDOW)
444
+ edge = np.linalg.norm(field - local, axis=2) > TEXT_EDGE_TOL
445
+ if not edge.any():
446
+ return 0.0
447
+ pix = field[edge].reshape(-1, 3)
448
+ return float(edge.sum() - _near_any(pix, furniture, tol).sum()) / edge.size
449
+
450
+
451
+ def _worst_backdrop(block, fg, min_share):
452
+ """The backdrop color the text reads worst against, among those covering at
453
+ least `min_share` of its box.
454
+
455
+ A single color is the wrong summary for a field: a label on viridis sits on
456
+ a range, and both the mean and the mode can clear the threshold while a
457
+ third of the box does not. A share floor keeps a handful of stray pixels
458
+ from setting the verdict.
459
+ """
460
+ import numpy as np
461
+ pix = block.reshape(-1, 3)
462
+ keys, counts = np.unique(pix // 8, axis=0, return_counts=True)
463
+ worst, ratio = None, float("inf")
464
+ for key, n in zip(keys, counts):
465
+ if n / len(pix) < min_share:
466
+ continue
467
+ color = pix[((pix // 8) == key).all(axis=1)].mean(axis=0)
468
+ r = _contrast_255(fg, color)
469
+ if r < ratio:
470
+ worst, ratio = color, r
471
+ if worst is None:
472
+ return pix.mean(axis=0), _contrast_255(fg, pix.mean(axis=0))
473
+ return worst, ratio
474
+
475
+
476
+ def check_text_readability(fig, r, canvas=None, scale=None, placed_frac=1.0,
477
+ venue=None):
478
+ """Whether each string can be read where it sits.
479
+
480
+ `check_label_attribution` asks which curve a label belongs to. This asks the
481
+ prior question — whether the label is legible at all — and the two come
482
+ apart hard: a label printed *on* its own curve is attributed perfectly, and
483
+ is read through the line crossing its letterforms.
484
+
485
+ Both clauses are measured off rendered pixels, because both depend on what
486
+ happened to land behind the glyphs and no artist knows that about itself.
487
+ The figure is drawn a second time with every string hidden; that render is
488
+ the backdrop each label was placed onto.
489
+
490
+ *Clutter.* Inside a label's box the backdrop should be one surface. Pixels
491
+ that no blend of {that surface, the grid, the axis rule} explains are data
492
+ ink passing through the text — a curve, a marker, a spike between the
493
+ strokes. Measuring the backdrop rather than the finished render is the whole
494
+ trick: casing hides the evidence, because a white halo over an orange curve
495
+ renders as clean white while punching a visible gap through the data. Both
496
+ halves of that are defects and this sees them as one number.
497
+
498
+ Uniform data ink is not clutter. A label on a heatmap cell has the cell as
499
+ its surface, so it is the contrast clause that governs there, which is the
500
+ correct division: a flat fill is a background, a curve is not.
501
+
502
+ *Contrast.* The text against the backdrop it actually got, at the WCAG text
503
+ threshold (4.5:1, or 3:1 for large text) rather than the 3:1 mark threshold,
504
+ because a glyph stem is thinner than a mark. Casing counts: a black label
505
+ with a white halo on a dark field is read against the halo.
506
+
507
+ Tick labels are included. They sit outside the axes on most figures and cost
508
+ nothing to check there, and on the figures where they do not — an inset, a
509
+ twinned frame, a label moved inside — that is exactly where they get
510
+ crossed.
511
+ """
512
+ import numpy as np
513
+ from matplotlib.colors import to_rgb
514
+
515
+ items = _texts(fig, r)
516
+ if not items:
517
+ return True, "no text to read"
518
+ if canvas is None:
519
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
520
+ canvas = FigureCanvasAgg(fig)
521
+ canvas.draw()
522
+
523
+ # Hiding text changes what constrained_layout has to fit, so the second
524
+ # render would come back with every artist in a slightly different place and
525
+ # the backdrop would not line up with the boxes measured against the first.
526
+ # Pin the layout for the duration; the engine is put back before returning.
527
+ engine = fig.get_layout_engine()
528
+ fig.set_layout_engine("none")
529
+ visible = [t.get_visible() for t, _ in items]
530
+ try:
531
+ for t, _ in items:
532
+ t.set_visible(False)
533
+ canvas.draw()
534
+ backdrop = np.asarray(
535
+ canvas.buffer_rgba())[:, :, :3].astype(np.int16).copy()
536
+ finally:
537
+ for (t, _), v in zip(items, visible):
538
+ t.set_visible(v)
539
+ canvas.draw()
540
+ if engine is not None:
541
+ fig.set_layout_engine(engine)
542
+
543
+ if scale is None:
544
+ scale = page_scale(fig, placed_frac, venue)
545
+ H, W = backdrop.shape[:2]
546
+ furniture = _furniture(fig)
547
+ # Ticks that exist on the axes but never reach the page — a hidden axes, a
548
+ # location outside the view. `check_clipping` learned about these the same
549
+ # way this did: by reporting a defect on a schematic that draws no axes and
550
+ # still carries the tick Text objects matplotlib made for it.
551
+ ghosts = _ghost_ticks(fig)
552
+ radial = _polar_radial_ticks(fig)
553
+ cluttered, faint, checked, unjudged = [], [], 0, 0
554
+
555
+ for t, bb in items:
556
+ if id(t) in ghosts:
557
+ continue
558
+ if id(t) in radial:
559
+ unjudged += 1
560
+ continue
561
+ xa, xb = max(int(bb.x0) - 1, 0), min(int(bb.x1) + 2, W)
562
+ ya, yb = max(int(bb.y0) - 1, 0), min(int(bb.y1) + 2, H)
563
+ # Agg's origin is top-left, the figure's is bottom-left
564
+ block = backdrop[slice(H - yb, H - ya), slice(xa, xb)]
565
+ # Below this the fractions are counting antialiasing, not measuring.
566
+ if block.size // 3 < TEXT_FOOTPRINT_MIN_PX:
567
+ continue
568
+ checked += 1
569
+
570
+ fg = np.array(to_rgb(t.get_color())) * 255.0
571
+ halo_color, _ = _halo(t)
572
+ name = str(t.get_text())[:22]
573
+
574
+ frac = _foreign_ink(block, furniture, TEXT_BLEND_TOL)
575
+ if frac > TEXT_CLUTTER_MAX:
576
+ cluttered.append(
577
+ f"{name!r} sits on data ink over {frac:.0%} of its box"
578
+ + (" — the casing hides it by erasing the data underneath"
579
+ if halo_color else " and wears no casing"))
580
+
581
+ pt = t.get_fontsize() * scale
582
+ weight = t.get_fontweight()
583
+ bold = (weight in ("bold", "heavy", "black", "extra bold", "semibold")
584
+ or (isinstance(weight, (int, float)) and weight >= 600))
585
+ floor = (TEXT_CONTRAST_MIN_LARGE
586
+ if pt >= 18.0 or (pt >= 14.0 and bold) else TEXT_CONTRAST_MIN)
587
+ if halo_color:
588
+ # Casing replaces the backdrop under the strokes, so that is what
589
+ # the reader reads against.
590
+ ratio = _contrast_255(fg, np.array(to_rgb(halo_color)) * 255.0)
591
+ else:
592
+ _, ratio = _worst_backdrop(block, fg, TEXT_BACKDROP_MIN_SHARE)
593
+ if ratio < floor:
594
+ faint.append(f"{name!r} at {ratio:.1f}:1 on its backdrop "
595
+ f"(text needs {floor}:1)")
596
+
597
+ skipped = (f"; {unjudged} polar radial labels not judged (matplotlib "
598
+ "places them on the data and offers nowhere else to put them)"
599
+ if unjudged else "")
600
+ if not checked:
601
+ return True, "no text large enough to measure" + skipped
602
+ bad = cluttered + faint
603
+ if not bad:
604
+ return True, (f"{checked} strings read clean against their backdrop"
605
+ + skipped)
606
+ return False, ("; ".join(bad[:3])
607
+ + " <- move the label to clear ground; casing rescues a "
608
+ "gridline, not a curve")
609
+
610
+
611
+ def _contrast_255(rgb_a, rgb_b):
612
+ """WCAG contrast for two 0-255 RGB triples.
613
+
614
+ Duplicated from `check_palette.contrast` rather than imported: this file
615
+ already treats that import as optional (`check_series_color` degrades to a
616
+ note when it is missing), and a legibility gate that silently stops running
617
+ when a sibling file is absent is the kind of decoration this repo exists to
618
+ argue against.
619
+ """
620
+ def lin(c):
621
+ c = c / 255.0
622
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
623
+
624
+ lums = []
625
+ for rgb in (rgb_a, rgb_b):
626
+ r, g, b = (lin(float(v)) for v in rgb)
627
+ lums.append(0.2126 * r + 0.7152 * g + 0.0722 * b)
628
+ hi, lo = max(lums), min(lums)
629
+ return (hi + 0.05) / (lo + 0.05)
630
+
631
+
632
+ def check_contrast_stack(fig):
633
+ """A figure where nothing is at full opacity has no focal point, and a long
634
+ tail of alpha values reads as haze rather than hierarchy."""
635
+ alphas = []
636
+ for ax in fig.axes:
637
+ for a in list(ax.collections) + list(ax.lines) + list(ax.patches):
638
+ if not a.get_visible():
639
+ continue
640
+ al = a.get_alpha()
641
+ # unset alpha means opaque, and that is exactly what this check
642
+ # wants to know about, so it counts as 1.0 rather than being skipped
643
+ alphas.append(1.0 if al is None else round(float(al), 2))
644
+ if not alphas:
645
+ return True, "no data artists"
646
+ levels = sorted(set(alphas))
647
+ solid = any(x >= 0.99 for x in alphas)
648
+ ok = len(levels) <= ALPHA_LEVELS_MAX and solid
649
+ note = ""
650
+ if not solid:
651
+ note = " <- nothing is opaque, so the figure has no focal point"
652
+ elif len(levels) > ALPHA_LEVELS_MAX:
653
+ note = f" <- {len(levels)} levels reads as haze; keep to {ALPHA_LEVELS_MAX}"
654
+ return ok, f"alpha levels {levels}{note}"
655
+
656
+
657
+ def check_mark_ratio(fig):
658
+ """One mark far larger than the rest stops reading as a mark and starts
659
+ reading as an ornament stuck on top of the plot.
660
+
661
+ Reads scatter sizes (already an area in pt^2) and line markers (a diameter
662
+ in points, so squared to match). Bars and other patches are deliberately
663
+ NOT counted: a bar thirty times another bar is the encoding working, not a
664
+ defect. This gate is about marks whose size is not carrying the value.
665
+ """
666
+ worst = None
667
+ for ax in fig.axes:
668
+ sizes = []
669
+ for c in ax.collections:
670
+ s = getattr(c, "get_sizes", lambda: [])()
671
+ sizes.extend(float(v) for v in s if v > 0)
672
+ for ln in ax.lines:
673
+ if not ln.get_visible() or ln.get_marker() in ("", "None", None):
674
+ continue
675
+ ms = float(ln.get_markersize())
676
+ if ms > 0:
677
+ sizes.append(ms ** 2)
678
+ if len(sizes) < 2:
679
+ continue
680
+ ratio = max(sizes) / min(sizes)
681
+ if worst is None or ratio > worst[0]:
682
+ worst = (ratio, min(sizes), max(sizes))
683
+ if worst is None:
684
+ return True, "fewer than two mark sizes"
685
+ ratio, lo, hi = worst
686
+ return (ratio <= MARK_RATIO_MAX,
687
+ f"largest/smallest mark area {ratio:.1f}x (s={lo:.0f} to {hi:.0f})"
688
+ + ("" if ratio <= MARK_RATIO_MAX
689
+ else f" <- cap at {MARK_RATIO_MAX}x"))
690
+
691
+
692
+ def check_overplotting(fig):
693
+ """Warn when scatter points overlap into an unreadable mass.
694
+
695
+ For each PathCollection with offsets (a scatter), estimates the fraction of
696
+ points whose nearest neighbour in display pixels is within one marker
697
+ radius. Above the threshold the marks merge into a blob — thin the count,
698
+ use hollow markers, add transparency, or switch to hexbin.
699
+ """
700
+ import numpy as np
701
+ try:
702
+ from scipy.spatial import cKDTree
703
+ except ImportError: # optional, see `_box_blur`
704
+ cKDTree = None
705
+
706
+ OVERPLOT_THRESHOLD = 0.5
707
+ dpi = fig.dpi
708
+
709
+ bad = []
710
+ for i, ax in enumerate(fig.axes):
711
+ for j, coll in enumerate(ax.collections):
712
+ try:
713
+ offsets = coll.get_offsets()
714
+ except Exception:
715
+ continue
716
+ if offsets.size < 2:
717
+ continue
718
+ try:
719
+ xy = ax.transData.transform(offsets)
720
+ except Exception:
721
+ continue
722
+ sizes = getattr(coll, "get_sizes", lambda: [])()
723
+ if len(sizes) == 0:
724
+ continue
725
+ sizes = np.asarray(sizes, dtype=float)
726
+ if sizes.size == 1:
727
+ sizes = np.full(len(offsets), sizes[0])
728
+ radius_px = np.sqrt(sizes / np.pi) * dpi / 72.0
729
+
730
+ n = len(xy)
731
+ if n < 2:
732
+ continue
733
+ if cKDTree is not None:
734
+ dists, _ = cKDTree(xy).query(xy, k=2)
735
+ if dists.ndim < 2:
736
+ continue
737
+ nn_dist = dists[:, 1]
738
+ else:
739
+ # Same nearest-neighbour distance, O(n^2). Only reached where
740
+ # scipy is absent, and only for the scatters this check looks
741
+ # at, so the cost lands on figures that already draw n points.
742
+ d = np.hypot(xy[:, 0][:, None] - xy[None, :, 0],
743
+ xy[:, 1][:, None] - xy[None, :, 1])
744
+ np.fill_diagonal(d, np.inf)
745
+ nn_dist = d.min(axis=1)
746
+ overlap = int((nn_dist < radius_px).sum())
747
+ frac = overlap / n
748
+ if frac > OVERPLOT_THRESHOLD:
749
+ bad.append((i, j, frac))
750
+
751
+ if not bad:
752
+ return True, "no scatter overplotting"
753
+ detail = "; ".join(f"ax{i}.col{j} {f:.0%}" for i, j, f in bad)
754
+ return "warn", (f"overplotting: {detail} — marks merge into blob; "
755
+ "thin counts, use hollow markers, add transparency, "
756
+ "or switch to hexbin")
757
+
758
+
759
+ def check_redundancy(fig, r):
760
+ """Side-by-side panels on the same scale should share their axis furniture.
761
+ Two identical tick columns and two identical axis labels is duplicated ink."""
762
+ # Only same-row panels can share a y axis, and only same-column panels can
763
+ # share an x axis. Two panels side by side each legitimately need their own
764
+ # x label; repeating the y label between them is the duplication.
765
+ rows, cols = {}, {}
766
+ for ax in fig.axes:
767
+ ss = ax.get_subplotspec()
768
+ if ss is None:
769
+ continue
770
+ # A panel at `axis("off")` shows no furniture to duplicate. Its tick
771
+ # Text objects still exist and still carry their strings, which is how
772
+ # three image panels with no visible axis at all came to be told to
773
+ # "use sharex/sharey" — advice with nothing to act on.
774
+ if not ax.axison:
775
+ continue
776
+ r_, c_ = ss.rowspan.start, ss.colspan.start
777
+ rows.setdefault(r_, []).append(ax)
778
+ cols.setdefault(c_, []).append(ax)
779
+
780
+ dupes = []
781
+ for group, getter, axis in ((rows, "get_ylabel", "y"),
782
+ (cols, "get_xlabel", "x")):
783
+ for _, axes in group.items():
784
+ vals = [getattr(a, getter)().strip() for a in axes]
785
+ vals = [v for v in vals if v]
786
+ for label, n in Counter(vals).items():
787
+ if n > 1:
788
+ dupes.append(f"{axis}label {label!r} x{n}")
789
+
790
+ dup_ticks = 0
791
+ for _, axes in rows.items():
792
+ cols_seen = Counter(
793
+ tuple(t.get_text() for t in a.get_yticklabels()
794
+ if t.get_text() and t.get_visible())
795
+ for a in axes)
796
+ dup_ticks += sum(n - 1 for v, n in cols_seen.items() if v and n > 1)
797
+
798
+ ok = not dupes and not dup_ticks
799
+ if ok:
800
+ return True, "axis furniture not duplicated"
801
+ bits = dupes + ([f"repeated y tick column x{dup_ticks}"] if dup_ticks else [])
802
+ return False, "; ".join(bits) + " <- use sharex/sharey"
803
+
804
+
805
+ def check_type_size(fig, r, scale=None, placed_frac=1.0, venue=None):
806
+ """Every rendered string clears the legibility floor once the figure is
807
+ scaled into the document.
808
+
809
+ This used to be a regex over the source file hunting for `fontsize=`, which
810
+ missed anything set through rcParams, anything computed, and anything set by
811
+ a helper. Reading `get_fontsize()` off the artists that actually rendered
812
+ reports what is on the page instead of what is in the source.
813
+ """
814
+ scale = page_scale(fig, placed_frac, venue) if scale is None else scale
815
+ ghosts = _ghost_ticks(fig)
816
+ sizes = [(round(float(t.get_fontsize()) * scale, 1), str(t.get_text())[:22])
817
+ for t, _ in _texts(fig, r) if id(t) not in ghosts]
818
+ if not sizes:
819
+ return True, "no text"
820
+ small = sorted({(pt, s) for pt, s in sizes if pt < TYPE_FLOOR_PT})
821
+ mn = min(pt for pt, _ in sizes)
822
+ if not small:
823
+ detail = f"smallest {mn:.1f}pt on page (floor {TYPE_FLOOR_PT})"
824
+ if placed_frac < 0.35:
825
+ return "warn", (f"{detail}; placed at {placed_frac:.0%} of content width"
826
+ " — labels may be too small to read; author at the"
827
+ " width it ships at")
828
+ return True, detail
829
+ return False, (f"under {TYPE_FLOOR_PT}pt on page at scale {scale}: {small[:4]}"
830
+ " <- cut words, do not shrink type")
831
+
832
+
833
+ def _axes_drew_anything(ax):
834
+ """Whether anything was drawn into this axes.
835
+
836
+ Not `_has_data`, which is a different question defined further down this
837
+ file: that one asks whether an axes carries *data*, which is what the
838
+ dual-axis gate needs. This asks whether the panel was used at all, so a
839
+ table or a lone annotation counts. The two names are close enough that one
840
+ shadowed the other once; keep them apart.
841
+
842
+ Every container matplotlib puts drawn content in: a blank axes has all of
843
+ them at zero, and a panel holding only an annotation or only a bar still
844
+ reports through one of them. `ax.patch` is the background and is
845
+ deliberately not consulted — it exists on the blank axes too.
846
+ """
847
+ return any(len(getattr(ax, name, ())) for name in
848
+ ("lines", "collections", "patches", "images", "texts",
849
+ "tables", "artists"))
850
+
851
+
852
+ def check_ink(fig, context_axes=None, canvas=None):
853
+ """Ink as a fraction of each plotting area, measured off the rendered
854
+ pixels rather than estimated from artist properties.
855
+
856
+ Near zero means a panel that did not need to be a panel. Very high means a
857
+ panel with no ground left in it. Reported per-axes, and advisory only: the
858
+ right density genuinely depends on the form, so this flags panels worth a
859
+ second look rather than declaring them wrong.
860
+
861
+ Pass `context_axes` — a list of Axes whose fill is a context surface (e.g. a
862
+ contourf landscape) rather than data-ink. For those axes, the ink fraction
863
+ measures only marks ON TOP of the surface by separating the pixel values
864
+ into two clusters (k-means with k=2) and removing the larger cluster (the
865
+ surface). A figure with a filled terrain plus a few sparse marks will PASS
866
+ rather than WARN.
867
+
868
+ Pass `canvas` — an already-drawn Agg canvas — to avoid a second render.
869
+ """
870
+ import numpy as np
871
+
872
+ if canvas is None:
873
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
874
+ canvas = FigureCanvasAgg(fig)
875
+ canvas.draw()
876
+ buf = np.asarray(canvas.buffer_rgba())[:, :, :3].astype(int)
877
+ h = buf.shape[0]
878
+ bg = buf[0, 0]
879
+ # anything more than a few levels off the page color counts as ink
880
+ ink_mask = (np.abs(buf - bg).sum(axis=2) > 24)
881
+
882
+ if context_axes is None:
883
+ context_axes = []
884
+ context_ids = frozenset(id(ax) for ax in context_axes)
885
+
886
+ rows = []
887
+ for i, ax in enumerate(fig.axes):
888
+ # A colorbar is a solid ramp by construction: 100% ink, always, on
889
+ # every figure that has one. Measuring it means every heatmap in the
890
+ # world stands at WARN for the one axes in it whose density is not a
891
+ # choice anybody made. matplotlib labels the axes it creates.
892
+ if ax.get_label() == "<colorbar>":
893
+ continue
894
+ bb = ax.get_window_extent(renderer=canvas.get_renderer())
895
+ x0, x1 = int(max(bb.x0, 0)), int(min(bb.x1, buf.shape[1]))
896
+ y0, y1 = int(max(bb.y0, 0)), int(min(bb.y1, h))
897
+ # Agg's origin is top-left, the figure's is bottom-left
898
+ sub = ink_mask[h - y1:h - y0, x0:x1]
899
+ if sub.size == 0:
900
+ continue
901
+
902
+ if id(ax) in context_ids:
903
+ # Separate surface pixels from mark pixels via 2-means on color.
904
+ sub_buf = buf[h - y1:h - y0, x0:x1].astype(float)
905
+ flat = sub_buf.reshape(-1, 3)
906
+ m1 = flat.mean(axis=0)
907
+ # init second centroid offset so they diverge
908
+ m2 = m1 + 30.0
909
+ for _ in range(12):
910
+ d1 = np.abs(flat - m1).sum(axis=1)
911
+ d2 = np.abs(flat - m2).sum(axis=1)
912
+ c1 = d1 <= d2
913
+ c2 = ~c1
914
+ if c1.sum() == 0 or c2.sum() == 0:
915
+ break
916
+ nm1 = flat[c1].mean(axis=0)
917
+ nm2 = flat[c2].mean(axis=0)
918
+ if (np.abs(nm1 - m1).sum() < 0.5
919
+ and np.abs(nm2 - m2).sum() < 0.5):
920
+ break
921
+ m1, m2 = nm1, nm2
922
+ surf = c1 if c1.sum() > c2.sum() else c2
923
+ surf_mask = surf.reshape(sub.shape)
924
+ # Ink = pixels in the ink_mask AND not in the surface cluster
925
+ frac = float((sub & ~surf_mask).mean())
926
+ else:
927
+ frac = float(sub.mean())
928
+ # An empty panel is structural, not a low number. The frame and the
929
+ # tick marks of a blank axes measure about 0.03 on their own, over the
930
+ # 0.02 floor, so the blank subplot in a grid — the case that actually
931
+ # ships — read as merely sparse. Ask whether anything was drawn.
932
+ rows.append((i, frac,
933
+ _axes_drew_anything(ax) and INK_MIN <= frac <= INK_MAX))
934
+
935
+ if not rows:
936
+ return True, "no measurable axes"
937
+ detail = ", ".join(f"ax{i} {f:.2f}" for i, f, _ in rows)
938
+ odd = [i for i, _, g in rows if not g]
939
+ if not odd:
940
+ return True, f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
941
+ return "warn", (f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
942
+ f" - look at ax{odd}: empty panels and saturated ones both"
943
+ " read badly, though a heatmap legitimately runs high")
944
+
945
+
946
+ # --- color ------------------------------------------------------------------
947
+
948
+ def _hex(c):
949
+ """A drawn color as lowercase 6-digit hex, or None if it never reaches the
950
+ page. Fully transparent is the case that matters: a bar's default edge color
951
+ is `none`, and reading it as black would put the ink token on every bar."""
952
+ from matplotlib.colors import to_hex, to_rgba
953
+ try:
954
+ r, g, b, a = to_rgba(c)
955
+ except (ValueError, TypeError):
956
+ return None
957
+ return None if a == 0 else to_hex((r, g, b))
958
+
959
+
960
+ def _colors_of(value):
961
+ """Normalize the several shapes matplotlib returns a color in: a string from
962
+ `Line2D`, an RGBA tuple from `Patch`, an Nx4 array from a `Collection`."""
963
+ if isinstance(value, str):
964
+ return [value]
965
+ import numpy as np
966
+ try:
967
+ arr = np.asarray(value, dtype=float)
968
+ except (ValueError, TypeError):
969
+ return []
970
+ if arr.ndim == 1:
971
+ return [tuple(arr)] if arr.size in (3, 4) else []
972
+ if arr.ndim == 2 and arr.shape[1] in (3, 4):
973
+ return [tuple(row) for row in arr]
974
+ return []
975
+
976
+
977
+ def _artist_kind(artist):
978
+ """A coarse artist family: a line, a scatter's marks, or a filled area.
979
+
980
+ The wrap check needs it. A cycler that wraps reuses one artist *type* - two
981
+ lines, two scatters - and hands them different labels. One series shown as a
982
+ band and its mean line and its points is three types in one hue, which is
983
+ the opposite: one identity, several artists. Kind tells them apart."""
984
+ from matplotlib.lines import Line2D
985
+ from matplotlib.collections import PathCollection
986
+ if isinstance(artist, Line2D):
987
+ # `plot(..., linestyle="none", marker="o")` is a Line2D that draws no
988
+ # line. Calling it one made a path and its own start marker, in one
989
+ # hue, read as a wrapped cycler handing two identities to one color —
990
+ # which is the exact case this function exists to tell apart.
991
+ stroke = str(artist.get_linestyle()).strip().lower()
992
+ drawn = stroke not in ("none", "", " ") and artist.get_linewidth() > 0
993
+ return "line" if drawn else "marks"
994
+ if isinstance(artist, PathCollection):
995
+ return "marks"
996
+ return "area" # bars, fill_between polys, other patches
997
+
998
+
999
+ def _data_colors_by_axes(fig):
1000
+ """`{axes: [(hex, label, kind), ...]}` - the categorical colors on the
1001
+ figure, kept apart by the panel they were drawn in and tagged with the kind
1002
+ of artist that drew them. `label` is None for an artist matplotlib named
1003
+ itself.
1004
+
1005
+ Structure the old figure-wide bag threw away. The panel is the unit a reader
1006
+ compares a hue within: two hues in different panels never sit side by side,
1007
+ so pooling every axes into one set gated a flow-chart node in one panel
1008
+ against a regression curve in another. The kind lets one identity drawn as
1009
+ several artist types in one hue read as one series, not a wrapped cycler.
1010
+
1011
+ Harvested narrowly on purpose. Two exclusions do the work:
1012
+
1013
+ - an artist with a colormap attached (`get_array()` is not None) encodes a
1014
+ *value*, not an identity - a heatmap or a scatter colored by magnitude is
1015
+ a continuous encoding and answers to the viridis rule instead. This is
1016
+ also the escape hatch for an ordinal ramp: draw it `c=values, cmap=...`,
1017
+ never as a pre-evaluated RGBA list, and it is read as the value it is.
1018
+ - the ink tokens, per `INK_TOKENS` above
1019
+ """
1020
+ out = {}
1021
+ for ax in fig.axes:
1022
+ items = []
1023
+ for artist in list(ax.lines) + list(ax.patches) + list(ax.collections):
1024
+ if not artist.get_visible():
1025
+ continue
1026
+ if getattr(artist, "get_array", lambda: None)() is not None:
1027
+ continue
1028
+ raw = str(artist.get_label() or "")
1029
+ label = raw if raw and not raw.startswith("_") else None
1030
+ kind = _artist_kind(artist)
1031
+ seen = set()
1032
+ for getter in ("get_color", "get_facecolor", "get_edgecolor"):
1033
+ fn = getattr(artist, getter, None)
1034
+ if fn is None:
1035
+ continue
1036
+ try:
1037
+ value = fn()
1038
+ except (ValueError, TypeError, AttributeError):
1039
+ continue
1040
+ for c in _colors_of(value):
1041
+ h = _hex(c)
1042
+ if h and h not in INK_TOKENS and h not in seen:
1043
+ seen.add(h)
1044
+ items.append((h, label, kind))
1045
+ if items:
1046
+ out[ax] = items
1047
+ return out
1048
+
1049
+
1050
+ def _data_colors(fig):
1051
+ """Flat `(hex, label)` across the whole figure, for the checks that only
1052
+ need the bag of identified hues and not the panel each lives in."""
1053
+ return [(h, label) for items in _data_colors_by_axes(fig).values()
1054
+ for h, label, _ in items]
1055
+
1056
+
1057
+ def _axes_all_pairs(ax):
1058
+ """Which separation mode a *panel* needs. `check_palette.py` has to ask for
1059
+ this on the command line because a list of hexes does not say what it will be
1060
+ drawn as; a built figure does say. Scatter puts every series next to every
1061
+ other, so every pair has to separate. Lines and bars only ever put
1062
+ neighbours next to each other, and gating all pairs there would fail
1063
+ palettes the guide explicitly sanctions.
1064
+
1065
+ Asked per axes, not per figure: a scatter in one panel does not make a
1066
+ line-only panel two panels over answer to the stricter rule."""
1067
+ from matplotlib.collections import PathCollection
1068
+ return any(isinstance(c, PathCollection) for c in ax.collections)
1069
+
1070
+
1071
+ def check_series_color(fig):
1072
+ """The hues actually drawn, put through the palette gates.
1073
+
1074
+ The hole this closes: `check_palette.py` judges a list of hexes someone
1075
+ remembered to paste into a terminal, and this file never looked at color at
1076
+ all. A figure on matplotlib's default `tab10` cycle - whose orange and green
1077
+ measure OKLab dE 1.4 under protanopia - passed every composition check
1078
+ clean. Two scripts in one project that never spoke.
1079
+
1080
+ Only what is never legitimate is gated: separation under color blindness,
1081
+ and separation in normal vision. The lightness-band and chroma-floor rows
1082
+ are deliberately *not* applied to harvested colors. A black or gray series
1083
+ is legal - a reference curve, a control group - and failing it is precisely
1084
+ the noise that teaches people to skim past the row.
1085
+
1086
+ Scoped per panel. The comparison, the hue count and the all-pairs mode are
1087
+ all asked of one axes at a time, because the panel is the unit a reader
1088
+ separates hues within - a figure-wide bag gated hues that never share a
1089
+ frame against each other.
1090
+ """
1091
+ by_ax = _data_colors_by_axes(fig)
1092
+ if not by_ax:
1093
+ return True, "no categorical series colors"
1094
+
1095
+ fails, notes = [], []
1096
+ cp = None
1097
+ try:
1098
+ import check_palette as cp
1099
+ except ImportError:
1100
+ notes.append("check_palette.py is not importable beside this file, "
1101
+ "so separation went unchecked")
1102
+
1103
+ for ax, items in by_ax.items():
1104
+ distinct = list(dict.fromkeys(h for h, _, _ in items))
1105
+
1106
+ if len(distinct) > MAX_SERIES_HUES:
1107
+ fails.append(f"{len(distinct)} distinct data hues in one panel, "
1108
+ f"theme has {MAX_SERIES_HUES} <- fold the tail into "
1109
+ "'Other' or facet")
1110
+
1111
+ # One hue carrying two identities is what a seventh series looks like
1112
+ # once the cycler wraps: matplotlib reuses slot 1 without complaint and
1113
+ # the legend confidently lists both. Narrowed to labels on artists of the
1114
+ # *same kind*: a wrap reuses one artist type, whereas a band, its mean
1115
+ # line and its points in one hue is one series shown three ways, each
1116
+ # legitimately labelled. Keyed on kind, that reads as one identity.
1117
+ by_hue_kind = {}
1118
+ for h, label, kind in items:
1119
+ if label:
1120
+ by_hue_kind.setdefault((h, kind), set()).add(label)
1121
+ for (h, kind), labels in sorted(by_hue_kind.items()):
1122
+ if len(labels) > 1:
1123
+ fails.append(f"{h} carries {len(labels)} identities "
1124
+ f"{sorted(labels)} on {kind} artists"
1125
+ " <- the color cycle wrapped")
1126
+
1127
+ if len(distinct) >= 2 and cp is not None:
1128
+ rows, _ = cp.check(distinct, all_pairs=_axes_all_pairs(ax))
1129
+ for name, status, detail in rows:
1130
+ if not name.startswith(("CVD separation", "Normal-vision floor")):
1131
+ continue
1132
+ if status is False:
1133
+ fails.append(detail)
1134
+ else:
1135
+ notes.append(detail.split(" <-")[0].strip())
1136
+
1137
+ max_per_panel = max(
1138
+ (len(list(dict.fromkeys(h for h, _, _ in items)))
1139
+ for items in by_ax.values()), default=0)
1140
+ head = f"up to {max_per_panel} data hues per panel"
1141
+ if fails:
1142
+ return False, f"{head}: " + "; ".join(fails)
1143
+ return True, f"{head}: " + ("; ".join(notes) if notes else "nothing to compare")
1144
+
1145
+
1146
+ # --- structure --------------------------------------------------------------
1147
+
1148
+ def _has_data(ax):
1149
+ return any(a.get_visible() for a in
1150
+ list(ax.lines) + list(ax.patches)
1151
+ + list(ax.collections) + list(ax.images))
1152
+
1153
+
1154
+ def check_dual_axis(fig):
1155
+ """Two y scales in one frame, which nothing in this project banned and a
1156
+ `twinx` figure sailed straight through.
1157
+
1158
+ Both scales are set by the author, so the crossing point of the two curves
1159
+ is an artifact of the limits chosen rather than anything in the data. Move
1160
+ the limits and the story changes; a reader cannot tell that from the figure.
1161
+
1162
+ The escape hatch is the one legitimate case: a *pure unit relabel* - degrees
1163
+ C against degrees F, eV against nm - where the twin is furniture and carries
1164
+ no data of its own. So the discriminator is data on both, not a shared
1165
+ frame, which keeps the gate off `secondary_yaxis` and off correct work.
1166
+ """
1167
+ pairs = []
1168
+ for i, j in itertools.combinations(range(len(fig.axes)), 2):
1169
+ a, b = fig.axes[i], fig.axes[j]
1170
+ if any(abs(x - y) > FRAME_TOL for x, y in
1171
+ zip(a.get_position().bounds, b.get_position().bounds)):
1172
+ continue
1173
+ if _has_data(a) and _has_data(b):
1174
+ pairs.append(f"ax{i}+ax{j}")
1175
+ if not pairs:
1176
+ return True, "one data scale per frame"
1177
+ return False, (f"two y scales sharing a frame: {', '.join(pairs)} <- the "
1178
+ "crossing point is set by the limits, not the data. Two "
1179
+ "panels, small multiples, or index both to a common base")
1180
+
1181
+
1182
+ def check_form(fig):
1183
+ """The mechanical subset of form choice - the three cases where the form is
1184
+ wrong no matter what the data is. `references/choosing-a-form.md` carries
1185
+ the judgement calls this cannot make.
1186
+ """
1187
+ from matplotlib.container import BarContainer
1188
+ from matplotlib.patches import Wedge
1189
+
1190
+ bad = []
1191
+ for i, ax in enumerate(fig.axes):
1192
+ if any(isinstance(p, Wedge) for p in ax.patches):
1193
+ bad.append(f"ax{i} pie/donut: angle and area are the two tasks the "
1194
+ "eye judges worst - a dot plot or a bar reads as position")
1195
+ if hasattr(ax, "get_zlim"):
1196
+ bad.append(f"ax{i} 3D: perspective makes the encoding unreadable and "
1197
+ "occludes data - facet or use color for the third variable")
1198
+ for con in getattr(ax, "containers", []):
1199
+ if not isinstance(con, BarContainer):
1200
+ continue
1201
+ vertical = getattr(con, "orientation", "vertical") == "vertical"
1202
+ lim = ax.get_ylim() if vertical else ax.get_xlim()
1203
+ scale = ax.get_yscale() if vertical else ax.get_xscale()
1204
+ # A log axis cannot include zero, so a log bar chart is truncated by
1205
+ # construction and this gate has nothing to say about it.
1206
+ if scale == "linear" and min(lim) > 0:
1207
+ axis = "y" if vertical else "x"
1208
+ bad.append(
1209
+ f"ax{i} bars on a truncated {axis} axis (starts at "
1210
+ f"{min(lim):.4g}): bar length encodes the value, so a "
1211
+ "cut baseline misstates every ratio <- the fix is the "
1212
+ "form, not the axis - use a dot plot")
1213
+ break
1214
+ if not bad:
1215
+ return True, "no pie, no 3D, no truncated bar baseline"
1216
+ return False, "; ".join(bad)
1217
+
1218
+
1219
+ def check_identity_channel(fig):
1220
+ """Identity carried by color and nothing else.
1221
+
1222
+ A warning rather than a gate, and the reason is honesty about what the
1223
+ script can see: it can count the hues, but it cannot tell a direct label
1224
+ from any other piece of text in the axes. Failing on that guess would fire
1225
+ on correct work, and a gate people learn to skip is worse than no gate.
1226
+ """
1227
+ labeled = {h for h, label in _data_colors(fig) if label}
1228
+ if len(labeled) < 2:
1229
+ return True, "fewer than two identified series"
1230
+ if fig.legends or any(ax.get_legend() is not None for ax in fig.axes):
1231
+ return True, f"{len(labeled)} series, legend present"
1232
+ if any(ax.texts for ax in fig.axes):
1233
+ return True, f"{len(labeled)} series, in-axes text (assumed direct labels)"
1234
+ return "warn", (f"{len(labeled)} series told apart by hue alone - no legend "
1235
+ "and no text in the axes. Direct labels beat a legend here: "
1236
+ "they remove the match-the-swatch step, and orange and sky "
1237
+ "blue are under 3:1 on white, where that step is hardest")
1238
+
1239
+
1240
+ def _polyline_px(line, ax):
1241
+ """A line's vertices in display space, densified so no gap exceeds 2px.
1242
+
1243
+ Vertices alone are not enough on a sparsely sampled line: two points 200px
1244
+ apart say nothing about the stroke between them, and that stroke is what the
1245
+ label actually lands next to.
1246
+ """
1247
+ import numpy as np
1248
+ xy = np.asarray(line.get_xydata(), dtype=float)
1249
+ if xy.ndim != 2 or len(xy) == 0:
1250
+ return None
1251
+ pts = ax.transData.transform(xy)
1252
+ pts = pts[np.isfinite(pts).all(axis=1)]
1253
+ if len(pts) < 2:
1254
+ return pts if len(pts) else None
1255
+ step = pts[1:] - pts[:-1]
1256
+ counts = np.maximum(1, np.ceil(np.hypot(step[:, 0], step[:, 1]) / 2.0)
1257
+ ).astype(int)
1258
+ out = [p + (np.arange(k) / k)[:, None] * d
1259
+ for p, d, k in zip(pts[:-1], step, counts)]
1260
+ out.append(pts[-1:])
1261
+ return np.vstack(out)
1262
+
1263
+
1264
+ def _legend_text_ids(fig):
1265
+ """Ids of every Text that belongs to a legend, figure-level or axes-level.
1266
+
1267
+ A legend is a lookup key placed *away* from the curves by design, so its
1268
+ entries can never pass a proximity check - and they slip past the `t.axes is
1269
+ ax` guard, because a legend child's `.axes` resolves to the parent axes. The
1270
+ attribution check has to drop them explicitly or it fails every figure that
1271
+ keeps a legend."""
1272
+ ids = set()
1273
+ legends = list(fig.legends)
1274
+ for ax in fig.axes:
1275
+ lg = ax.get_legend()
1276
+ if lg is not None:
1277
+ legends.append(lg)
1278
+ for lg in legends:
1279
+ for t in lg.get_texts():
1280
+ ids.add(id(t))
1281
+ return ids
1282
+
1283
+
1284
+ def _box_distance(bb, pts):
1285
+ """Shortest distance from a text's box to any point on a polyline, in px.
1286
+ Zero when the stroke passes under the text."""
1287
+ import numpy as np
1288
+ if len(pts) == 0:
1289
+ return float('inf')
1290
+ dx = np.maximum.reduce([bb.x0 - pts[:, 0], pts[:, 0] - bb.x1,
1291
+ np.zeros(len(pts))])
1292
+ dy = np.maximum.reduce([bb.y0 - pts[:, 1], pts[:, 1] - bb.y1,
1293
+ np.zeros(len(pts))])
1294
+ return float(np.min(np.hypot(dx, dy)))
1295
+
1296
+
1297
+ def check_label_attribution(fig, r):
1298
+ """A direct label sitting nearer some other series than the one it names.
1299
+
1300
+ `check_collisions` compares text against text, so a label that clears every
1301
+ other label and still floats in the corridor between two curves passes it
1302
+ clean. That is not hypothetical: `examples/demo.py` shipped with "Tuned"
1303
+ closer to a neighbouring curve than to its own and the whole suite was
1304
+ green. Text against text and text against data are different questions.
1305
+
1306
+ Harvested narrowly on purpose - only text whose string matches exactly one
1307
+ series label is judged, because only there is the intent known. A callout,
1308
+ a panel letter, an "n = 300" attributes nothing to a curve, and failing
1309
+ those is the noise that teaches people to skim the row.
1310
+
1311
+ The threshold is a ratio rather than a distance because the judgement the
1312
+ reader makes is comparative: a label is unambiguous when its own curve is
1313
+ plainly the closest thing to it, not when it is some absolute number of
1314
+ points away.
1315
+ """
1316
+ bad, checked = [], 0
1317
+ legend_ids = _legend_text_ids(fig)
1318
+ all_texts = _texts(fig, r)
1319
+ for ax in fig.axes:
1320
+ px = {}
1321
+ for line in ax.lines:
1322
+ if not line.get_visible():
1323
+ continue
1324
+ p = _polyline_px(line, ax)
1325
+ if p is not None and len(p):
1326
+ px[line] = p
1327
+ if len(px) < 2:
1328
+ continue
1329
+
1330
+ lines_list = list(px.keys())
1331
+ owners = {}
1332
+ for i, line in enumerate(lines_list):
1333
+ owners.setdefault(str(line.get_label()).strip(), []).append(i)
1334
+
1335
+ for t, bb in all_texts:
1336
+ if t.axes is not ax or id(t) in legend_ids:
1337
+ continue
1338
+ match = owners.get(str(t.get_text()).strip())
1339
+ if not match or len(match) != 1:
1340
+ continue
1341
+ own_line = lines_list[match[0]]
1342
+ checked += 1
1343
+ # A floor on the own-curve distance: without it a label printed
1344
+ # directly on its line divides by ~zero, and every other line in
1345
+ # the figure reads as infinitely far.
1346
+ d_own = max(_box_distance(bb, px[own_line]), 0.5)
1347
+ # The minimum over every OTHER curve, box-to-polyline. A KD-tree
1348
+ # over the pooled points was tried here for speed and was wrong:
1349
+ # it returns the nearest *points*, so for a label sitting close to
1350
+ # its own dense curve all the near points belong to that curve, no
1351
+ # other curve is ever reached, and `d_other` stays infinite. Which
1352
+ # is to say it passed every label it was closest to — the common
1353
+ # case, and the one the gate exists for.
1354
+ d_other = min(_box_distance(bb, p)
1355
+ for line, p in px.items() if line is not own_line)
1356
+ if d_other < LABEL_MARGIN * d_own:
1357
+ bad.append(f"{str(t.get_text())[:22]!r} is {d_own:.0f}px from "
1358
+ f"its own curve and {d_other:.0f}px from another")
1359
+ if not checked:
1360
+ return True, "no direct labels matched to a series"
1361
+ if not bad:
1362
+ return True, f"{checked} direct label{'s' if checked != 1 else ''}, "\
1363
+ f"each nearest the curve it names"
1364
+ return False, ("; ".join(bad) + f" <- the reader resolves a direct label "
1365
+ "by proximity, so it has to be plainly nearest its own "
1366
+ "curve. Move it to where that curve is furthest from its "
1367
+ "neighbours, or draw a leader line to the anchor")
1368
+
1369
+
1370
+ # --- the sheet itself -------------------------------------------------------
1371
+
1372
+ def _style_sheet():
1373
+ """`figure.mplstyle` as the skill tells you to lay it out (beside this
1374
+ script), and as this repository lays it out (`assets/` next to
1375
+ `scripts/`)."""
1376
+ here = Path(__file__).resolve().parent
1377
+ for cand in (here / "figure.mplstyle",
1378
+ here.parent / "assets" / "figure.mplstyle"):
1379
+ if cand.is_file():
1380
+ return cand
1381
+ return None
1382
+
1383
+
1384
+ def _is_dashed_linestyle(ls):
1385
+ """True if a linestyle value is not solid.
1386
+
1387
+ Accepts strings ('dashed', '--', '-.', ':') and (offset, dashes) tuples.
1388
+ """
1389
+ if isinstance(ls, str):
1390
+ return ls.lower() not in ("solid", "-", "", "none")
1391
+ if isinstance(ls, (tuple, list)):
1392
+ if len(ls) < 2:
1393
+ return False
1394
+ dashes = ls[1]
1395
+ return dashes is not None and bool(dashes)
1396
+ return False
1397
+
1398
+
1399
+ def check_contour_dash(fig):
1400
+ """Negative-level contours auto-dash via matplotlib default.
1401
+
1402
+ In a monochrome contour, `rcParams["contour.negative_linestyle"]` is
1403
+ "dashed" by default, so negative-Z contours ship dashed isolines nobody
1404
+ chose. The skill's own convention is dashing = unobserved / projected /
1405
+ threshold, making this a silent semantic error every existing gate misses.
1406
+
1407
+ Non-monochrome (colored) contours are always solid and unaffected.
1408
+ """
1409
+ from matplotlib.contour import ContourSet
1410
+
1411
+ warned = []
1412
+ for i, ax in enumerate(fig.axes):
1413
+ for c in ax.collections:
1414
+ if not isinstance(c, ContourSet):
1415
+ continue
1416
+ levels = getattr(c, "levels", None)
1417
+ if levels is None or len(levels) == 0:
1418
+ continue
1419
+ if not all(l <= 0 for l in levels):
1420
+ continue
1421
+ if not getattr(c, "monochrome", False):
1422
+ continue
1423
+ nl = getattr(c, "negative_linestyles", None)
1424
+ if nl is None:
1425
+ continue
1426
+ # negative_linestyles may be a scalar (string or tuple) or a list;
1427
+ # handle both. If it's a list, any non-solid entry triggers.
1428
+ styles = nl if isinstance(nl, (tuple, list)) else [nl]
1429
+ if any(_is_dashed_linestyle(s) for s in styles):
1430
+ warned.append(
1431
+ f"ax{i}: negative-level contours auto-dashed — dashing "
1432
+ "reads as projected/unobserved here; pass "
1433
+ 'linestyles="solid" to contour on signed data')
1434
+ break
1435
+
1436
+ if not warned:
1437
+ return True, "no auto-dashed negative contours"
1438
+ return "warn", "; ".join(warned)
1439
+
1440
+
1441
+ def check_line_weight(fig, scale=None, placed_frac=1.0, venue=None):
1442
+ """Every drawn stroke against the printer's floor, measured ON THE PAGE.
1443
+
1444
+ SIAM states it plainly in its instructions for authors: illustrations must
1445
+ use lines one point or thicker, because thinner lines break up or disappear.
1446
+ It is the same failure as the type floor and it has the same cause — a
1447
+ stroke authored at 0.8pt in a 9-inch figure placed at 5.5 inches prints at
1448
+ 0.49pt — so it is measured the same way, through `page_scale`.
1449
+
1450
+ Furniture is held to a lower floor than data. A gridline that drops out at
1451
+ the printer costs the reader a reference; a data curve that drops out costs
1452
+ them the finding. The sheet ships the grid at 0.7pt deliberately, and
1453
+ failing it against the data floor would be failing the sheet's own design.
1454
+ """
1455
+ from matplotlib.lines import Line2D
1456
+ from matplotlib.collections import LineCollection
1457
+
1458
+ if scale is None:
1459
+ scale = page_scale(fig, placed_frac, venue)
1460
+
1461
+ thin, widths = [], []
1462
+ for ax in fig.axes:
1463
+ # A colorbar's dividers ship at 0.4pt and are matplotlib's, not
1464
+ # anybody's design decision — the same reason `check_ink` skips this
1465
+ # axes entirely.
1466
+ if ax.get_label() == "<colorbar>":
1467
+ continue
1468
+ gridlines = {id(g) for axis in (ax.xaxis, ax.yaxis)
1469
+ for g in axis.get_gridlines()}
1470
+ for artist in list(ax.lines) + list(ax.collections):
1471
+ if not artist.get_visible() or id(artist) in gridlines:
1472
+ continue
1473
+ if isinstance(artist, Line2D):
1474
+ stroke = str(artist.get_linestyle()).strip().lower()
1475
+ if stroke in ("none", "", " "):
1476
+ continue
1477
+ raw = [artist.get_linewidth()]
1478
+ elif isinstance(artist, LineCollection):
1479
+ raw = list(artist.get_linewidth())
1480
+ elif getattr(artist, "filled", None) is False:
1481
+ # An unfilled ContourSet is strokes. A *filled* one is bands
1482
+ # whose linewidth is the seam between two fills, which no
1483
+ # reader is being asked to see.
1484
+ raw = list(artist.get_linewidth())
1485
+ else:
1486
+ continue
1487
+ for w in raw:
1488
+ on_page = float(w) * scale
1489
+ if on_page <= 0:
1490
+ continue
1491
+ widths.append(on_page)
1492
+ if on_page < LINE_FLOOR_PT:
1493
+ name = str(artist.get_label() or "")
1494
+ thin.append(f"{name if name and not name.startswith('_') else 'a stroke'}"
1495
+ f" at {on_page:.2f}pt")
1496
+
1497
+ if not widths:
1498
+ return True, "no strokes to measure"
1499
+ if not thin:
1500
+ return True, (f"{len(widths)} strokes, thinnest {min(widths):.2f}pt on "
1501
+ f"page (floor {LINE_FLOOR_PT})")
1502
+ seen = list(dict.fromkeys(thin))
1503
+ return False, (f"under {LINE_FLOOR_PT}pt on page at scale {scale:.2f}: "
1504
+ f"{seen[:4]} <- SIAM: lines thinner than one point break up "
1505
+ "or disappear in print")
1506
+
1507
+
1508
+ def check_fonts(fig):
1509
+ """Two silent failures between the figure on screen and the file you submit.
1510
+
1511
+ *Type 3.* Matplotlib defaults `pdf.fonttype` and `ps.fonttype` to 3. IEEE,
1512
+ ACM and Elsevier all reject submissions carrying Type 3 fonts, and IEEE PDF
1513
+ eXpress fails the upload outright. Nothing warns you: the figure renders
1514
+ identically, and the paper bounces at the latest and most expensive possible
1515
+ moment. Type 42 embeds TrueType outlines instead. `figure.mplstyle` sets it;
1516
+ this catches the project that did not copy the sheet, and the notebook that
1517
+ called `rcParams.update` afterwards.
1518
+
1519
+ *Silent substitution.* When none of the faces named in `font.<family>` is
1520
+ installed, matplotlib falls back to its own default and logs nothing at
1521
+ default verbosity. The guide calls the typeface the single largest visual
1522
+ lever in the whole method, so a figure set in DejaVu because someone named
1523
+ "Times New Roman" on a machine that does not have it is the lever quietly
1524
+ disengaged. Falling back *within* the named list is not flagged — that is
1525
+ what a fallback list is for, and the sheet ships one on purpose.
1526
+
1527
+ A warning rather than a gate, for the same reason `check_style_sheet` is:
1528
+ both read the *global* rcParams rather than anything the figure carries, so
1529
+ neither can tell a figure built under someone else's settings from a figure
1530
+ built under none. A figure that will only ever be a PNG in a README is also
1531
+ genuinely unaffected by the PDF font type. What is gated instead is the
1532
+ thing this repo controls — the shipped `figure.mplstyle` declares 42, and
1533
+ the suite fails if that line ever goes missing.
1534
+ """
1535
+ import matplotlib as mpl
1536
+ from matplotlib.font_manager import FontProperties, findfont, get_font
1537
+
1538
+ notes = []
1539
+ type3 = [k for k in ("pdf.fonttype", "ps.fonttype")
1540
+ if int(mpl.rcParams[k]) == 3]
1541
+ if type3:
1542
+ notes.append(f"{' and '.join(type3)} = 3 (Type 3) <- IEEE PDF eXpress "
1543
+ "rejects the upload and ACM/Elsevier reject the "
1544
+ "submission; set both to 42")
1545
+
1546
+ family = mpl.rcParams["font.family"]
1547
+ generic = family[0] if isinstance(family, (list, tuple)) else family
1548
+ wanted = list(mpl.rcParams.get(f"font.{generic}", []))
1549
+ if wanted:
1550
+ try:
1551
+ got = get_font(findfont(FontProperties(family=generic))).family_name
1552
+ except Exception:
1553
+ got = None
1554
+ if got is not None and not any(got.lower() == w.lower() for w in wanted):
1555
+ notes.append(f"asked for {wanted[:3]}, rendering in {got!r} — none "
1556
+ "of the named faces is installed on this machine")
1557
+
1558
+ if notes:
1559
+ return "warn", "; ".join(notes)
1560
+ return True, f"Type 42 embedding, {generic} face resolves within the list"
1561
+
1562
+
1563
+ ALT_TEXT_ATTR = "_figure_gate_alt"
1564
+ # Under this many characters a description is naming the figure, not describing
1565
+ # it. "Validation loss" is a title; the alt text has to carry what the reader
1566
+ # would have taken from looking.
1567
+ ALT_TEXT_MIN_CHARS = 60
1568
+
1569
+
1570
+ def describe(fig, text):
1571
+ """Attach a text description to a figure, for readers who cannot see it.
1572
+
1573
+ Across 100,000 public Jupyter notebooks, 99.81% of programmatically
1574
+ generated images shipped with no alt text at all, and the overwhelming
1575
+ majority of them were matplotlib. Matplotlib has no field for this, so the
1576
+ description is stashed on the figure and handed to `savefig`:
1577
+
1578
+ describe(fig, "Validation loss against training epoch for three "
1579
+ "optimisers. All three fall; the Bayesian run reaches "
1580
+ "0.05 by epoch 6, the baseline is still at 0.25 at 12.")
1581
+ fig.savefig(path, metadata=alt_metadata(fig))
1582
+
1583
+ Say what the reader would have taken from looking, not what the figure is
1584
+ made of. "A line chart with three lines" describes the file; the numbers
1585
+ and the direction describe the finding.
1586
+ """
1587
+ setattr(fig, ALT_TEXT_ATTR, str(text))
1588
+ return fig
1589
+
1590
+
1591
+ def alt_metadata(fig):
1592
+ """The `metadata=` dict for `savefig`, carrying whatever `describe` set.
1593
+
1594
+ PNG, PDF and SVG each keep a description field and matplotlib writes to all
1595
+ three from this key, so the text survives into the file rather than living
1596
+ only in the build script.
1597
+ """
1598
+ text = getattr(fig, ALT_TEXT_ATTR, None)
1599
+ return {"Description": text} if text else {}
1600
+
1601
+
1602
+ def check_alt_text(fig):
1603
+ """Whether the figure carries a description for a reader who cannot see it.
1604
+
1605
+ A warning rather than a gate, and deliberately: on a paper the description
1606
+ frequently *is* the caption, and the caption lives in the .tex file where
1607
+ this cannot see it. Hard-failing every figure in that entirely reasonable
1608
+ setup is how a row becomes something everyone learns to skip, which is worse
1609
+ than not having it. Where there is no caption — a notebook, a README, a
1610
+ slide, a web page — nothing else is carrying this and the row is the only
1611
+ thing that will say so.
1612
+ """
1613
+ text = str(getattr(fig, ALT_TEXT_ATTR, "") or "").strip()
1614
+ if not text:
1615
+ return "warn", ("no description attached <- describe(fig, \"...\") "
1616
+ "and pass alt_metadata(fig) to savefig; if the "
1617
+ "document's caption carries it, this row is discharged")
1618
+ if len(text) < ALT_TEXT_MIN_CHARS:
1619
+ return "warn", (f"description is {len(text)} characters — that is a "
1620
+ "title, not a description of what the reader would "
1621
+ "have seen")
1622
+ return True, f"described in {len(text)} characters"
1623
+
1624
+
1625
+ def check_style_sheet(fig):
1626
+ """Every key in the sheet against the rcParams that are actually in effect.
1627
+
1628
+ Three separate silent failures land here at once: a color written with a
1629
+ leading `#` (which is a comment in this format, so matplotlib keeps its own
1630
+ default), a forgotten `plt.style.use`, and an rcParams override applied
1631
+ later. All three ship stock matplotlib while every other check passes.
1632
+
1633
+ A warning, not a gate, for one honest reason: a figure built on a *different*
1634
+ project's sheet is correct work, and this compares against the global
1635
+ rcParams rather than what the figure was drawn under, so a figure built
1636
+ inside an `rc_context` that has since exited reads as drift when it is not.
1637
+ Both make a hard failure the wrong instrument. The row names the keys.
1638
+ """
1639
+ import matplotlib as mpl
1640
+ path = _style_sheet()
1641
+ if path is None:
1642
+ return True, "no figure.mplstyle beside this script, nothing to compare"
1643
+ written = mpl.rc_params_from_file(path, use_default_template=False)
1644
+ drift = []
1645
+ for key, value in written.items():
1646
+ try:
1647
+ same = mpl.rcParams[key] == value
1648
+ except KeyError:
1649
+ continue
1650
+ if not isinstance(same, bool): # a numpy array of comparisons
1651
+ same = bool(getattr(same, "all", lambda: same)())
1652
+ if not same:
1653
+ drift.append(key)
1654
+ if not drift:
1655
+ return True, f"all {len(written)} keys match {path.name}"
1656
+ return "warn", (f"{len(drift)} of {len(written)} keys differ from "
1657
+ f"{path.name}: {sorted(drift)[:5]}"
1658
+ f"{' ...' if len(drift) > 5 else ''} <- the sheet is not "
1659
+ "the one in effect: check plt.style.use, and check no color "
1660
+ "in the sheet was written with a leading #")
1661
+
1662
+
1663
+ def audit(fig, scale=None, placed_frac=1.0, context_axes=None, venue=None):
1664
+ r, canvas = _renderer(fig)
1665
+ rows = [
1666
+ ("Clipping", *check_clipping(fig, r)),
1667
+ ("Text collision", *check_collisions(fig, r)),
1668
+ ("Text readability",
1669
+ *check_text_readability(fig, r, canvas, scale, placed_frac, venue)),
1670
+ ("Contrast stack", *check_contrast_stack(fig)),
1671
+ ("Mark ratio", *check_mark_ratio(fig)),
1672
+ ("Overplotting", *check_overplotting(fig)),
1673
+ ("Axis redundancy", *check_redundancy(fig, r)),
1674
+ ("Type size", *check_type_size(fig, r, scale, placed_frac, venue)),
1675
+ ("Line weight", *check_line_weight(fig, scale, placed_frac, venue)),
1676
+ ("Ink coverage", *check_ink(fig, context_axes, canvas)),
1677
+ ("Series color", *check_series_color(fig)),
1678
+ ("Dual axis", *check_dual_axis(fig)),
1679
+ ("Form", *check_form(fig)),
1680
+ ("Identity channel", *check_identity_channel(fig)),
1681
+ ("Label attribution", *check_label_attribution(fig, r)),
1682
+ ("Style sheet", *check_style_sheet(fig)),
1683
+ ("Contour dash", *check_contour_dash(fig)),
1684
+ ("Fonts", *check_fonts(fig)),
1685
+ ("Alt text", *check_alt_text(fig)),
1686
+ ]
1687
+ # "warn" rows are advisory: they report something worth a look without
1688
+ # failing the build. Only a hard False gates.
1689
+ return all(s is not False for _, s, _ in rows), rows
1690
+
1691
+
1692
+ def report(fig, name="", scale=None, placed_frac=1.0, context_axes=None,
1693
+ venue=None):
1694
+ ok, rows = audit(fig, scale, placed_frac, context_axes, venue)
1695
+ print(f"\nComposition audit{': ' + name if name else ''}")
1696
+ warned = False
1697
+ for label, status, detail in rows:
1698
+ tag = "WARN" if status == "warn" else ("PASS" if status else "FAIL")
1699
+ warned = warned or status == "warn"
1700
+ print(f" [{tag}] {label:<18} {detail}")
1701
+ verdict = "COMPOSED" if ok else "FIX THE MARKED CHECKS"
1702
+ if ok and warned:
1703
+ verdict += " (with advisories)"
1704
+ print(f"\n -> {verdict}\n")
1705
+ return ok
1706
+
1707
+
1708
+ def self_test_figure():
1709
+ """A figure that breaks several checks on purpose.
1710
+
1711
+ Kept as a function rather than inlined under __main__ so the test suite can
1712
+ assert the gate still fails on it. A gate nobody has watched fail is
1713
+ decoration, and this one silently stopped working twice while it was being
1714
+ written.
1715
+ """
1716
+ import matplotlib.pyplot as plt
1717
+ fig, (a, b) = plt.subplots(1, 2, figsize=(4, 2))
1718
+ for ax in (a, b):
1719
+ ax.scatter([1, 2, 3], [1, 2, 3], s=12, alpha=0.4)
1720
+ ax.scatter([2], [2], marker="*", s=400, alpha=0.7)
1721
+ ax.set_xlabel("a very long axis label that will not fit")
1722
+ ax.set_ylabel("Batch size")
1723
+ return fig
1724
+
1725
+
1726
+ def main():
1727
+ """Run the self-test. Exits 0 when the gate correctly rejects a bad figure,
1728
+ so `check-figure` in a build verifies the checker itself is still working."""
1729
+ try:
1730
+ import matplotlib
1731
+ except ImportError:
1732
+ raise SystemExit(
1733
+ "check_figure.py needs matplotlib (numpy comes with it; scipy is "
1734
+ "optional and only a speed-up):\n"
1735
+ " pip install matplotlib\n\n"
1736
+ "The composition RULES are library-agnostic and written up in the "
1737
+ "style guide; only this automated check is matplotlib-specific. On "
1738
+ "another plotting stack, apply the rules by hand or port the "
1739
+ "checks - each one reads geometry any library can report.")
1740
+ import sys
1741
+ if "--venues" in sys.argv:
1742
+ print("\nContent widths, in points. Pass one as venue= to audit().")
1743
+ print("Verify against `\\the\\textwidth` in your own document before "
1744
+ "trusting one for anything that matters.\n")
1745
+ for name, pt in sorted(VENUE_WIDTH_PT.items()):
1746
+ print(f" {name:<16} {pt:>7.2f} pt ({pt / 72:.2f} in)")
1747
+ print()
1748
+ return
1749
+
1750
+ matplotlib.use("agg")
1751
+ composed = report(self_test_figure(), "self-test (expected: FAIL)")
1752
+ if composed:
1753
+ raise SystemExit(
1754
+ "The self-test figure passed, which means the checker is broken.")
1755
+ print(" The gate correctly rejected a deliberately bad figure.\n")
1756
+
1757
+
1758
+ if __name__ == "__main__":
1759
+ main()