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/__init__.py +77 -0
- paperplot/core.py +228 -0
- paperplot/data/fonts/GUST-FONT-LICENSE.txt +29 -0
- paperplot/data/fonts/LICENSE.md +22 -0
- paperplot/data/fonts/texgyreheros-bold.otf +0 -0
- paperplot/data/fonts/texgyreheros-bolditalic.otf +0 -0
- paperplot/data/fonts/texgyreheros-italic.otf +0 -0
- paperplot/data/fonts/texgyreheros-regular.otf +0 -0
- paperplot/data/journals.toml +123 -0
- paperplot/fonts.py +45 -0
- paperplot/journals.py +57 -0
- paperplot/layout.py +133 -0
- paperplot/lint.py +156 -0
- paperplot/mplstyle.py +103 -0
- paperplot/palettes.py +190 -0
- paperplot/plots.py +310 -0
- paperplot/preview.py +250 -0
- paperplot/registry.py +111 -0
- paperplot/save.py +99 -0
- paperplot/style.py +194 -0
- paperplot_quantum-0.1.0.dist-info/METADATA +281 -0
- paperplot_quantum-0.1.0.dist-info/RECORD +27 -0
- paperplot_quantum-0.1.0.dist-info/WHEEL +5 -0
- paperplot_quantum-0.1.0.dist-info/licenses/LICENSE +21 -0
- paperplot_quantum-0.1.0.dist-info/licenses/paperplot/data/fonts/GUST-FONT-LICENSE.txt +29 -0
- paperplot_quantum-0.1.0.dist-info/licenses/paperplot/data/fonts/LICENSE.md +22 -0
- paperplot_quantum-0.1.0.dist-info/top_level.txt +1 -0
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
|