citegraph 0.2.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.
citegraph/plotting.py ADDED
@@ -0,0 +1,1214 @@
1
+ """Publication-quality matplotlib styling for economics manuscripts.
2
+
3
+ The conventions encoded here follow what top economics journals (AER, QJE,
4
+ JPE, Econometrica, REStud) actually print:
5
+
6
+ * **Grayscale-first.** Black and gray marks carry the data; colour, when
7
+ used, is the colour-blind-safe Okabe–Ito set and is always doubled by a
8
+ line style so the figure survives black-and-white printing.
9
+ * **No title inside the figure.** The title lives in the caption
10
+ ("Figure 1. …"), followed by *Notes:* then *Source:*. Panels are labelled
11
+ "Panel A. …" above each axes.
12
+ * **Serif type matching the manuscript**, 8–10 pt at final size, one
13
+ weight. Figures are designed at the final printed width so nothing is
14
+ rescaled.
15
+ * **Minimal chrome.** Left and bottom spines only, thin black axes,
16
+ outward ticks, no gridlines, bars start at zero, integer/percent tick
17
+ formats.
18
+ * **Vector output** (PDF with TrueType fonts embedded) plus a 300 dpi PNG.
19
+
20
+ ``matplotlib`` is an optional dependency (``pip install "citegraph[plots]"``);
21
+ it is imported lazily inside each function so ``import citegraph`` stays
22
+ cheap.
23
+
24
+ Typical use inside a notebook::
25
+
26
+ from citegraph.plotting import bar, econ_style, figure_size, panel_title, save_figure
27
+
28
+ with econ_style():
29
+ fig, (ax_a, ax_b) = plt.subplots(1, 2, figsize=figure_size("text", ratio=0.45))
30
+ bar(ax_a, years, counts)
31
+ panel_title(ax_a, "A", "Annual counts")
32
+ ...
33
+ save_figure(fig, out_dir / "figures" / "trend",
34
+ caption="Publication trends", notes="…", source="…")
35
+
36
+ Draw bars with :func:`bar` / :func:`barh` rather than ``ax.bar`` / ``ax.barh``:
37
+ matplotlib takes an unspecified bar colour from the property cycle, whose first
38
+ entry is ink, so a bare ``ax.bar`` comes out solid black under this style
39
+ however ``patch.facecolor`` is set. Network figures have their own vocabulary —
40
+ :func:`network_axes`, :func:`draw_node`, :func:`hairline` — so a node-link
41
+ diagram and a bar chart in the same manuscript read as one family.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import contextlib
47
+ import math
48
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
49
+ from pathlib import Path
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ if TYPE_CHECKING: # pragma: no cover - typing only
53
+ from matplotlib.axes import Axes
54
+ from matplotlib.container import BarContainer
55
+ from matplotlib.figure import Figure
56
+ from matplotlib.lines import Line2D
57
+ from matplotlib.text import Annotation
58
+ from matplotlib.transforms import Bbox
59
+
60
+ __all__ = [
61
+ "INK",
62
+ "GRAYS",
63
+ "OKABE_ITO",
64
+ "LINESTYLES",
65
+ "MARKERS",
66
+ "HATCHES",
67
+ "WIDTHS",
68
+ "BAR_FILL",
69
+ "BAR_WIDTH",
70
+ "NODE_FILL",
71
+ "NODE_OPEN",
72
+ "BOX_FILL",
73
+ "NODE_EDGE",
74
+ "NODE_RADIUS",
75
+ "LABEL_OFFSET",
76
+ "JUNCTION_RADIUS",
77
+ "EDGE_COLOR",
78
+ "RULE_COLOR",
79
+ "econ_rc",
80
+ "econ_style",
81
+ "use_econ_style",
82
+ "figure_size",
83
+ "bar",
84
+ "barh",
85
+ "year_ticks",
86
+ "panel_title",
87
+ "integer_ticks",
88
+ "percent_ticks",
89
+ "reference_line",
90
+ "value_labels",
91
+ "caption_markdown",
92
+ "save_figure",
93
+ "show_caption",
94
+ "grayscale_preview",
95
+ "draw_node",
96
+ "box_node",
97
+ "flow_band",
98
+ "node_legend",
99
+ "hairline",
100
+ "arrow_props",
101
+ "junction_dot",
102
+ "network_axes",
103
+ "separator_rule",
104
+ "place_node_labels",
105
+ ]
106
+
107
+ # ----------------------------------------------------------------------
108
+ # Palette constants
109
+ # ----------------------------------------------------------------------
110
+
111
+ #: Text, axes, and the first series.
112
+ INK = "#000000"
113
+
114
+ #: Ordinal gray ramp, dark → light, for bars/stacked segments. Fills always
115
+ #: carry a black edge so the lightest step still reads on white.
116
+ GRAYS: tuple[str, ...] = ("#252525", "#636363", "#969696", "#BDBDBD", "#E6E6E6")
117
+
118
+ #: Okabe–Ito colour-blind-safe hues in fixed order (blue, vermillion,
119
+ #: bluish green, reddish purple, orange, sky blue). Validated: adjacent-pair
120
+ #: CVD ΔE ≥ 7.6, normal-vision ΔE ≥ 21. The last three are light against white
121
+ #: and must always be paired with a line style, marker, or direct label.
122
+ OKABE_ITO: tuple[str, ...] = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9")
123
+
124
+ #: Dash patterns in the order they are assigned to series.
125
+ LINESTYLES: tuple[str, ...] = ("-", "--", ":", "-.")
126
+
127
+ #: Marker shapes in the order they are assigned to series.
128
+ MARKERS: tuple[str, ...] = ("o", "s", "^", "D")
129
+
130
+ #: Hatch patterns for distinguishing bar series without colour. The empty
131
+ #: string is the first (solid) series.
132
+ HATCHES: tuple[str, ...] = ("", "////", "....", "xxxx", "\\\\\\\\")
133
+
134
+ #: Default bar fill. ``ax.bar`` takes its colour from the property cycle, whose
135
+ #: first entry is :data:`INK`, so an un-styled bar comes out solid black however
136
+ #: ``patch.facecolor`` is set — no rcParam can express "black lines, gray bars".
137
+ #: :func:`bar` and :func:`barh` apply this fill instead; call them rather than
138
+ #: ``ax.bar``/``ax.barh`` so every bar in the corpus reads the same.
139
+ BAR_FILL = GRAYS[1]
140
+
141
+ #: Default bar thickness (fraction of the category step).
142
+ BAR_WIDTH = 0.72
143
+
144
+ #: Network node fill for a node inside the population being described.
145
+ NODE_FILL = GRAYS[1]
146
+
147
+ #: Network node fill for a node outside it (an advisor who is not a listed
148
+ #: author, a co-cited author below the inclusion threshold).
149
+ NODE_OPEN = "white"
150
+
151
+ #: Fill for a :func:`box_node` inside the population described. A box is orders
152
+ #: of magnitude more area than a marker, and area at :data:`NODE_FILL` leaves no
153
+ #: room for the text printed on it — at that weight the subtitle gray and the
154
+ #: fill are the same value and every second line inside a filled box vanishes.
155
+ #: An area mark takes the light end of the ramp and keeps its text black; the
156
+ #: black edge every node carries is what makes the tint read as a fill at all.
157
+ BOX_FILL = GRAYS[4]
158
+
159
+ #: Every network node carries this edge so the open fill still reads on white.
160
+ NODE_EDGE = INK
161
+
162
+ #: Network edges and connectors: a hairline dark enough to follow across the
163
+ #: page but light enough not to compete with the node markers.
164
+ EDGE_COLOR = "#555555"
165
+
166
+ #: Hairline separating bands of a grouped figure (lineage blocks, tie rows).
167
+ RULE_COLOR = "#DBDBDB"
168
+
169
+ #: Network node radius in points. Markers are sized ``2 * NODE_RADIUS``.
170
+ NODE_RADIUS = 2.5
171
+
172
+ #: Gap in points between a node centre and its label.
173
+ LABEL_OFFSET = 6.0
174
+
175
+ #: Radius in points of the mark at a connector junction. Deliberately far below
176
+ #: :data:`NODE_RADIUS`: a junction is punctuation on a connector, and one drawn
177
+ #: anywhere near node size reads as an unlabelled person in the network.
178
+ JUNCTION_RADIUS = 1.15
179
+
180
+ #: Final printed widths in inches. ``text`` is a US-letter manuscript with
181
+ #: 1-inch margins; ``journal`` approximates an AER/QJE text block; ``column``
182
+ #: is one column of a two-column layout or a half-width panel.
183
+ WIDTHS: dict[str, float] = {"column": 3.25, "journal": 4.75, "text": 6.5}
184
+
185
+ _SERIF_STACK = (
186
+ "Times New Roman",
187
+ "Times",
188
+ "STIX Two Text",
189
+ "STIXGeneral",
190
+ "Nimbus Roman",
191
+ "DejaVu Serif",
192
+ )
193
+ _SANS_STACK = ("Helvetica", "Arial", "Liberation Sans", "DejaVu Sans")
194
+
195
+
196
+ # ----------------------------------------------------------------------
197
+ # Lazy import helpers
198
+ # ----------------------------------------------------------------------
199
+
200
+
201
+ def _mpl():
202
+ try:
203
+ import matplotlib
204
+ except ImportError as exc: # pragma: no cover - import guard
205
+ raise ImportError(
206
+ "citegraph.plotting requires matplotlib. Install with: "
207
+ 'pip install "citegraph[plots]"'
208
+ ) from exc
209
+ return matplotlib
210
+
211
+
212
+ def _pyplot():
213
+ _mpl()
214
+ import matplotlib.pyplot as plt
215
+
216
+ return plt
217
+
218
+
219
+ # ----------------------------------------------------------------------
220
+ # rcParams
221
+ # ----------------------------------------------------------------------
222
+
223
+
224
+ def _prop_cycle(palette: str):
225
+ from cycler import cycler
226
+
227
+ if palette == "mono":
228
+ colors = (INK, "#636363", "#A6A6A6", "#404040")
229
+ styles = LINESTYLES
230
+ elif palette == "color":
231
+ colors = (INK, *OKABE_ITO)
232
+ # Repeat the dash cycle so every colour still differs in line style.
233
+ styles = tuple(LINESTYLES[i % len(LINESTYLES)] for i in range(len(colors)))
234
+ else:
235
+ raise ValueError(f"Unknown palette {palette!r}; expected 'mono' or 'color'.")
236
+ return cycler(color=list(colors)) + cycler(linestyle=list(styles))
237
+
238
+
239
+ def econ_rc(
240
+ *,
241
+ palette: str = "mono",
242
+ font: str = "serif",
243
+ base_size: float = 9.0,
244
+ ) -> dict[str, Any]:
245
+ """Return the rcParams dict for the economics-journal style.
246
+
247
+ Parameters
248
+ ----------
249
+ palette:
250
+ ``"mono"`` (default) cycles black and grays with distinct dashes;
251
+ ``"color"`` cycles black then the Okabe–Ito hues, still with dashes.
252
+ font:
253
+ ``"serif"`` (Times/STIX, matching a LaTeX or Word manuscript) or
254
+ ``"sans"`` (Helvetica/Arial, the Stata default many journals print).
255
+ base_size:
256
+ Body font size in points at the final printed width. Tick labels,
257
+ legend entries and value labels are one step smaller.
258
+ """
259
+ _mpl()
260
+ if font == "serif":
261
+ family, stack, mathtext = "serif", _SERIF_STACK, "stix"
262
+ elif font == "sans":
263
+ family, stack, mathtext = "sans-serif", _SANS_STACK, "stixsans"
264
+ else:
265
+ raise ValueError(f"Unknown font {font!r}; expected 'serif' or 'sans'.")
266
+
267
+ small = round(base_size - 1.0, 2)
268
+ return {
269
+ # --- type ---
270
+ "font.family": family,
271
+ f"font.{family}": list(stack),
272
+ "mathtext.fontset": mathtext,
273
+ "font.size": base_size,
274
+ "font.weight": "normal",
275
+ "axes.titlesize": base_size,
276
+ "axes.titleweight": "normal",
277
+ "axes.titlelocation": "center",
278
+ "axes.titlepad": 6.0,
279
+ "axes.labelsize": base_size,
280
+ "axes.labelweight": "normal",
281
+ "axes.labelpad": 4.0,
282
+ "xtick.labelsize": small,
283
+ "ytick.labelsize": small,
284
+ "legend.fontsize": small,
285
+ "legend.title_fontsize": small,
286
+ "figure.titlesize": base_size,
287
+ "figure.titleweight": "normal",
288
+ "axes.unicode_minus": True,
289
+ # --- axes & ticks ---
290
+ "axes.edgecolor": INK,
291
+ "axes.labelcolor": INK,
292
+ "axes.linewidth": 0.6,
293
+ "axes.spines.top": False,
294
+ "axes.spines.right": False,
295
+ "axes.spines.left": True,
296
+ "axes.spines.bottom": True,
297
+ "axes.axisbelow": True,
298
+ "axes.grid": False,
299
+ "grid.color": "#BFBFBF",
300
+ "grid.linewidth": 0.5,
301
+ "grid.linestyle": "-",
302
+ "xtick.color": INK,
303
+ "ytick.color": INK,
304
+ "xtick.direction": "out",
305
+ "ytick.direction": "out",
306
+ "xtick.major.size": 3.0,
307
+ "ytick.major.size": 3.0,
308
+ "xtick.major.width": 0.6,
309
+ "ytick.major.width": 0.6,
310
+ "xtick.minor.visible": False,
311
+ "ytick.minor.visible": False,
312
+ "axes.prop_cycle": _prop_cycle(palette),
313
+ # --- marks ---
314
+ "lines.linewidth": 1.2,
315
+ "lines.markersize": 4.0,
316
+ "lines.markeredgewidth": 0.6,
317
+ "patch.linewidth": 0.6,
318
+ "patch.edgecolor": INK,
319
+ "patch.force_edgecolor": True,
320
+ "hatch.linewidth": 0.5,
321
+ "hatch.color": INK,
322
+ "errorbar.capsize": 2.0,
323
+ # --- legend ---
324
+ "legend.frameon": False,
325
+ "legend.handlelength": 2.2,
326
+ "legend.handleheight": 0.7,
327
+ "legend.borderaxespad": 0.3,
328
+ "legend.labelspacing": 0.4,
329
+ "legend.columnspacing": 1.2,
330
+ # --- figure & output ---
331
+ "figure.facecolor": "white",
332
+ "figure.edgecolor": "white",
333
+ "figure.dpi": 130,
334
+ "figure.constrained_layout.use": True,
335
+ "savefig.facecolor": "white",
336
+ "savefig.edgecolor": "white",
337
+ "savefig.dpi": 300,
338
+ "savefig.bbox": "tight",
339
+ "savefig.pad_inches": 0.03,
340
+ "pdf.fonttype": 42,
341
+ "ps.fonttype": 42,
342
+ "svg.fonttype": "none",
343
+ }
344
+
345
+
346
+ @contextlib.contextmanager
347
+ def econ_style(
348
+ *,
349
+ palette: str = "mono",
350
+ font: str = "serif",
351
+ base_size: float = 9.0,
352
+ overrides: dict[str, Any] | None = None,
353
+ ) -> Iterator[dict[str, Any]]:
354
+ """Context manager applying :func:`econ_rc` for the duration of a block.
355
+
356
+ ``overrides`` are merged last, so callers can switch on gridlines or
357
+ change a single size without redefining the whole style.
358
+ """
359
+ plt = _pyplot()
360
+ rc = econ_rc(palette=palette, font=font, base_size=base_size)
361
+ if overrides:
362
+ rc.update(overrides)
363
+ with plt.rc_context(rc):
364
+ yield rc
365
+
366
+
367
+ def use_econ_style(
368
+ *,
369
+ palette: str = "mono",
370
+ font: str = "serif",
371
+ base_size: float = 9.0,
372
+ overrides: dict[str, Any] | None = None,
373
+ ) -> dict[str, Any]:
374
+ """Apply :func:`econ_rc` globally (for the rest of a notebook session)."""
375
+ plt = _pyplot()
376
+ rc = econ_rc(palette=palette, font=font, base_size=base_size)
377
+ if overrides:
378
+ rc.update(overrides)
379
+ plt.rcParams.update(rc)
380
+ return rc
381
+
382
+
383
+ # ----------------------------------------------------------------------
384
+ # Sizes
385
+ # ----------------------------------------------------------------------
386
+
387
+
388
+ def figure_size(
389
+ width: str | float = "text",
390
+ *,
391
+ ratio: float = 0.6,
392
+ height: float | None = None,
393
+ ) -> tuple[float, float]:
394
+ """Return ``(width, height)`` in inches at the final printed size.
395
+
396
+ ``width`` is a preset name from :data:`WIDTHS` or a number of inches.
397
+ ``height`` overrides ``ratio`` (height / width) when given.
398
+ """
399
+ if isinstance(width, str):
400
+ try:
401
+ w = WIDTHS[width]
402
+ except KeyError as exc:
403
+ raise ValueError(
404
+ f"Unknown width preset {width!r}; expected one of {sorted(WIDTHS)} or inches."
405
+ ) from exc
406
+ else:
407
+ w = float(width)
408
+ h = float(height) if height is not None else w * ratio
409
+ return (w, h)
410
+
411
+
412
+ # ----------------------------------------------------------------------
413
+ # Axis helpers
414
+ # ----------------------------------------------------------------------
415
+
416
+
417
+ def _axis(ax: Axes, axis: str):
418
+ if axis == "x":
419
+ return ax.xaxis
420
+ if axis == "y":
421
+ return ax.yaxis
422
+ raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
423
+
424
+
425
+ def bar(ax: Axes, x, height, *, width: float = BAR_WIDTH, **kwargs: Any) -> BarContainer:
426
+ """Vertical bars in the house fill: :data:`BAR_FILL` with a black edge.
427
+
428
+ Use instead of ``ax.bar``. Matplotlib takes an un-specified bar colour from
429
+ the property cycle, whose first entry is black, so ``ax.bar`` produces solid
430
+ black bars under :func:`econ_style` no matter what ``patch.facecolor`` says.
431
+ Passing ``color=`` here still wins, for stacked or multi-series bars.
432
+ """
433
+ kwargs.setdefault("color", BAR_FILL)
434
+ kwargs.setdefault("edgecolor", INK)
435
+ return ax.bar(x, height, width=width, **kwargs)
436
+
437
+
438
+ def barh(ax: Axes, y, width, *, height: float = BAR_WIDTH, **kwargs: Any) -> BarContainer:
439
+ """Horizontal bars in the house fill. See :func:`bar`."""
440
+ kwargs.setdefault("color", BAR_FILL)
441
+ kwargs.setdefault("edgecolor", INK)
442
+ return ax.barh(y, width, height=height, **kwargs)
443
+
444
+
445
+ def year_ticks(ax: Axes, first_year: int, last_year: int, *, pad: float = 0.7) -> None:
446
+ """Horizontal year ticks at a readable spacing, with the x-limits padded.
447
+
448
+ Every five years over a long span, every two over a short one, so a dense
449
+ annual series never needs rotated labels. Ticks land on round years.
450
+ """
451
+ step = 5 if last_year - first_year > 15 else 2
452
+ first_tick = first_year + (-first_year) % step
453
+ ax.set_xticks(range(first_tick, last_year + 1, step))
454
+ ax.set_xlim(first_year - pad, last_year + pad)
455
+
456
+
457
+ def panel_title(ax: Axes, letter: str, title: str | None = None, **kwargs: Any):
458
+ """Label a panel the AER way: ``Panel A. Title`` centred above the axes."""
459
+ text = f"Panel {letter}. {title}" if title else f"Panel {letter}"
460
+ return ax.set_title(text, **kwargs)
461
+
462
+
463
+ def integer_ticks(ax: Axes, axis: str = "y") -> None:
464
+ """Force whole-number ticks (counts) on one axis."""
465
+ from matplotlib.ticker import MaxNLocator
466
+
467
+ _axis(ax, axis).set_major_locator(MaxNLocator(integer=True))
468
+
469
+
470
+ def percent_ticks(ax: Axes, axis: str = "y", *, xmax: float = 100, decimals: int = 0) -> None:
471
+ """Format ticks as percentages. ``xmax=100`` for data already in percent,
472
+ ``xmax=1`` for proportions."""
473
+ from matplotlib.ticker import PercentFormatter
474
+
475
+ _axis(ax, axis).set_major_formatter(PercentFormatter(xmax=xmax, decimals=decimals))
476
+
477
+
478
+ def reference_line(
479
+ ax: Axes,
480
+ value: float,
481
+ *,
482
+ label: str | None = None,
483
+ orientation: str = "h",
484
+ color: str = INK,
485
+ linestyle: str = "--",
486
+ linewidth: float = 0.8,
487
+ in_legend: bool = False,
488
+ label_kwargs: dict[str, Any] | None = None,
489
+ **kwargs: Any,
490
+ ) -> Line2D:
491
+ """Draw a dashed hairline at ``value`` (a mean, a target, an overall share).
492
+
493
+ By default the optional ``label`` sits just above the line at its right
494
+ end (or beside a vertical line at its top) in small ink text — the journal
495
+ way to annotate a benchmark without a legend entry. When bars or lines
496
+ would collide with that text, pass ``in_legend=True``: the label is then
497
+ attached to the line artist so a later ``ax.legend()`` lists it instead.
498
+ """
499
+ if orientation == "h":
500
+ line = ax.axhline(value, color=color, linestyle=linestyle, linewidth=linewidth, **kwargs)
501
+ elif orientation == "v":
502
+ line = ax.axvline(value, color=color, linestyle=linestyle, linewidth=linewidth, **kwargs)
503
+ else:
504
+ raise ValueError(f"orientation must be 'h' or 'v', got {orientation!r}")
505
+
506
+ if label and in_legend:
507
+ line.set_label(label)
508
+ elif label:
509
+ opts: dict[str, Any] = {"color": INK, "fontsize": "small"}
510
+ if orientation == "h":
511
+ opts.update(
512
+ x=0.995,
513
+ y=value,
514
+ ha="right",
515
+ va="bottom",
516
+ transform=ax.get_yaxis_transform(),
517
+ )
518
+ else:
519
+ opts.update(
520
+ x=value,
521
+ y=0.98,
522
+ ha="left",
523
+ va="top",
524
+ rotation=90,
525
+ transform=ax.get_xaxis_transform(),
526
+ )
527
+ if label_kwargs:
528
+ opts.update(label_kwargs)
529
+ ax.text(s=label, **opts)
530
+ return line
531
+
532
+
533
+ def value_labels(
534
+ ax: Axes,
535
+ bars: BarContainer,
536
+ *,
537
+ fmt: str = "{:,.0f}",
538
+ labels: Iterable[str] | None = None,
539
+ padding: float = 2.0,
540
+ **kwargs: Any,
541
+ ) -> list[Annotation]:
542
+ """Annotate bars with their values in small, regular-weight ink text.
543
+
544
+ Use sparingly (a handful of bars, or a horizontal ranking where the exact
545
+ count matters). Never bold, never coloured.
546
+ """
547
+ if labels is None:
548
+ labels = [fmt.format(value) for value in bars.datavalues]
549
+ opts: dict[str, Any] = {
550
+ "color": INK,
551
+ "fontsize": "small",
552
+ "fontweight": "normal",
553
+ "padding": padding,
554
+ }
555
+ opts.update(kwargs)
556
+ return ax.bar_label(bars, labels=list(labels), **opts)
557
+
558
+
559
+ # ----------------------------------------------------------------------
560
+ # Captions and saving
561
+ # ----------------------------------------------------------------------
562
+
563
+
564
+ def caption_markdown(
565
+ caption: str,
566
+ *,
567
+ notes: str | None = None,
568
+ source: str | None = None,
569
+ number: int | str | None = None,
570
+ ) -> str:
571
+ """Render a journal-style caption block as Markdown.
572
+
573
+ ``**Figure N. Caption**`` then ``*Notes:*`` then ``*Source:*`` — notes
574
+ precede the source note, following the AER style guide. Without ``number``
575
+ the title stands alone (``**Caption**``) so figures can be numbered later,
576
+ when they are arranged in the manuscript.
577
+ """
578
+ title = caption.strip()
579
+ heading = f"Figure {number}. {title}" if number is not None else title
580
+ parts = [f"**{heading}**"]
581
+ if notes:
582
+ parts.append(f"*Notes:* {notes.strip()}")
583
+ if source:
584
+ parts.append(f"*Source:* {source.strip()}")
585
+ return "\n\n".join(parts)
586
+
587
+
588
+ def save_figure(
589
+ fig: Figure,
590
+ stem: str | Path,
591
+ *,
592
+ formats: Sequence[str] = ("pdf", "png"),
593
+ dpi: int = 300,
594
+ caption: str | None = None,
595
+ notes: str | None = None,
596
+ source: str | None = None,
597
+ number: int | str | None = None,
598
+ grayscale: bool = True,
599
+ ) -> list[Path]:
600
+ """Save ``fig`` as ``<stem>.<fmt>`` for each format, plus ``<stem>.caption.md``.
601
+
602
+ PDF is the vector file journals ask for (TrueType fonts embedded via
603
+ ``pdf.fonttype = 42``); PNG at ``dpi`` is for Word drafts and slides.
604
+ The caption file keeps title, notes and source next to the artwork so the
605
+ figure itself can stay free of in-plot titles.
606
+
607
+ ``grayscale`` (default) also writes a black-and-white proof to a ``gray/``
608
+ subdirectory, so the "legible without colour" check leaves evidence on disk
609
+ without doubling the file count in the directory handed to a coauthor. It is
610
+ skipped silently when Pillow is absent or no PNG was requested.
611
+ """
612
+ stem = Path(stem)
613
+ stem.parent.mkdir(parents=True, exist_ok=True)
614
+ written: list[Path] = []
615
+ for fmt in formats:
616
+ path = stem.with_name(f"{stem.name}.{fmt}")
617
+ fig.savefig(path, dpi=dpi, bbox_inches="tight", facecolor="white")
618
+ written.append(path)
619
+ if caption:
620
+ caption_path = stem.with_name(f"{stem.name}.caption.md")
621
+ caption_path.write_text(
622
+ caption_markdown(caption, notes=notes, source=source, number=number) + "\n",
623
+ encoding="utf-8",
624
+ )
625
+ written.append(caption_path)
626
+ if grayscale:
627
+ png = next((p for p in written if p.suffix == ".png"), None)
628
+ if png is not None:
629
+ gray_dir = stem.parent / "gray"
630
+ try:
631
+ gray_dir.mkdir(parents=True, exist_ok=True)
632
+ written.append(
633
+ grayscale_preview(png, gray_dir / f"{stem.name}.gray.png")
634
+ )
635
+ except ImportError: # Pillow not installed; the proof is optional
636
+ pass
637
+ return written
638
+
639
+
640
+ def network_axes(ax: Axes, *, equal: bool = True) -> Axes:
641
+ """Strip a network drawing down to its marks: no spines, ticks, or frame.
642
+
643
+ A node-link diagram has no meaningful axes, so the chrome
644
+ :func:`econ_style` draws for a plot is noise here. ``equal`` keeps circles
645
+ circular; pass ``equal=False`` when one axis carries real units (a year).
646
+ """
647
+ ax.set_axis_off()
648
+ if equal:
649
+ ax.set_aspect("equal")
650
+ return ax
651
+
652
+
653
+ def hairline(
654
+ ax: Axes,
655
+ xs: Sequence[float],
656
+ ys: Sequence[float],
657
+ *,
658
+ color: str = EDGE_COLOR,
659
+ linewidth: float = 0.7,
660
+ zorder: float = 2.0,
661
+ **kwargs: Any,
662
+ ) -> Line2D:
663
+ """Draw one thin connector segment.
664
+
665
+ ``econ_style`` installs a property cycle that carries *line styles* as well
666
+ as colours, so a bare ``ax.plot`` silently comes out dashed or dotted on the
667
+ second call. Connectors are structure, not data, so this pins a solid line
668
+ and butt caps — the caps matter where segments meet at a right angle.
669
+ """
670
+ return ax.plot(
671
+ xs,
672
+ ys,
673
+ color=color,
674
+ linewidth=linewidth,
675
+ linestyle="-",
676
+ solid_capstyle="butt",
677
+ zorder=zorder,
678
+ **kwargs,
679
+ )[0]
680
+
681
+
682
+ def arrow_props(
683
+ *,
684
+ color: str = EDGE_COLOR,
685
+ linewidth: float = 0.7,
686
+ mutation_scale: float = 5.0,
687
+ arrowstyle: str = "-|>",
688
+ **kwargs: Any,
689
+ ) -> dict[str, Any]:
690
+ """``arrowprops`` for a directed connector, matching :func:`hairline`.
691
+
692
+ ``shrinkA``/``shrinkB`` are zero because callers anchor arrows at a measured
693
+ label or node edge themselves; letting matplotlib shrink from the centre is
694
+ what runs an arrow back through its own label.
695
+ """
696
+ props: dict[str, Any] = {
697
+ "arrowstyle": arrowstyle,
698
+ "mutation_scale": mutation_scale,
699
+ "color": color,
700
+ "linewidth": linewidth,
701
+ "shrinkA": 0,
702
+ "shrinkB": 0,
703
+ }
704
+ props.update(kwargs)
705
+ return props
706
+
707
+
708
+ def junction_dot(
709
+ ax: Axes,
710
+ x: float,
711
+ y: float,
712
+ *,
713
+ color: str = EDGE_COLOR,
714
+ radius: float = JUNCTION_RADIUS,
715
+ zorder: float = 2.6,
716
+ **kwargs: Any,
717
+ ) -> Line2D:
718
+ """Mark a point where connectors *meet*, as opposed to merely cross.
719
+
720
+ This is the schematic convention, and a bracket router needs it as soon as
721
+ the graph it draws stops being a forest. With one parent per node, every
722
+ bracket owns its own rows and no two ever touch; with several — a doctorate
723
+ supervised by three people — one bracket's vertical spine has to run past
724
+ rows that other brackets are using, and a reader has no way to tell the
725
+ resulting T-junction from an X-crossing. Left unmarked, that is a figure
726
+ asserting ties nobody recorded: in one supervision tree it joined two
727
+ people who share no advisor, no institution and no edge.
728
+
729
+ So a filled dot means connected and bare crossing lines mean not connected,
730
+ and the two become distinguishable. Callers decide which points qualify —
731
+ the rule is three or more connector directions leaving the same point, since
732
+ a plain corner or a straight pass-through needs no mark and a figure peppered
733
+ with dots teaches the reader to ignore them.
734
+
735
+ ``radius`` is in points, independent of the axis scale, so the mark stays put
736
+ on a year axis as readily as in the point space the lineage trees use.
737
+ """
738
+ return ax.plot(
739
+ [x],
740
+ [y],
741
+ marker="o",
742
+ markersize=radius * 2,
743
+ linestyle="none",
744
+ markerfacecolor=color,
745
+ markeredgecolor=color,
746
+ markeredgewidth=0.0,
747
+ zorder=zorder,
748
+ **kwargs,
749
+ )[0]
750
+
751
+
752
+ def draw_node(
753
+ ax: Axes,
754
+ x: float,
755
+ y: float,
756
+ label: str | None = None,
757
+ *,
758
+ inside: bool = True,
759
+ focus: bool = False,
760
+ size: float = NODE_RADIUS * 2,
761
+ fontsize: float | str | None = None,
762
+ label_offset: float = LABEL_OFFSET,
763
+ label_side: str = "right",
764
+ unit: float = 1.0,
765
+ zorder: float = 4.0,
766
+ **kwargs: Any,
767
+ ) -> Line2D:
768
+ """Draw one network node, plus its label to the right, in the house style.
769
+
770
+ The fill carries one bit of membership and nothing else: ``inside`` (the
771
+ default) fills with :data:`NODE_FILL` for a node in the population being
772
+ described, and ``inside=False`` leaves it open (:data:`NODE_OPEN`) for one
773
+ outside it. ``focus=True`` fills with ink, for the single node a figure is
774
+ built around. Every node keeps a black edge so the open fill still reads.
775
+
776
+ ``size`` is the marker diameter in points. ``label_offset`` is the gap
777
+ between node centre and label, also in points; ``unit`` converts points to
778
+ data units when the axis carries real units (a year axis), so the label sits
779
+ the same distance from the marker whatever the axis scale. ``label_side``
780
+ flips the label to the left of the marker, for the left end of a paired row.
781
+
782
+ Labels are never bold — the focus node is already distinguished by its fill.
783
+ """
784
+ facecolor = INK if focus else (NODE_FILL if inside else NODE_OPEN)
785
+ marker = ax.plot(
786
+ [x],
787
+ [y],
788
+ marker="o",
789
+ markersize=size,
790
+ linestyle="none",
791
+ markerfacecolor=facecolor,
792
+ markeredgecolor=NODE_EDGE,
793
+ markeredgewidth=0.7,
794
+ zorder=zorder,
795
+ **kwargs,
796
+ )[0]
797
+ if label:
798
+ if label_side == "right":
799
+ offset, align = label_offset, "left"
800
+ elif label_side == "left":
801
+ offset, align = -label_offset, "right"
802
+ else:
803
+ raise ValueError(f"label_side must be 'left' or 'right', got {label_side!r}")
804
+ opts: dict[str, Any] = {"ha": align, "va": "center", "color": INK}
805
+ if fontsize is not None:
806
+ opts["fontsize"] = fontsize
807
+ ax.text(x + offset * unit, y, label, zorder=zorder + 1, **opts)
808
+ return marker
809
+
810
+
811
+ def box_node(
812
+ ax: Axes,
813
+ x: float,
814
+ y: float,
815
+ lines: Sequence[str],
816
+ *,
817
+ width: float,
818
+ height: float,
819
+ inside: bool = True,
820
+ focus: bool = False,
821
+ fontsize: float | None = None,
822
+ sub_fontsize: float | None = None,
823
+ zorder: float = 4.0,
824
+ **kwargs: Any,
825
+ ) -> Any:
826
+ """Draw one network node as a box carrying a name and an attribute.
827
+
828
+ The box is :func:`draw_node` with room for a second fact: the first line is
829
+ the node's name, and any line after it is an attribute of that node — an
830
+ institution, a department — set smaller and in :data:`GRAYS` so it reads as
831
+ a subtitle rather than as a second name. The fill still carries membership
832
+ and nothing else, exactly as ``draw_node``: :data:`NODE_FILL` for a node in
833
+ the population being described, :data:`NODE_OPEN` for one outside it, ink
834
+ for the single node a figure is built around. On an ink fill the text flips
835
+ to white, since the alternative is a box with nothing legible in it.
836
+
837
+ ``x`` is the **left edge** and ``y`` the **vertical centre**, which is how a
838
+ tree router positions a node: it knows the column a generation starts at and
839
+ the row a person sits on. ``width`` and ``height`` are in data units and
840
+ arrive measured — this function draws a box at the size it is given and
841
+ never asks the renderer how wide the text is, because the layout needs those
842
+ widths before any axes exist.
843
+ """
844
+ from matplotlib.patches import Rectangle
845
+
846
+ facecolor = INK if focus else (BOX_FILL if inside else NODE_OPEN)
847
+ patch = Rectangle(
848
+ (x, y - height / 2),
849
+ width,
850
+ height,
851
+ facecolor=facecolor,
852
+ edgecolor=NODE_EDGE,
853
+ linewidth=0.7,
854
+ zorder=zorder,
855
+ **kwargs,
856
+ )
857
+ ax.add_patch(patch)
858
+
859
+ base = float(_pyplot().rcParams["font.size"]) if fontsize is None else float(fontsize)
860
+ sub = base - 1.1 if sub_fontsize is None else float(sub_fontsize)
861
+ head_color, sub_color = ("white", GRAYS[3]) if focus else (INK, GRAYS[1])
862
+ step = height / (len(lines) + 1)
863
+ for i, line in enumerate(lines):
864
+ ax.text(
865
+ x + width / 2,
866
+ y + height / 2 - (i + 1) * step,
867
+ line,
868
+ ha="center",
869
+ va="center",
870
+ color=head_color if i == 0 else sub_color,
871
+ fontsize=base if i == 0 else sub,
872
+ zorder=zorder + 1,
873
+ )
874
+ return patch
875
+
876
+
877
+ def flow_band(
878
+ ax: Axes,
879
+ x0: float,
880
+ x1: float,
881
+ *,
882
+ y_left: float,
883
+ y_right: float,
884
+ thickness: float,
885
+ fill: str = BAR_FILL,
886
+ edgecolor: str = INK,
887
+ linewidth: float = 0.4,
888
+ zorder: float = 2.0,
889
+ **kwargs: Any,
890
+ ) -> Any:
891
+ """Draw one ribbon of a two-column flow diagram.
892
+
893
+ A quadrilateral running from ``(x0, y_left)`` to ``(x1, y_right)``, of the
894
+ same ``thickness`` at both ends because both ends count the same people.
895
+ ``y_left`` and ``y_right`` are its **lower** edge, so a caller stacks bands
896
+ by carrying a running offset up each column.
897
+
898
+ The edges are straight rather than curved on purpose: a ribbon's job here is
899
+ to be countable, and a sheaf of Béziers reads as a texture. The hairline
900
+ outline is what keeps the lightest fill separable from its neighbour once
901
+ the figure goes through the grayscale proof.
902
+ """
903
+ from matplotlib.patches import Polygon
904
+
905
+ band = Polygon(
906
+ [
907
+ (x0, y_left),
908
+ (x1, y_right),
909
+ (x1, y_right + thickness),
910
+ (x0, y_left + thickness),
911
+ ],
912
+ closed=True,
913
+ facecolor=fill,
914
+ edgecolor=edgecolor,
915
+ linewidth=linewidth,
916
+ zorder=zorder,
917
+ **kwargs,
918
+ )
919
+ ax.add_patch(band)
920
+ return band
921
+
922
+
923
+ def node_legend(
924
+ ax: Axes,
925
+ inside_label: str,
926
+ outside_label: str,
927
+ *,
928
+ focus_label: str | None = None,
929
+ loc: str = "lower left",
930
+ size: float = NODE_RADIUS * 2,
931
+ shape: str = "circle",
932
+ **kwargs: Any,
933
+ ) -> Any:
934
+ """Frameless legend naming what a filled node means and what an open one means.
935
+
936
+ Entries follow the same fill vocabulary as :func:`draw_node`, so the legend
937
+ cannot drift from the marks it explains. ``shape="box"`` swaps the circular
938
+ proxies for rectangular ones, for a figure whose nodes are
939
+ :func:`box_node` — a legend of circles under a diagram of boxes invites the
940
+ reader to hunt for a distinction that is not there.
941
+ """
942
+ plt = _pyplot()
943
+ if shape not in ("circle", "box"):
944
+ raise ValueError(f"shape must be 'circle' or 'box', got {shape!r}")
945
+ inside_fill = BOX_FILL if shape == "box" else NODE_FILL
946
+ entries = [(inside_fill, inside_label), (NODE_OPEN, outside_label)]
947
+ if focus_label:
948
+ entries.insert(0, (INK, focus_label))
949
+ if shape == "box":
950
+ handles: list[Any] = [
951
+ plt.Rectangle(
952
+ (0, 0),
953
+ 1,
954
+ 1,
955
+ facecolor=fill,
956
+ edgecolor=NODE_EDGE,
957
+ linewidth=0.7,
958
+ label=text,
959
+ )
960
+ for fill, text in entries
961
+ ]
962
+ else:
963
+ handles = [
964
+ plt.Line2D(
965
+ [],
966
+ [],
967
+ marker="o",
968
+ linestyle="none",
969
+ markerfacecolor=fill,
970
+ markeredgecolor=NODE_EDGE,
971
+ markeredgewidth=0.7,
972
+ markersize=size,
973
+ label=text,
974
+ )
975
+ for fill, text in entries
976
+ ]
977
+ opts: dict[str, Any] = {
978
+ "frameon": False,
979
+ "loc": loc,
980
+ "ncol": len(handles),
981
+ "handlelength": 1.2,
982
+ "borderaxespad": 0.3,
983
+ "columnspacing": 1.5,
984
+ }
985
+ opts.update(kwargs)
986
+ return ax.legend(handles=handles, **opts)
987
+
988
+
989
+ def _label_anchors(reaches: Sequence[float]) -> tuple[tuple[float, float, str, str], ...]:
990
+ """Offsets to try for a label, nearest ring first.
991
+
992
+ Four sides, then four diagonals, then the same eight further out. One ring
993
+ is not enough: a force-directed layout routinely pulls five people into a
994
+ clump, and the fifth name needs somewhere to go that is not on top of the
995
+ fourth.
996
+ """
997
+ directions = (
998
+ (0.0, 1.0, "center", "bottom"),
999
+ (0.0, -1.0, "center", "top"),
1000
+ (1.0, 0.0, "left", "center"),
1001
+ (-1.0, 0.0, "right", "center"),
1002
+ (0.75, 0.75, "left", "bottom"),
1003
+ (-0.75, 0.75, "right", "bottom"),
1004
+ (0.75, -0.75, "left", "top"),
1005
+ (-0.75, -0.75, "right", "top"),
1006
+ )
1007
+ return tuple(
1008
+ (dx * reach, dy * reach, ha, va)
1009
+ for reach in reaches
1010
+ for dx, dy, ha, va in directions
1011
+ )
1012
+
1013
+
1014
+ def place_node_labels(
1015
+ ax: Axes,
1016
+ labels: Mapping[str, str],
1017
+ positions: Mapping[str, Any],
1018
+ *,
1019
+ node_sizes: Mapping[str, float] | float = NODE_RADIUS * 2,
1020
+ order: Sequence[str] | None = None,
1021
+ fontsize: float | str | None = None,
1022
+ reaches: Sequence[float] = (6.5, 13.0, 21.0),
1023
+ leader_beyond: float = 13.5,
1024
+ padding: tuple[float, float] = (1.08, 1.25),
1025
+ **kwargs: Any,
1026
+ ) -> dict[str, Annotation]:
1027
+ """Label nodes of a node-link drawing, moving each name off what it would hit.
1028
+
1029
+ A force-directed layout has no idea that its nodes carry names, so the
1030
+ naive "text just above the marker" collides — with another name, or with a
1031
+ marker belonging to somebody else. Each label here is drawn, *measured
1032
+ against the renderer*, and moved to the next anchor around its node when it
1033
+ clashes with a label already placed or with any node's marker. Measuring
1034
+ the rendered text rather than guessing at distances is what makes this hold
1035
+ for a different corpus, or the same corpus one stage later.
1036
+
1037
+ ``positions`` covers **every** node in the drawing, in data coordinates;
1038
+ ``labels`` names only the subset that gets text. The difference matters:
1039
+ an unlabelled node is still an obstacle, and treating it as absent is how a
1040
+ name ends up sitting on a marker. ``node_sizes`` are marker diameters in
1041
+ points, one per node or one for all, and set how wide a berth each marker
1042
+ gets.
1043
+
1044
+ Labels are placed in ``order`` (default: the sorted keys of ``labels``), so
1045
+ the caller decides who keeps the preferred anchor — pass the most prominent
1046
+ nodes first. A fixed order also makes the drawing reproducible, which a set
1047
+ or dict built from graph iteration would not be. When a label has to travel
1048
+ beyond ``leader_beyond`` points it gets a hairline leader back to its node,
1049
+ because a name far from every marker belongs to none of them.
1050
+
1051
+ Returns the annotations, keyed as ``labels`` was.
1052
+ """
1053
+ missing = sorted(key for key in labels if key not in positions)
1054
+ if missing:
1055
+ raise KeyError(f"no position for labelled node(s): {', '.join(missing)}")
1056
+
1057
+ figure = ax.figure
1058
+ figure.canvas.draw()
1059
+ renderer = figure.canvas.get_renderer()
1060
+
1061
+ def radius(key: str) -> float:
1062
+ size = node_sizes[key] if isinstance(node_sizes, Mapping) else node_sizes
1063
+ return float(size) / 2.0 + 1.5
1064
+
1065
+ obstacles = [
1066
+ (ax.transData.transform(positions[key]), radius(key)) for key in sorted(positions)
1067
+ ]
1068
+ anchors = _label_anchors(reaches)
1069
+ taken: list[Bbox] = []
1070
+ placed: dict[str, Annotation] = {}
1071
+
1072
+ def draw(key: str, anchor: tuple[float, float, str, str], *, leader: bool) -> Annotation:
1073
+ dx, dy, ha, va = anchor
1074
+ opts: dict[str, Any] = {"ha": ha, "va": va, "color": INK, "zorder": 6.0}
1075
+ if fontsize is not None:
1076
+ opts["fontsize"] = fontsize
1077
+ if leader:
1078
+ opts["arrowprops"] = {
1079
+ "arrowstyle": "-",
1080
+ "color": GRAYS[2],
1081
+ "linewidth": 0.4,
1082
+ "shrinkA": 0.0,
1083
+ "shrinkB": 2.0,
1084
+ }
1085
+ opts.update(kwargs)
1086
+ return ax.annotate(
1087
+ labels[key],
1088
+ positions[key],
1089
+ xytext=(dx, dy),
1090
+ textcoords="offset points",
1091
+ **opts,
1092
+ )
1093
+
1094
+ def needs_leader(anchor: tuple[float, float, str, str]) -> bool:
1095
+ return max(abs(anchor[0]), abs(anchor[1])) > leader_beyond
1096
+
1097
+ for key in (sorted(labels) if order is None else order):
1098
+ if key not in labels:
1099
+ continue
1100
+ chosen: Annotation | None = None
1101
+ box: Bbox | None = None
1102
+ least_bad: tuple[float, tuple[float, float, str, str]] | None = None
1103
+ picked: tuple[float, float, str, str] | None = None
1104
+ for anchor in anchors:
1105
+ # Measured without its leader on purpose: an annotation that owns an
1106
+ # arrow reports the arrow inside its extent, so a far anchor would
1107
+ # always measure as a collision and never be chosen at all.
1108
+ text = draw(key, anchor, leader=False)
1109
+ extent = text.get_window_extent(renderer=renderer).expanded(*padding)
1110
+ cost = sum(_overlap_area(extent, other) for other in taken) + sum(
1111
+ _circle_overlap(centre, r, extent) for centre, r in obstacles
1112
+ )
1113
+ if cost == 0.0:
1114
+ chosen, box, picked = text, extent, anchor
1115
+ break
1116
+ text.remove()
1117
+ if least_bad is None or cost < least_bad[0]:
1118
+ least_bad = (cost, anchor)
1119
+ if chosen is None:
1120
+ # Nowhere is clean. Take the least-bad anchor rather than the
1121
+ # preferred one: falling back to "just above the node" every time
1122
+ # stacks a whole crowd's names in one place, which is the exact
1123
+ # collision this function exists to avoid.
1124
+ assert least_bad is not None
1125
+ picked = least_bad[1]
1126
+ chosen = draw(key, picked, leader=False)
1127
+ box = chosen.get_window_extent(renderer=renderer).expanded(*padding)
1128
+ if needs_leader(picked):
1129
+ chosen.remove()
1130
+ chosen = draw(key, picked, leader=True)
1131
+ taken.append(box)
1132
+ placed[key] = chosen
1133
+ return placed
1134
+
1135
+
1136
+ def _overlap_area(box: Bbox, other: Bbox) -> float:
1137
+ """Area shared by two display-space boxes, zero when they are disjoint."""
1138
+ width = min(box.x1, other.x1) - max(box.x0, other.x0)
1139
+ height = min(box.y1, other.y1) - max(box.y0, other.y0)
1140
+ return width * height if width > 0 and height > 0 else 0.0
1141
+
1142
+
1143
+ def _circle_overlap(centre: Any, radius: float, box: Bbox) -> float:
1144
+ """How deeply a node marker intrudes into a label box, as a penalty area.
1145
+
1146
+ The exact lens area is not worth computing: this only has to order
1147
+ candidate anchors, and squared intrusion depth does that monotonically.
1148
+ """
1149
+ nearest_x = min(max(float(centre[0]), box.x0), box.x1)
1150
+ nearest_y = min(max(float(centre[1]), box.y0), box.y1)
1151
+ gap = math.hypot(float(centre[0]) - nearest_x, float(centre[1]) - nearest_y)
1152
+ return (radius - gap) ** 2 if gap < radius else 0.0
1153
+
1154
+
1155
+ def separator_rule(
1156
+ ax: Axes,
1157
+ y: float,
1158
+ *,
1159
+ xmin: float = 0.0,
1160
+ xmax: float = 1.0,
1161
+ color: str = RULE_COLOR,
1162
+ linewidth: float = 0.6,
1163
+ zorder: float = 0.5,
1164
+ **kwargs: Any,
1165
+ ) -> Line2D:
1166
+ """Hairline separating one band of a grouped figure from the next.
1167
+
1168
+ Lighter than any data mark, drawn under everything, so it groups rows
1169
+ without reading as a gridline.
1170
+ """
1171
+ return ax.axhline(
1172
+ y,
1173
+ xmin=xmin,
1174
+ xmax=xmax,
1175
+ color=color,
1176
+ linewidth=linewidth,
1177
+ linestyle="-",
1178
+ zorder=zorder,
1179
+ **kwargs,
1180
+ )
1181
+
1182
+
1183
+ def show_caption(
1184
+ caption: str,
1185
+ *,
1186
+ notes: str | None = None,
1187
+ source: str | None = None,
1188
+ number: int | str | None = None,
1189
+ ) -> None:
1190
+ """Render the caption block as Markdown under a figure in a notebook.
1191
+
1192
+ The display counterpart of :func:`save_figure`'s caption sidecar, so the
1193
+ caption a reader sees in the notebook is built by the same code that writes
1194
+ the file next to the artwork.
1195
+ """
1196
+ from IPython.display import Markdown, display
1197
+
1198
+ display(Markdown(caption_markdown(caption, notes=notes, source=source, number=number)))
1199
+
1200
+
1201
+ def grayscale_preview(png_path: str | Path, out_path: str | Path | None = None) -> Path:
1202
+ """Write a grayscale copy of a PNG so the "legible in black and white"
1203
+ requirement can be checked by eye. Returns the output path."""
1204
+ from PIL import Image
1205
+
1206
+ png_path = Path(png_path)
1207
+ out = (
1208
+ Path(out_path)
1209
+ if out_path is not None
1210
+ else png_path.with_name(f"{png_path.stem}.gray{png_path.suffix}")
1211
+ )
1212
+ with Image.open(png_path) as img:
1213
+ img.convert("L").save(out)
1214
+ return out