plotastro 1.0.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.
plotastro/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """plotastro — publication-quality matplotlib figures for astronomy journals.
2
+
3
+ One import gives you journal-matched styles, correctly sized figures and a
4
+ colour-blind-friendly palette:
5
+
6
+ import matplotlib.pyplot as plt
7
+ import plotastro as pa
8
+
9
+ pa.set_style("mnras") # or "aanda", "apj", "oja", "prd", ...
10
+ fig, ax = pa.subplots() # one-column, golden-ratio figure
11
+ ax.plot(x, y, label="model")
12
+ ax.set_xlabel("$x$")
13
+ ax.legend()
14
+ pa.savefig("myplot") # -> myplot.pdf
15
+
16
+ Importing plotastro also registers the styles with matplotlib itself, so
17
+ ``plt.style.use("mnras")`` works anywhere afterwards.
18
+
19
+ Supported journals: MNRAS, RASTI, A&A, ApJ/ApJL (AASTeX), the Open Journal
20
+ of Astrophysics, PRD/PRL (REVTeX), JCAP and Nature Astronomy — plus
21
+ "thesis" and "beamer" width presets. See the README for the full tutorial.
22
+ """
23
+
24
+ from importlib.metadata import PackageNotFoundError, version as _version
25
+
26
+ import matplotlib as _mpl
27
+ import matplotlib.style as _mstyle
28
+
29
+ from ._authors import authorlist
30
+ from ._core import (
31
+ GOLDEN, JOURNALS, STYLE_DIR,
32
+ current_journal, figsize, savefig, set_size, set_style, subplots, use,
33
+ )
34
+ from ._colors import (
35
+ COLORS, CYCLE, OKABE_ITO, PAIRED, PETROFF10,
36
+ check_colors, check_figure, darken, lighten, simulate_cvd,
37
+ )
38
+ from ._extras import (
39
+ LINESTYLES, MARKERS,
40
+ label_panels, show_colors, show_linestyles, show_markers, style_cycler,
41
+ )
42
+
43
+ try:
44
+ __version__ = _version("plotastro")
45
+ except PackageNotFoundError: # running from a source checkout
46
+ __version__ = "0+unknown"
47
+
48
+ __all__ = [
49
+ "set_style", "use", "figsize", "subplots", "savefig", "current_journal",
50
+ "authorlist",
51
+ "JOURNALS", "GOLDEN", "STYLE_DIR",
52
+ "COLORS", "CYCLE", "OKABE_ITO", "PETROFF10", "PAIRED",
53
+ "lighten", "darken", "simulate_cvd", "check_colors", "check_figure",
54
+ "MARKERS", "LINESTYLES", "style_cycler", "label_panels",
55
+ "show_colors", "show_markers", "show_linestyles",
56
+ "set_size",
57
+ ]
58
+
59
+
60
+ def _register_styles():
61
+ """Make the bundled styles available as plt.style.use("mnras") etc."""
62
+ for path in STYLE_DIR.glob("*.mplstyle"):
63
+ _mstyle.library[path.stem] = _mpl.rc_params_from_file(
64
+ path, use_default_template=False)
65
+ _mstyle.available[:] = sorted(_mstyle.library)
66
+
67
+
68
+ _register_styles()
plotastro/_authors.py ADDED
@@ -0,0 +1,302 @@
1
+ """Generate journal-ready LaTeX author/affiliation blocks from a CSV file.
2
+
3
+ Works directly with real collaboration author lists. Recognised columns
4
+ (header names are case-insensitive; **any other column is ignored**, so
5
+ collaboration bookkeeping like ``JoinedAsBuilder`` is fine):
6
+
7
+ Authorname display name, e.g. "Behnood Bandi" (alias: "name");
8
+ if absent, built from "Firstname" + "Lastname"
9
+ Affiliation one or more affiliations separated by ";"
10
+ (aliases: "affiliations", "affil", "affiliation1", ...)
11
+ ORCID optional
12
+ Email optional; authors with an email are marked corresponding
13
+
14
+ Example (a typical collaboration list — see examples/authors_example.csv
15
+ for the full file)::
16
+
17
+ Lastname,Firstname,Authorname,Email,JoinedAsBuilder,Affiliation,ORCID,
18
+ Bandi,Behnood,Behnood Bandi, b.bandi@sussex.ac.uk, False,"Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK",0000-0001-5838-3903,
19
+ Rocher,Antoine,Antoine Rocher,antoine.rocher@epfl.ch,False,"EPFL, \\'{E}cole polytechnique f\\'{e}d\\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland",0000-0003-4349-6424,
20
+
21
+ Notes:
22
+
23
+ - LaTeX already present in the CSV (accents like ``\\'{e}``, maths, ...)
24
+ is passed through untouched; only unescaped ``& % # _`` are escaped.
25
+ - Whitespace around values is stripped (`` b.bandi@...`` is fine).
26
+ - An author appearing on **several rows** (one per affiliation, as some
27
+ collaborations do) is merged into one entry with all affiliations.
28
+ - Author order = row order; affiliations are numbered in order of first
29
+ appearance and shared between authors automatically.
30
+
31
+ Usage from Python::
32
+
33
+ import plotastro as pa
34
+ print(pa.authorlist("authors.csv", journal="mnras"))
35
+
36
+ or from the command line (installed with the package)::
37
+
38
+ plotastro-authors authors.csv --journal mnras
39
+ plotastro-authors authors.csv -j apj -o authors.tex
40
+
41
+ The output is a starting point that compiles with the journal's template —
42
+ fine-tune addresses, footnotes etc. in the .tex file.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import csv
49
+ import re
50
+ import string
51
+ from pathlib import Path
52
+
53
+ # Journals sharing an author-block format:
54
+ _FORMATS = {
55
+ "mnras": "mnras", "rasti": "mnras",
56
+ "aanda": "aanda",
57
+ "apj": "aastex", "oja": "aastex",
58
+ "prd": "revtex",
59
+ "jcap": "jcap",
60
+ "generic": "generic", "natastro": "generic",
61
+ "thesis": "generic", "beamer": "generic",
62
+ }
63
+
64
+ def _escape(text):
65
+ """Escape unescaped & % # _ for LaTeX; leave existing LaTeX alone."""
66
+ return re.sub(r"(?<!\\)([&%#_])", r"\\\1", str(text).strip())
67
+
68
+
69
+ def _norm_key(key):
70
+ return str(key).strip().lower().replace(" ", "").replace("_", "")
71
+
72
+
73
+ def _read_authors(source):
74
+ """Return a list of {name, affils, orcid, email} dicts.
75
+
76
+ `source` is a CSV path, or an already-parsed list of dicts (with the
77
+ same keys as the CSV columns) for programmatic use. See the module
78
+ docstring for the recognised columns; rows repeating an author's name
79
+ are merged (extra affiliations appended).
80
+ """
81
+ if isinstance(source, (str, Path)):
82
+ with open(source, newline="", encoding="utf-8-sig") as f:
83
+ rows = list(csv.DictReader(f))
84
+ else:
85
+ rows = [dict(row) for row in source]
86
+
87
+ authors, seen = [], {}
88
+ for raw in rows:
89
+ row = {_norm_key(k): (v or "").strip() for k, v in raw.items() if k}
90
+ name = (row.get("authorname") or row.get("name") or " ".join(
91
+ part for part in (row.get("firstname"), row.get("lastname")) if part))
92
+ if not name:
93
+ continue
94
+ affils = []
95
+ for key in sorted(row): # affiliation, affiliations, affiliation1, ...
96
+ if key.startswith("affil"):
97
+ affils += [a.strip() for a in row[key].split(";") if a.strip()]
98
+
99
+ author = seen.get(name.lower())
100
+ if author is None:
101
+ author = {"name": _escape(name), "affils": [],
102
+ "orcid": "", "email": ""}
103
+ seen[name.lower()] = author
104
+ authors.append(author)
105
+ for aff in affils:
106
+ escaped = _escape(aff)
107
+ if escaped not in author["affils"]:
108
+ author["affils"].append(escaped)
109
+ author["orcid"] = author["orcid"] or row.get("orcid", "")
110
+ author["email"] = author["email"] or row.get("email", "")
111
+ if not authors:
112
+ raise ValueError(
113
+ "No authors found — the CSV needs an 'Authorname' or 'name' "
114
+ "column (or 'Firstname'/'Lastname') with at least one "
115
+ "non-empty row.")
116
+ return authors
117
+
118
+
119
+ def _affiliation_index(authors):
120
+ """Ordered unique affiliations -> 1-based numbering."""
121
+ index = {}
122
+ for a in authors:
123
+ for aff in a["affils"]:
124
+ index.setdefault(aff, len(index) + 1)
125
+ return index
126
+
127
+
128
+ def _short_authors(authors):
129
+ """Running-head form: 'B. Bandi et al.', 'Doe & Roe', or 'B. Bandi'."""
130
+ def surname(a):
131
+ return a["name"].split()[-1]
132
+
133
+ first = authors[0]["name"].split()
134
+ initial = f"{first[0][0]}. {first[-1]}" if len(first) > 1 else first[0]
135
+ if len(authors) == 1:
136
+ return initial
137
+ if len(authors) == 2:
138
+ return f"{surname(authors[0])} \\& {surname(authors[1])}"
139
+ return f"{initial} et al."
140
+
141
+
142
+ def _sup(author, index):
143
+ nums = ",".join(str(n) for n in sorted(index[a] for a in author["affils"]))
144
+ return f"$^{{{nums}}}$" if nums else ""
145
+
146
+
147
+ def _corresponding(authors):
148
+ """First author with an email — the only one footnoted in the
149
+ single-\\thanks formats (MNRAS, A&A)."""
150
+ return next((a for a in authors if a["email"]), None)
151
+
152
+
153
+ def _fmt_mnras(authors, index):
154
+ corr = _corresponding(authors)
155
+ lines = [f"\\author[{_short_authors(authors)}]{{"]
156
+ for i, a in enumerate(authors):
157
+ thanks = f"\\thanks{{E-mail: {a['email']}}}" if a is corr else ""
158
+ if len(authors) > 1 and i == len(authors) - 1:
159
+ lines.append(f"and {a['name']}{_sup(a, index)}{thanks}")
160
+ elif i >= len(authors) - 2: # no comma before the final 'and'
161
+ lines.append(f"{a['name']}{_sup(a, index)}{thanks}")
162
+ else:
163
+ lines.append(f"{a['name']},{_sup(a, index)}{thanks}")
164
+ lines += ["\\\\", "% List of institutions"]
165
+ insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
166
+ lines.append("\\\\\n".join(insts))
167
+ lines.append("}")
168
+ return "\n".join(lines)
169
+
170
+
171
+ def _fmt_aanda(authors, index):
172
+ corr = _corresponding(authors)
173
+ parts = []
174
+ for a in authors:
175
+ nums = ",".join(str(n) for n in sorted(index[x] for x in a["affils"]))
176
+ inst = f"\\inst{{{nums}}}" if nums else ""
177
+ thanks = f"\\thanks{{\\email{{{a['email']}}}}}" if a is corr else ""
178
+ parts.append(f"{a['name']}{inst}{thanks}")
179
+ author_block = "\\author{" + "\n \\and ".join(parts) + "}"
180
+ inst_block = ("\\institute{" +
181
+ "\n \\and ".join(index) + "}")
182
+ return author_block + "\n\n" + inst_block
183
+
184
+
185
+ def _fmt_aastex(authors, index):
186
+ blocks = []
187
+ corresponding = _corresponding(authors)
188
+ if corresponding:
189
+ blocks.append(f"\\correspondingauthor{{{corresponding['name']}}}\n"
190
+ f"\\email{{{corresponding['email']}}}")
191
+ for a in authors:
192
+ opt = f"[{a['orcid']}]" if a["orcid"] else ""
193
+ lines = [f"\\author{opt}{{{a['name']}}}"]
194
+ lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
195
+ blocks.append("\n".join(lines))
196
+ return "\n\n".join(blocks)
197
+
198
+
199
+ def _fmt_revtex(authors, index):
200
+ blocks = []
201
+ for a in authors:
202
+ lines = [f"\\author{{{a['name']}}}"]
203
+ if a["email"]:
204
+ lines.append(f"\\email{{{a['email']}}}")
205
+ lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
206
+ blocks.append("\n".join(lines))
207
+ return "\n\n".join(blocks)
208
+
209
+
210
+ def _fmt_jcap(authors, index):
211
+ # jcappub labels affiliations with letters
212
+ if len(index) > 26:
213
+ raise ValueError("The JCAP format supports at most 26 affiliations.")
214
+ letters = {aff: string.ascii_lowercase[n - 1] for aff, n in index.items()}
215
+ lines = []
216
+ for a in authors:
217
+ labels = ",".join(sorted(letters[x] for x in a["affils"]))
218
+ opt = f"[{labels}]" if labels else ""
219
+ lines.append(f"\\author{opt}{{{a['name']}}}")
220
+ lines.append("")
221
+ lines += [f"\\affiliation[{letters[aff]}]{{{aff}}}" for aff in index]
222
+ emails = [a["email"] for a in authors if a["email"]]
223
+ if emails:
224
+ lines.append("")
225
+ lines += [f"\\emailAdd{{{e}}}" for e in emails]
226
+ return "\n".join(lines)
227
+
228
+
229
+ def _fmt_generic(authors, index):
230
+ names = [f"{a['name']}{_sup(a, index)}" for a in authors]
231
+ if len(names) > 1:
232
+ head = ", ".join(names[:-1]) + " and " + names[-1]
233
+ else:
234
+ head = names[0]
235
+ insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
236
+ return head + "\n\n" + "\n".join(insts)
237
+
238
+
239
+ _RENDERERS = {"mnras": _fmt_mnras, "aanda": _fmt_aanda, "aastex": _fmt_aastex,
240
+ "revtex": _fmt_revtex, "jcap": _fmt_jcap, "generic": _fmt_generic}
241
+
242
+
243
+ def authorlist(source, journal="mnras"):
244
+ """LaTeX author/affiliation block for a journal, from a CSV file.
245
+
246
+ Parameters
247
+ ----------
248
+ source : str, Path, or list of dicts
249
+ Path to a CSV file with columns ``name``, ``affiliations``
250
+ (";"-separated), ``orcid``, ``email`` — or an equivalent list of
251
+ dicts. See the module docstring for the format.
252
+ journal : str
253
+ Any journal key/alias the package knows (``"mnras"``, ``"aanda"``,
254
+ ``"apj"``, ``"oja"``, ``"prd"``, ``"jcap"``, ...) or ``"generic"``
255
+ for a plain numbered-superscript block.
256
+
257
+ Returns
258
+ -------
259
+ str : LaTeX source to paste into your manuscript.
260
+
261
+ Examples
262
+ --------
263
+ >>> print(pa.authorlist("authors.csv", journal="aanda"))
264
+ """
265
+ from ._core import _resolve
266
+ key = "generic" if str(journal).lower() == "generic" else _resolve(journal)
267
+ fmt = _FORMATS[key]
268
+ authors = _read_authors(source)
269
+ index = _affiliation_index(authors)
270
+ header = (f"% Author list generated by plotastro ({key} format)\n"
271
+ f"% Check addresses/footnotes against the journal template.\n")
272
+ return header + _RENDERERS[fmt](authors, index)
273
+
274
+
275
+ def main(argv=None):
276
+ """Command-line entry point: ``plotastro-authors authors.csv -j mnras``."""
277
+ parser = argparse.ArgumentParser(
278
+ prog="plotastro-authors",
279
+ description="Generate a journal-ready LaTeX author/affiliation block "
280
+ "from a CSV file (columns: Authorname or Firstname/"
281
+ "Lastname, Affiliation, ORCID, Email; extra columns are "
282
+ "ignored).")
283
+ parser.add_argument("csv", help="path to the author CSV file")
284
+ parser.add_argument("-j", "--journal", default="mnras",
285
+ help="journal key, e.g. mnras, aanda, apj, oja, prd, "
286
+ "jcap, or 'generic' (default: mnras)")
287
+ parser.add_argument("-o", "--output",
288
+ help="write to this .tex file instead of stdout")
289
+ args = parser.parse_args(argv)
290
+ try:
291
+ tex = authorlist(args.csv, journal=args.journal)
292
+ except (ValueError, OSError) as exc:
293
+ parser.exit(1, f"error: {exc}\n")
294
+ if args.output:
295
+ Path(args.output).write_text(tex + "\n", encoding="utf-8")
296
+ print(f"wrote {args.output}")
297
+ else:
298
+ print(tex)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
plotastro/_colors.py ADDED
@@ -0,0 +1,227 @@
1
+ """Palettes, colour utilities and colour-vision-deficiency checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+
7
+ import matplotlib.colors as mcolors
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+
11
+ from ._core import figsize
12
+
13
+ # ----------------------------------------------------------------------
14
+ # Palettes
15
+ # ----------------------------------------------------------------------
16
+
17
+ #: The default colour-blind-friendly cycle, by name (see the README).
18
+ #: First 9: reordered ColorBrewer "Set1" made colour-blind safe;
19
+ #: last 3: light companions from Tableau's Color Blind 10 palette.
20
+ COLORS = {
21
+ "blue": "#377eb8",
22
+ "orange": "#ff7f00",
23
+ "green": "#4daf4a",
24
+ "pink": "#f781bf",
25
+ "brown": "#a65628",
26
+ "purple": "#984ea3",
27
+ "grey": "#999999",
28
+ "red": "#e41a1c",
29
+ "yellow": "#dede00",
30
+ "lightblue": "#a2c8ec",
31
+ "lightorange": "#ffbc79",
32
+ "lightgrey": "#ababab",
33
+ }
34
+
35
+ #: The colours of the default cycle, in order.
36
+ CYCLE = list(COLORS.values())
37
+
38
+ #: Okabe & Ito (2008) palette — the classic 8-colour scheme designed
39
+ #: for all common types of colour-vision deficiency.
40
+ OKABE_ITO = {
41
+ "black": "#000000",
42
+ "orange": "#e69f00",
43
+ "skyblue": "#56b4e9",
44
+ "green": "#009e73",
45
+ "yellow": "#f0e442",
46
+ "blue": "#0072b2",
47
+ "vermillion": "#d55e00",
48
+ "purple": "#cc79a7",
49
+ }
50
+
51
+ #: Petroff (2021) 10-colour palette — the CVD-optimised cycle adopted as
52
+ #: matplotlib's "petroff10" style and widely used in particle physics.
53
+ PETROFF10 = {
54
+ "blue": "#3f90da",
55
+ "yellow": "#ffa90e",
56
+ "red": "#bd1f01",
57
+ "grey": "#94a4a2",
58
+ "purple": "#832db6",
59
+ "brown": "#a96b59",
60
+ "orange": "#e76300",
61
+ "tan": "#b9ac70",
62
+ "slate": "#717581",
63
+ "cyan": "#92dadd",
64
+ }
65
+
66
+ #: Light/dark pairs (ColorBrewer "Paired") — ideal for data/model or
67
+ #: before/after pairs: PAIRED["blue"] -> (light, dark). Note this palette
68
+ #: is *not* fully CVD-safe on its own (it contains red and green); pair
69
+ #: it with distinct line styles or markers.
70
+ PAIRED = {
71
+ "blue": ("#a6cee3", "#1f78b4"),
72
+ "green": ("#b2df8a", "#33a02c"),
73
+ "red": ("#fb9a99", "#e31a1c"),
74
+ "orange": ("#fdbf6f", "#ff7f00"),
75
+ "purple": ("#cab2d6", "#6a3d9a"),
76
+ "brown": ("#ffff99", "#b15928"),
77
+ }
78
+
79
+
80
+ def lighten(color, amount=0.5):
81
+ """Lighten a colour by moving it towards white (0 = unchanged, 1 = white).
82
+
83
+ Handy for e.g. filled uncertainty bands under a line of the same hue:
84
+
85
+ >>> ax.plot(x, y, color=pa.COLORS["blue"])
86
+ >>> ax.fill_between(x, lo, hi, color=pa.lighten(pa.COLORS["blue"], 0.7))
87
+ """
88
+ h, l, s = colorsys.rgb_to_hls(*mcolors.to_rgb(color))
89
+ return colorsys.hls_to_rgb(h, l + (1 - l) * amount, s)
90
+
91
+
92
+ def darken(color, amount=0.5):
93
+ """Darken a colour by moving it towards black (0 = unchanged, 1 = black)."""
94
+ h, l, s = colorsys.rgb_to_hls(*mcolors.to_rgb(color))
95
+ return colorsys.hls_to_rgb(h, l * (1 - amount), s)
96
+
97
+
98
+ # ----------------------------------------------------------------------
99
+ # Colour-vision-deficiency simulation
100
+ # ----------------------------------------------------------------------
101
+
102
+ # Machado, Oliveira & Fernandes (2009), IEEE TVCG 15(6) — severity-1.0
103
+ # transformation matrices, applied in linear RGB.
104
+ _CVD_MATRICES = {
105
+ "protanopia": np.array([
106
+ [0.152286, 1.052583, -0.204868],
107
+ [0.114503, 0.786281, 0.099216],
108
+ [-0.003882, -0.048116, 1.051998]]),
109
+ "deuteranopia": np.array([
110
+ [0.367322, 0.860646, -0.227968],
111
+ [0.280085, 0.672501, 0.047413],
112
+ [-0.011820, 0.042940, 0.968881]]),
113
+ "tritanopia": np.array([
114
+ [1.255528, -0.076749, -0.178779],
115
+ [-0.078411, 0.930809, 0.147602],
116
+ [0.004733, 0.691367, 0.303900]]),
117
+ }
118
+
119
+
120
+ def _srgb_to_linear(c):
121
+ return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
122
+
123
+
124
+ def _linear_to_srgb(c):
125
+ return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
126
+
127
+
128
+ def simulate_cvd(colors, kind="deuteranopia"):
129
+ """Simulate how colours appear with a colour-vision deficiency.
130
+
131
+ Parameters
132
+ ----------
133
+ colors : colour, sequence of colours, or (..., 3/4) float array
134
+ Anything matplotlib understands (hex strings, names, RGB tuples,
135
+ an image array from a rendered figure, ...).
136
+ kind : {"deuteranopia", "protanopia", "tritanopia", "greyscale"}
137
+ Deficiency to simulate. Deuteranopia and protanopia (red-green)
138
+ together affect ~5% of male readers; greyscale is what a
139
+ black-and-white printout shows.
140
+
141
+ Returns
142
+ -------
143
+ ndarray of RGB(A) values in [0, 1], one row per input colour (or the
144
+ same shape as an input image array).
145
+
146
+ Notes
147
+ -----
148
+ Uses the severity-1.0 matrices of Machado et al. (2009), applied in
149
+ linear RGB — the same model behind most online CVD simulators.
150
+ """
151
+ arr = np.asarray(colors, dtype=float) if (
152
+ isinstance(colors, np.ndarray) and colors.ndim >= 2
153
+ ) else np.atleast_2d(mcolors.to_rgba_array(colors))
154
+ if arr.max() > 1.0: # e.g. uint8 image content passed as float
155
+ arr = arr / 255.0
156
+ rgb, alpha = arr[..., :3], arr[..., 3:]
157
+ lin = _srgb_to_linear(np.clip(rgb, 0, 1))
158
+ if kind == "greyscale":
159
+ lum = lin @ np.array([0.2126, 0.7152, 0.0722])
160
+ lin = np.repeat(lum[..., None], 3, axis=-1)
161
+ elif kind in _CVD_MATRICES:
162
+ lin = lin @ _CVD_MATRICES[kind].T
163
+ else:
164
+ options = ", ".join(list(_CVD_MATRICES) + ["greyscale"])
165
+ raise ValueError(f"kind must be one of: {options}; got {kind!r}")
166
+ out = _linear_to_srgb(np.clip(lin, 0, 1))
167
+ return np.concatenate([out, alpha], axis=-1) if alpha.size else out
168
+
169
+
170
+ def check_colors(palette=None, kinds=("deuteranopia", "protanopia", "greyscale")):
171
+ """Show a palette next to CVD simulations of it, to verify that the
172
+ colours stay distinguishable. Returns the figure.
173
+
174
+ Parameters
175
+ ----------
176
+ palette : dict, list of colours, or None
177
+ Defaults to the package's colour cycle.
178
+ kinds : sequence of str
179
+ Simulations to include (see :func:`simulate_cvd`).
180
+ """
181
+ palette = palette if palette is not None else COLORS
182
+ cols = list(palette.values()) if isinstance(palette, dict) else list(palette)
183
+ rows = ["original", *kinds]
184
+ fig, ax = plt.subplots(
185
+ figsize=figsize("full", journal="mnras", fraction=0.9,
186
+ height=0.4 * len(rows) + 0.4))
187
+ ax.set_axis_off()
188
+ for i, row in enumerate(rows):
189
+ y = len(rows) - 1 - i
190
+ shown = cols if row == "original" else simulate_cvd(cols, row)
191
+ for j, c in enumerate(shown):
192
+ ax.add_patch(plt.Rectangle((j, y + 0.12), 0.92, 0.76, color=c))
193
+ ax.text(-0.15, y + 0.5, row, ha="right", va="center", fontsize=8)
194
+ ax.set_xlim(-2.2, len(cols))
195
+ ax.set_ylim(0, len(rows))
196
+ ax.set_title("Colour-vision-deficiency check")
197
+ return fig
198
+
199
+
200
+ def check_figure(fig=None, kinds=("deuteranopia", "protanopia", "greyscale")):
201
+ """Render an existing figure and show how it appears under CVD
202
+ simulations — the final accessibility check before submission.
203
+
204
+ Parameters
205
+ ----------
206
+ fig : Figure, optional
207
+ Defaults to the current figure.
208
+ kinds : sequence of str
209
+ Simulations to include (see :func:`simulate_cvd`).
210
+
211
+ Returns
212
+ -------
213
+ The new comparison figure.
214
+ """
215
+ fig = fig if fig is not None else plt.gcf()
216
+ fig.canvas.draw()
217
+ img = np.asarray(fig.canvas.buffer_rgba(), dtype=float) / 255.0
218
+ panels = [("original", img)] + [(k, simulate_cvd(img, k)) for k in kinds]
219
+ n = len(panels)
220
+ ar = img.shape[0] / img.shape[1]
221
+ w = 6.5
222
+ out, axes = plt.subplots(1, n, figsize=(w, ar * w / n * 1.15))
223
+ for ax, (label, im) in zip(np.atleast_1d(axes), panels):
224
+ ax.imshow(im)
225
+ ax.set_axis_off()
226
+ ax.set_title(label, fontsize=8)
227
+ return out