paperplot-quantum 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.
paperplot/plots.py ADDED
@@ -0,0 +1,310 @@
1
+ """Opinionated publication plot helpers — the few composites that come up over and
2
+ over in physics figures and that bare matplotlib makes fiddly:
3
+
4
+ * :func:`hist_outline` — a translucent filled histogram with a crisp staircase
5
+ outline drawn on top. The outline is what keeps *overlapping* histograms
6
+ legible; ``ax.hist`` alone muddies them.
7
+ * :func:`hist_filled` — the lighter, centers-based shaded histogram
8
+ (``fill_between(step="mid")`` + ``steps-mid`` line), with optional peak rescale.
9
+ * :func:`data_fit_band` — the "data points (error bars, black-edged markers) +
10
+ bold fit line + shaded confidence band" composite used for ZNE / decay fits.
11
+ * :func:`swatches` — render color swatches to eyeball a palette (the inline
12
+ ``display(sns.color_palette(...))`` experience, but in matplotlib).
13
+
14
+ matplotlib-only by default; :func:`hist_outline` has an opt-in ``use_seaborn``
15
+ path that draws the fill via ``seaborn.histplot`` (KDE, multiple stats, etc.).
16
+ The default colors follow paperplot's fill/stroke convention: muted fills under
17
+ bright/black strokes (see :func:`paperplot.fills` / :func:`paperplot.strokes`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Optional
23
+
24
+ import numpy as np
25
+
26
+ from . import palettes
27
+
28
+
29
+ def _ensure_ax(ax):
30
+ if ax is None:
31
+ import matplotlib.pyplot as plt
32
+ _, ax = plt.subplots()
33
+ return ax
34
+
35
+
36
+ def _staircase(counts, edges):
37
+ """Bin counts/edges -> (x, y) tracing the histogram outline as a staircase."""
38
+ x = np.repeat(edges, 2)[1:-1]
39
+ y = np.repeat(counts, 2)
40
+ return x, y
41
+
42
+
43
+ def hist_outline(
44
+ data,
45
+ ax=None,
46
+ *,
47
+ bins=50,
48
+ range=None,
49
+ density=False,
50
+ rescale=False,
51
+ color=None,
52
+ alpha=0.6,
53
+ fill=True,
54
+ label=None,
55
+ outline_color="k",
56
+ outline_lw=1.0,
57
+ outline_kw: Optional[dict] = None,
58
+ zorder=None,
59
+ use_seaborn=False,
60
+ **fill_kw,
61
+ ):
62
+ """Filled histogram with a solid staircase outline traced over the bars.
63
+
64
+ Returns ``(ax, (counts, edges))``. The fill carries the legend ``label``.
65
+
66
+ Parameters
67
+ ----------
68
+ data : array-like
69
+ Samples to histogram.
70
+ bins, range, density :
71
+ Passed to ``numpy.histogram`` (and to ``seaborn.histplot`` when
72
+ ``use_seaborn=True``). ``density`` controls the y-scale.
73
+ rescale : bool
74
+ Normalize counts so the tallest bin is 1 (a "relative probability"
75
+ y-axis). Ignored when ``density=True``.
76
+ color : color, optional
77
+ Fill color. Defaults to the first muted fill color (``pp.fills()[0]``).
78
+ outline_color, outline_lw, outline_kw :
79
+ The staircase outline. ``outline_kw`` overrides any of these per call.
80
+ use_seaborn : bool
81
+ Draw the fill via ``seaborn.histplot`` (enables KDE, stat=, etc. through
82
+ ``**fill_kw``) instead of ``ax.fill_between``. The outline is always
83
+ matplotlib. Raises ``ImportError`` if seaborn is not installed.
84
+ """
85
+ ax = _ensure_ax(ax)
86
+ if color is None:
87
+ color = palettes.fills()[0]
88
+ z = {} if zorder is None else {"zorder": zorder}
89
+
90
+ counts, edges = np.histogram(data, bins=bins, range=range, density=density)
91
+ counts = counts.astype(float)
92
+ if rescale and not density and counts.max() > 0:
93
+ counts = counts / counts.max()
94
+
95
+ if use_seaborn:
96
+ import seaborn as sns # optional extra; only imported on request
97
+ sns.histplot(
98
+ data, ax=ax, bins=bins, binrange=range,
99
+ stat="density" if density else "count",
100
+ color=color, alpha=alpha, label=label, **z, **fill_kw,
101
+ )
102
+ elif fill:
103
+ x, y = _staircase(counts, edges)
104
+ ax.fill_between(x, y, color=color, alpha=alpha, linewidth=0,
105
+ label=label, **z, **fill_kw)
106
+
107
+ x, y = _staircase(counts, edges)
108
+ okw = {"color": outline_color, "linestyle": "-", "linewidth": outline_lw}
109
+ okw.update(z)
110
+ if outline_kw:
111
+ okw.update(outline_kw)
112
+ # If there is no fill to carry the label (fill=False, no seaborn), let the
113
+ # outline carry it instead.
114
+ if label is not None and not fill and not use_seaborn:
115
+ okw.setdefault("label", label)
116
+ ax.plot(x, y, **okw)
117
+
118
+ return ax, (counts, edges)
119
+
120
+
121
+ def hist_filled(
122
+ data,
123
+ ax=None,
124
+ *,
125
+ bins=50,
126
+ range=None,
127
+ density=False,
128
+ rescale=False,
129
+ color=None,
130
+ alpha=0.7,
131
+ label=None,
132
+ line_color="k",
133
+ line_lw=1.0,
134
+ line_kw: Optional[dict] = None,
135
+ fill_kw: Optional[dict] = None,
136
+ zorder=None,
137
+ ):
138
+ """Shaded histogram drawn on bin *centers* (``steps-mid``), with a line on top.
139
+
140
+ Lighter sibling of :func:`hist_outline` — the shape is centered on bins rather
141
+ than tracing the bar edges. Returns ``(counts, edges, centers)``.
142
+ """
143
+ ax = _ensure_ax(ax)
144
+ if color is None:
145
+ color = palettes.fills()[0]
146
+ z = {} if zorder is None else {"zorder": zorder}
147
+
148
+ counts, edges = np.histogram(data, bins=bins, range=range, density=density)
149
+ counts = counts.astype(float)
150
+ if rescale and not density and counts.max() > 0:
151
+ counts = counts / counts.max()
152
+ centers = (edges[:-1] + edges[1:]) / 2
153
+
154
+ fkw = {"step": "mid", "alpha": alpha, "color": color, "linewidth": 0,
155
+ "label": label}
156
+ fkw.update(z)
157
+ if fill_kw:
158
+ fkw.update(fill_kw)
159
+ ax.fill_between(centers, counts, **fkw)
160
+
161
+ lkw = {"drawstyle": "steps-mid", "color": line_color, "linewidth": line_lw}
162
+ lkw.update(z)
163
+ if line_kw:
164
+ lkw.update(line_kw)
165
+ ax.plot(centers, counts, **lkw)
166
+
167
+ return counts, edges, centers
168
+
169
+
170
+ def data_fit_band(
171
+ ax,
172
+ x,
173
+ y,
174
+ *,
175
+ yerr=None,
176
+ x_fit=None,
177
+ y_fit=None,
178
+ y_fit_err=None,
179
+ color=None,
180
+ fit_color=None,
181
+ band_color=None,
182
+ label=None,
183
+ fit_label=None,
184
+ band_alpha=0.2,
185
+ data_kw: Optional[dict] = None,
186
+ fit_kw: Optional[dict] = None,
187
+ band_kw: Optional[dict] = None,
188
+ ):
189
+ """Plot measured data with error bars, an overlaid fit line, and a CI band.
190
+
191
+ The standard decay-fit composite:
192
+
193
+ * data as black-edged markers with error bars (``capsize``, thin ``mew``),
194
+ * a bold fit line drawn *behind* the markers (``zorder=0``),
195
+ * a translucent confidence band from ``y_fit ± y_fit_err`` (``zorder=-1``).
196
+
197
+ Pass ``x_fit``/``y_fit`` (e.g. from ``lmfit`` ``result.eval``) to draw the
198
+ fit, and ``y_fit_err`` (e.g. ``result.eval_uncertainty``) to add the band.
199
+ ``color`` is the data color; ``fit_color`` and ``band_color`` default to it.
200
+ """
201
+ if color is None:
202
+ color = palettes.strokes()[0]
203
+ if fit_color is None:
204
+ fit_color = color
205
+ if band_color is None:
206
+ band_color = fit_color
207
+
208
+ dkw = {"fmt": "o", "ms": 3, "mec": "k", "mew": 0.25, "lw": 0.5,
209
+ "capsize": 3, "color": color, "label": label}
210
+ if data_kw:
211
+ dkw.update(data_kw)
212
+ ax.errorbar(x, y, yerr=yerr, **dkw)
213
+
214
+ if x_fit is not None and y_fit is not None:
215
+ fkw = {"lw": 2.0, "zorder": 0, "color": fit_color, "label": fit_label}
216
+ if fit_kw:
217
+ fkw.update(fit_kw)
218
+ ax.plot(x_fit, y_fit, **fkw)
219
+
220
+ if y_fit_err is not None:
221
+ bkw = {"alpha": band_alpha, "color": band_color, "zorder": -1,
222
+ "linewidth": 0}
223
+ if band_kw:
224
+ bkw.update(band_kw)
225
+ y_fit = np.asarray(y_fit)
226
+ y_fit_err = np.asarray(y_fit_err)
227
+ ax.fill_between(x_fit, y_fit - y_fit_err, y_fit + y_fit_err, **bkw)
228
+
229
+ return ax
230
+
231
+
232
+ def _draw_swatch_rows(ax, rows, *, tags=None, tag_color="#1a7f4b"):
233
+ """Draw labeled swatch rows on ``ax``. ``rows`` is a list of (name, colors);
234
+ ``tags`` is an optional list of short right-side annotations (one per row)."""
235
+ import matplotlib.pyplot as plt
236
+
237
+ nrows = len(rows)
238
+ ncols = max((len(c) for _, c in rows), default=0)
239
+ for r, (name, cols) in enumerate(rows):
240
+ yr = nrows - 1 - r
241
+ for i, c in enumerate(cols):
242
+ ax.add_patch(plt.Rectangle((i, yr + 0.04), 0.92, 0.92, color=c))
243
+ if name:
244
+ ax.text(-0.25, yr + 0.5, str(name), ha="right", va="center")
245
+ if tags and tags[r]:
246
+ ax.text(ncols + 0.25, yr + 0.5, tags[r], ha="left", va="center",
247
+ color=tag_color, weight="bold")
248
+ ax.set_xlim(0, ncols)
249
+ ax.set_ylim(0, nrows)
250
+ ax.axis("off")
251
+ return ncols, nrows
252
+
253
+
254
+ def swatches(colors, ax=None, *, labels=None, size=0.6):
255
+ """Render color swatches — eyeball a palette or compare fills vs strokes.
256
+
257
+ ``colors`` is a list of colors (one row) or a ``{name: [colors]}`` dict (one
258
+ row per name). Returns the ``Figure``. The matplotlib analogue of
259
+ ``display(sns.color_palette(...))`` in a notebook.
260
+ """
261
+ import matplotlib.pyplot as plt
262
+
263
+ if isinstance(colors, dict):
264
+ rows = list(colors.items())
265
+ else:
266
+ rows = [(labels if isinstance(labels, str) else "", list(colors))]
267
+
268
+ ncols = max((len(c) for _, c in rows), default=0)
269
+ if ax is None:
270
+ fig, ax = plt.subplots(figsize=(1.6 + size * ncols, 0.3 + size * len(rows)))
271
+ else:
272
+ fig = ax.figure
273
+ _draw_swatch_rows(ax, rows)
274
+ ax.set_aspect("equal")
275
+ return fig
276
+
277
+
278
+ def show_palettes(names=None, *, n=10, size=0.42):
279
+ """First-class palette reference: swatches for every available palette, each
280
+ tagged with whether it is colorblind-safe, plus a one-line summary footnote.
281
+
282
+ ``names`` defaults to :func:`paperplot.available_palettes` (built-in +
283
+ anything you registered). Returns the ``Figure``.
284
+ """
285
+ import matplotlib.pyplot as plt
286
+ from . import palettes
287
+
288
+ if names is None:
289
+ names = palettes.available_palettes()
290
+
291
+ rows, tags = [], []
292
+ for nm in names:
293
+ rows.append((nm, palettes.palette(nm, n)))
294
+ tags.append("colorblind-safe" if palettes.is_colorblind_safe(nm) else "")
295
+
296
+ nrows = len(rows)
297
+ fig_w = 3.8 + size * n # label gutter + swatches + tag gutter
298
+ fig_h = 0.7 + size * nrows
299
+ fig = plt.figure(figsize=(fig_w, fig_h))
300
+ # Fixed-inch gutters so labels/tags never clip; swatch block fills the middle.
301
+ left = 1.4 / fig_w
302
+ ax = fig.add_axes([left, 0.55 / fig_h,
303
+ (size * n) / fig_w, (size * nrows) / fig_h])
304
+ _draw_swatch_rows(ax, rows, tags=tags)
305
+
306
+ fig.text(0.5, 0.12 / fig_h,
307
+ "Green tag = stays distinguishable under color-vision deficiency. "
308
+ "muted = fills | bright = strokes (pair them; not for categorical lines).",
309
+ ha="center", va="bottom", fontsize=7, color="0.35")
310
+ return fig
paperplot/preview.py ADDED
@@ -0,0 +1,250 @@
1
+ """Notebook preview helpers for small publication figures.
2
+
3
+ - ``show(fig, zoom)`` : magnify the real figure (SVG) without mutating it.
4
+ - ``preview_in_page`` : embed the figure at true scale in a mock journal page.
5
+ - ``grayscale_proof`` : render desaturated to check APS print legibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import io
12
+
13
+ import matplotlib.pyplot as plt
14
+
15
+ from . import layout
16
+
17
+ # pp.style() shadows the style submodule on the package; reach it via sys.modules.
18
+ _style = importlib.import_module("paperplot.style")
19
+
20
+ # Bundled placeholder text (no external 'lorem' dependency).
21
+ LOREM = (
22
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
23
+ "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, "
24
+ "quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo "
25
+ "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse "
26
+ "cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non "
27
+ "proident, sunt in culpa qui officia deserunt mollit anim id est laborum. "
28
+ )
29
+
30
+
31
+ def _svg_bytes(fig) -> bytes:
32
+ buf = io.BytesIO()
33
+ fig.savefig(buf, format="svg") # no bbox_inches='tight' -> stable geometry
34
+ return buf.getvalue()
35
+
36
+
37
+ def show(fig, zoom: float = 2.0):
38
+ """Display ``fig`` magnified by ``zoom`` in a notebook. Figure untouched.
39
+
40
+ Renders to SVG (vector, crisp at any zoom) and scales the <svg> width/height.
41
+ Returns the IPython SVG object (also auto-displays in a notebook).
42
+ """
43
+ try:
44
+ from IPython.display import SVG, display
45
+ except ImportError as e: # optional extra: paperplot[notebook]
46
+ raise ImportError(
47
+ "show() needs IPython. Install paperplot[notebook] or use "
48
+ "preview_in_page() (pure matplotlib)."
49
+ ) from e
50
+
51
+ svg = _svg_bytes(fig).decode("utf-8")
52
+ w_in, h_in = fig.get_size_inches()
53
+ style_attr = f'style="width:{w_in * zoom:.3f}in;height:{h_in * zoom:.3f}in;"'
54
+ # inject a sizing style on the first <svg ...> tag
55
+ svg = svg.replace("<svg ", f"<svg {style_attr} ", 1)
56
+ obj = SVG(data=svg)
57
+ display(obj)
58
+ return obj
59
+
60
+
61
+ def _word_width(bg, word, pt, family, renderer, cache):
62
+ """Measured width of ``word`` in inches (cached)."""
63
+ if word not in cache:
64
+ tmp = bg.text(0, -1000, word, fontsize=pt, family=family)
65
+ cache[word] = tmp.get_window_extent(renderer).width / renderer.dpi
66
+ tmp.remove()
67
+ return cache[word]
68
+
69
+
70
+ def _wrap_to_width(bg, body, col_w, pt, family, renderer, cache):
71
+ """Break ``body`` into lines whose *measured* width never exceeds ``col_w``.
72
+
73
+ Character-count wrapping (textwrap) can run a word past the column edge when
74
+ its width estimate is slightly off; measuring each word guarantees every line
75
+ fits — which also keeps the justification gap non-negative (no overflow).
76
+ """
77
+ space_w = max(0.0, (_word_width(bg, "n n", pt, family, renderer, cache)
78
+ - _word_width(bg, "nn", pt, family, renderer, cache)))
79
+ lines, cur, cur_w = [], [], 0.0
80
+ for word in body.split():
81
+ ww = _word_width(bg, word, pt, family, renderer, cache)
82
+ add = ww + (space_w if cur else 0.0)
83
+ if cur and cur_w + add > col_w:
84
+ lines.append(" ".join(cur))
85
+ cur, cur_w = [word], ww
86
+ else:
87
+ cur.append(word)
88
+ cur_w += add
89
+ if cur:
90
+ lines.append(" ".join(cur))
91
+ return lines
92
+
93
+
94
+ def _justify_column(bg, x0, y_top, col_w, chunk, pt, family, color,
95
+ linespacing, renderer, cache):
96
+ """Render a column of text fully justified (last line left-aligned)."""
97
+ line_h = pt / 72.0 * linespacing
98
+ y = y_top
99
+ n = len(chunk)
100
+ for i, line in enumerate(chunk):
101
+ words = line.split()
102
+ is_last = i == n - 1
103
+ if len(words) > 1 and not is_last:
104
+ widths = [_word_width(bg, w, pt, family, renderer, cache) for w in words]
105
+ gap = (col_w - sum(widths)) / (len(words) - 1)
106
+ if 0 <= gap <= col_w: # sane spacing only
107
+ x = x0
108
+ for w, ww in zip(words, widths):
109
+ bg.text(x, y, w, fontsize=pt, family=family, color=color,
110
+ va="top", ha="left")
111
+ x += ww + gap
112
+ y -= line_h
113
+ continue
114
+ bg.text(x0, y, line, fontsize=pt, family=family, color=color,
115
+ va="top", ha="left") # ragged fallback / last line
116
+ y -= line_h
117
+
118
+
119
+ def preview_in_page(fig, width="single", *, journal=None, page=(8.5, 11.0),
120
+ columns: int = 2, dpi: int = 150, text=None,
121
+ body_pt=None, show_info: bool = True, justify: bool = True,
122
+ figure_box: bool = False):
123
+ """Embed ``fig`` at true physical scale inside a mock journal page.
124
+
125
+ The surrounding text is real placeholder prose (lorem ipsum by default,
126
+ override via ``text``) rendered in serif at the journal's body font size
127
+ (``spec.page_body_pt``), so the figure-to-text scale is faithful. A header
128
+ lists the journal, column, physical width, and the body/figure font sizes.
129
+
130
+ Args:
131
+ justify: fully justify the body text (default True) so column edges line
132
+ up with the figure; set False for ragged-right.
133
+ figure_box: draw the figure's bounding box (default False) — useful to
134
+ confirm the figure width equals the column width.
135
+
136
+ Returns a NEW matplotlib Figure (the proof). True scale holds when the proof
137
+ is viewed/printed at 100%.
138
+ """
139
+ import matplotlib.image as mpimg
140
+ import matplotlib.patches as mpatches
141
+
142
+ spec = _style.resolve_spec(journal)
143
+ wkey = layout.resolve_width(width)
144
+ w_lookup = "double" if wkey == "full_page" else wkey
145
+ body_pt = float(body_pt if body_pt is not None else spec.page_body_pt)
146
+ page_w, page_h = page
147
+ fig_w_in, fig_h_in = fig.get_size_inches()
148
+
149
+ # Make the mock page geometrically faithful: page columns == journal columns.
150
+ col_w = layout.mm_to_in(spec.widths_mm["single"])
151
+ double_w = layout.mm_to_in(spec.widths_mm.get("double", 2 * spec.widths_mm["single"]))
152
+ gutter = max(0.12, double_w - 2 * col_w) if columns == 2 else 0.2
153
+ block_w = columns * col_w + (columns - 1) * gutter
154
+ margin_x = max(0.4, (page_w - block_w) / 2)
155
+ margin_y = 0.8
156
+
157
+ proof = plt.figure(figsize=(page_w, page_h), dpi=dpi)
158
+ proof.patch.set_facecolor("white")
159
+ bg = proof.add_axes([0, 0, 1, 1])
160
+ bg.set_xlim(0, page_w)
161
+ bg.set_ylim(0, page_h)
162
+ bg.axis("off")
163
+ top = page_h - margin_y
164
+
165
+ # --- info header: list exactly what this is ---
166
+ if show_info:
167
+ w_in = layout.mm_to_in(spec.widths_mm[w_lookup])
168
+ info = (f"{spec.name} · {wkey} column · "
169
+ f"{spec.widths_mm[w_lookup]:.0f} mm ({w_in:.2f} in) wide · "
170
+ f"body {body_pt:g} pt · figure text {spec.font_pt.base:g} pt")
171
+ bg.text(margin_x, page_h - 0.45, info, fontsize=7, family="sans-serif",
172
+ color="0.45", va="bottom", ha="left")
173
+ bg.plot([margin_x, margin_x + block_w], [page_h - 0.52] * 2,
174
+ color="0.8", lw=0.6)
175
+
176
+ # --- embed the real figure at true inches (top of column 0) ---
177
+ spans = fig_w_in > col_w * 1.5
178
+ fig_bottom = top - fig_h_in
179
+ ax_fig = proof.add_axes([margin_x / page_w, fig_bottom / page_h,
180
+ fig_w_in / page_w, fig_h_in / page_h])
181
+ png = io.BytesIO()
182
+ fig.savefig(png, format="png", dpi=300, facecolor="white")
183
+ png.seek(0)
184
+ ax_fig.imshow(mpimg.imread(png))
185
+ ax_fig.axis("off")
186
+ if figure_box:
187
+ bg.add_patch(mpatches.Rectangle(
188
+ (margin_x, fig_bottom), fig_w_in, fig_h_in,
189
+ fill=False, edgecolor="0.55", lw=0.8))
190
+
191
+ # --- figure caption (serif, italic, at figure text size) ---
192
+ cap_pt = spec.font_pt.base
193
+ cap_y = fig_bottom - 0.07
194
+ bg.text(margin_x, cap_y,
195
+ f"FIG. 1. Placeholder figure shown at true {wkey}-column scale.",
196
+ fontsize=cap_pt, family="serif", style="italic",
197
+ va="top", ha="left", color="0.2")
198
+ after_caption = cap_y - cap_pt / 72.0 * 1.7 - 0.08
199
+
200
+ # --- body text: real lorem ipsum at the journal body size, flowing columns ---
201
+ body = text if text is not None else (LOREM * 14)
202
+ linespacing = 1.45
203
+ line_h = body_pt / 72.0 * linespacing
204
+
205
+ # a renderer is needed to measure word widths (for wrapping + justification)
206
+ proof.canvas.draw()
207
+ renderer = proof.canvas.get_renderer()
208
+ cache: dict = {}
209
+ # Wrap by measured width so no word ever spills past the column edge.
210
+ lines = _wrap_to_width(bg, body, col_w, body_pt, "serif", renderer, cache)
211
+
212
+ idx = 0
213
+ for c in range(columns):
214
+ x0 = margin_x + c * (col_w + gutter)
215
+ if spans:
216
+ col_top = after_caption
217
+ else:
218
+ col_top = after_caption if c == 0 else top
219
+ nfit = max(0, int((col_top - margin_y) / line_h))
220
+ chunk = lines[idx:idx + nfit]
221
+ idx += nfit
222
+ if not chunk:
223
+ continue
224
+ if justify:
225
+ _justify_column(bg, x0, col_top, col_w, chunk, body_pt, "serif",
226
+ "0.15", linespacing, renderer, cache)
227
+ else:
228
+ bg.text(x0, col_top, "\n".join(chunk), fontsize=body_pt,
229
+ family="serif", color="0.15", va="top", ha="left",
230
+ linespacing=linespacing)
231
+ return proof
232
+
233
+
234
+ def grayscale_proof(fig, dpi: int = 200):
235
+ """Return a NEW figure showing ``fig`` desaturated (APS print reality)."""
236
+ import numpy as np
237
+ import matplotlib.image as mpimg
238
+
239
+ buf = io.BytesIO()
240
+ fig.savefig(buf, format="png", dpi=dpi, facecolor="white")
241
+ buf.seek(0)
242
+ rgb = mpimg.imread(buf)[..., :3]
243
+ gray = rgb @ np.array([0.2126, 0.7152, 0.0722])
244
+
245
+ w_in, h_in = fig.get_size_inches()
246
+ proof = plt.figure(figsize=(w_in, h_in), dpi=dpi)
247
+ ax = proof.add_axes([0, 0, 1, 1])
248
+ ax.imshow(gray, cmap="gray", vmin=0, vmax=1)
249
+ ax.axis("off")
250
+ return proof
paperplot/registry.py ADDED
@@ -0,0 +1,111 @@
1
+ """Load journal specs from ``data/journals.toml`` into ``JournalSpec`` objects.
2
+
3
+ This is the layer between TOML and the frozen dataclasses: it reads the data
4
+ file via ``importlib.resources``, coerces types (TOML lists -> tuples), merges
5
+ variant overrides over their base, validates, and caches the result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ from functools import lru_cache
12
+ from importlib.resources import files
13
+ from typing import Dict
14
+
15
+ try: # Python 3.11+
16
+ import tomllib
17
+ except ModuleNotFoundError: # Python 3.10 backport
18
+ import tomli as tomllib # type: ignore
19
+
20
+ from .journals import FontScale, JournalSpec
21
+
22
+ _REQUIRED = {
23
+ "name",
24
+ "revision",
25
+ "widths_mm",
26
+ "page_height_mm",
27
+ "caption_reserve_mm",
28
+ "min_linewidth_pt",
29
+ "font_family",
30
+ "font_pt",
31
+ "rasterize_dpi",
32
+ }
33
+
34
+
35
+ def _read_data() -> dict:
36
+ text = files("paperplot").joinpath("data", "journals.toml").read_text("utf-8")
37
+ return tomllib.loads(text)
38
+
39
+
40
+ def _deep_merge(base: dict, over: dict) -> dict:
41
+ """Recursively merge ``over`` onto ``base`` so a variant can override a single
42
+ nested field (e.g. font_pt.base) without dropping its siblings."""
43
+ out = dict(base)
44
+ for k, v in over.items():
45
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
46
+ out[k] = _deep_merge(out[k], v)
47
+ else:
48
+ out[k] = v
49
+ return out
50
+
51
+
52
+ def _build_spec(d: dict) -> JournalSpec:
53
+ missing = _REQUIRED - d.keys()
54
+ if missing:
55
+ raise ValueError(f"Journal spec {d.get('name', '?')!r} missing keys: {sorted(missing)}")
56
+ return JournalSpec(
57
+ name=d["name"],
58
+ revision=d["revision"],
59
+ widths_mm={k: float(v) for k, v in d["widths_mm"].items()},
60
+ page_height_mm=float(d["page_height_mm"]),
61
+ caption_reserve_mm=float(d["caption_reserve_mm"]),
62
+ font_family=tuple(d["font_family"]), # TOML list -> tuple
63
+ font_pt=FontScale(**d["font_pt"]),
64
+ min_linewidth_pt=float(d["min_linewidth_pt"]),
65
+ page_body_pt=float(d.get("page_body_pt", 10.0)),
66
+ rasterize_dpi={k: int(v) for k, v in d["rasterize_dpi"].items()},
67
+ )
68
+
69
+
70
+ @lru_cache(maxsize=1)
71
+ def _registry() -> Dict[str, JournalSpec]:
72
+ data = _read_data()
73
+ specs: Dict[str, JournalSpec] = {}
74
+ for key, raw in data.get("journal", {}).items():
75
+ variants = raw.pop("variants", {}) if isinstance(raw, dict) else {}
76
+ base = _build_spec(raw)
77
+ specs[key.lower()] = base
78
+ for vkey, override in variants.items():
79
+ # deep per-field override so a partial nested table (e.g. just
80
+ # font_pt.base) keeps the base journal's sibling values.
81
+ merged = _deep_merge(raw, override)
82
+ specs[vkey.lower()] = _build_spec(merged)
83
+ return specs
84
+
85
+
86
+ def available() -> list[str]:
87
+ """Sorted list of known journal keys (including variants)."""
88
+ return sorted(_registry())
89
+
90
+
91
+ def get_spec(key: str) -> JournalSpec:
92
+ """Look up a spec by key, e.g. ``"aps"`` or ``"prl"``.
93
+
94
+ A ``"key@revision"`` suffix is accepted and validated against the spec's
95
+ own revision (reproducibility pin).
96
+ """
97
+ name, _, revision = str(key).strip().lower().partition("@")
98
+ reg = _registry()
99
+ try:
100
+ spec = reg[name]
101
+ except KeyError:
102
+ hint = difflib.get_close_matches(name, reg, n=1)
103
+ suffix = f" Did you mean {hint[0]!r}?" if hint else ""
104
+ raise KeyError(
105
+ f"Unknown journal {key!r}. Available: {available()}.{suffix}"
106
+ ) from None
107
+ if revision and revision != spec.revision.lower():
108
+ raise ValueError(
109
+ f"Journal {name!r} is revision {spec.revision!r}, not {revision!r}."
110
+ )
111
+ return spec