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/layout.py ADDED
@@ -0,0 +1,133 @@
1
+ """Figure geometry: width classes, unit conversions, and figsize math.
2
+
3
+ Pure geometry — no matplotlib styling here. ``units`` live here too (the
4
+ conversions are small and only used for sizing).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING: # avoid a runtime import cycle with journals.py
14
+ from .journals import JournalSpec
15
+
16
+ MM_PER_IN = 25.4
17
+ GOLDEN = (1.0 + 5.0 ** 0.5) / 2.0 # ~1.618
18
+
19
+
20
+ def mm_to_in(mm: float) -> float:
21
+ return mm / MM_PER_IN
22
+
23
+
24
+ def in_to_mm(inch: float) -> float:
25
+ return inch * MM_PER_IN
26
+
27
+
28
+ def pt_to_mm(pt: float) -> float:
29
+ return pt * (MM_PER_IN / 72.0) # 1 pt = 1/72 in
30
+
31
+
32
+ class Width(str, Enum):
33
+ """Standard journal figure widths."""
34
+
35
+ SINGLE = "single"
36
+ ONEHALF = "onehalf"
37
+ DOUBLE = "double"
38
+ FULL_PAGE = "full_page"
39
+
40
+
41
+ def resolve_width(width) -> str:
42
+ """Normalize a Width/str to a canonical key, with a friendly error."""
43
+ if isinstance(width, Width):
44
+ return width.value
45
+ key = str(width).strip().lower().replace("-", "_")
46
+ valid = [w.value for w in Width]
47
+ if key in valid:
48
+ return key
49
+ hint = difflib.get_close_matches(key, valid, n=1)
50
+ suffix = f" Did you mean {hint[0]!r}?" if hint else ""
51
+ raise ValueError(f"Unknown width {width!r}. Valid: {valid}.{suffix}")
52
+
53
+
54
+ def width_in(spec: "JournalSpec", width="single") -> float:
55
+ """Physical figure width in inches for the given width class."""
56
+ key = resolve_width(width)
57
+ if key == Width.FULL_PAGE.value:
58
+ key = Width.DOUBLE.value # full-page spans the double-column block
59
+ try:
60
+ return mm_to_in(spec.widths_mm[key])
61
+ except KeyError:
62
+ raise ValueError(
63
+ f"Journal {spec.name!r} has no {key!r} width "
64
+ f"(has {sorted(spec.widths_mm)})."
65
+ ) from None
66
+
67
+
68
+ def figsize(spec: "JournalSpec", width="single", aspect="golden", height=None):
69
+ """Return ``(w_in, h_in)``.
70
+
71
+ Width is fixed by the column class. Height comes from ``height`` (inches,
72
+ overrides everything), else ``aspect`` applied to the width, clamped to the
73
+ page's usable height. ``FULL_PAGE`` defaults to page-height minus the caption
74
+ reserve so it actually fills the page.
75
+ """
76
+ key = resolve_width(width)
77
+ w = width_in(spec, key)
78
+ usable_h = mm_to_in(spec.page_height_mm)
79
+
80
+ if key == Width.FULL_PAGE.value:
81
+ if height is not None:
82
+ h = float(height)
83
+ else:
84
+ h = mm_to_in(spec.page_height_mm - spec.caption_reserve_mm)
85
+ return (w, min(h, usable_h))
86
+
87
+ if height is not None:
88
+ h = float(height)
89
+ elif aspect in (None, "golden"):
90
+ h = w / GOLDEN
91
+ elif aspect == "equal":
92
+ h = w
93
+ else:
94
+ h = w * float(aspect) # aspect = height / width
95
+
96
+ return (w, min(h, usable_h))
97
+
98
+ import numpy as np
99
+
100
+ class Row:
101
+ def __init__(self, name: str, height: float, gap_above: float = 0.0, attached: bool = False):
102
+ self.name = name
103
+ self.height = height
104
+ self.gap_above = gap_above
105
+ self.attached = attached
106
+
107
+ class Grid:
108
+ def __init__(self, *rows: Row, ncols: int = 1, col_width: float = 1.25, wspace: float = 0.3, margins: dict = None):
109
+ self.rows = list(rows)
110
+ self.ncols = ncols
111
+ self.col_width = col_width
112
+ self.wspace = wspace
113
+ self.margins = {"left": 0.54, "right": 0.05, "top": 0.18, "bottom": 0.30}
114
+ if margins:
115
+ self.margins.update(margins)
116
+
117
+ class GridAxesDict(dict):
118
+ """Hybrid array-dict for accessing axes by name or index."""
119
+ def __init__(self, axes_array, row_names):
120
+ super().__init__()
121
+ self.axes_array = axes_array
122
+ self.row_names = row_names
123
+ for r_idx, name in enumerate(row_names):
124
+ for c_idx in range(axes_array.shape[1]):
125
+ self[(name, c_idx)] = axes_array[r_idx, c_idx]
126
+
127
+ def __getitem__(self, key):
128
+ if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int) and isinstance(key[1], int):
129
+ return self.axes_array[key[0], key[1]]
130
+ return super().__getitem__(key)
131
+
132
+ def __iter__(self):
133
+ return iter(self.axes_array)
paperplot/lint.py ADDED
@@ -0,0 +1,156 @@
1
+ """``preflight()`` — validate a figure against journal rules. Warn, never block.
2
+
3
+ Returns a structured ``Report`` so CI can gate on it while humans read a table.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Tuple, Union
10
+
11
+ import matplotlib.colors as mcolors
12
+
13
+ import importlib
14
+
15
+ from .journals import JournalSpec
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
+ Number = Union[float, str]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Finding:
25
+ severity: str # "warn" | "info"
26
+ rule: str # min_font | min_linewidth | gray_luminance
27
+ locator: str # human-readable artist location
28
+ measured: Number
29
+ limit: Number
30
+ message: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Report:
35
+ findings: Tuple[Finding, ...]
36
+ spec_name: str = ""
37
+
38
+ @property
39
+ def warnings(self) -> Tuple[Finding, ...]:
40
+ return tuple(f for f in self.findings if f.severity == "warn")
41
+
42
+ @property
43
+ def ok(self) -> bool:
44
+ return not self.warnings
45
+
46
+ def __bool__(self) -> bool:
47
+ return self.ok
48
+
49
+ def by_rule(self, rule: str) -> Tuple[Finding, ...]:
50
+ return tuple(f for f in self.findings if f.rule == rule)
51
+
52
+ def __str__(self) -> str:
53
+ if not self.findings:
54
+ return f"preflight [{self.spec_name}]: OK ✓ (no issues)"
55
+ lines = [f"preflight [{self.spec_name}]: "
56
+ f"{len(self.warnings)} warning(s), "
57
+ f"{len(self.findings) - len(self.warnings)} info"]
58
+ for f in self.findings:
59
+ mark = "⚠" if f.severity == "warn" else "ℹ"
60
+ lines.append(f" {mark} {f.message}")
61
+ return "\n".join(lines)
62
+
63
+
64
+ def _luminance(color) -> float:
65
+ r, g, b = mcolors.to_rgb(color)
66
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
67
+
68
+
69
+ def preflight(fig, spec: Optional[JournalSpec] = None) -> Report:
70
+ """Inspect a drawn figure for sub-spec fonts, lines, and gray ambiguity."""
71
+ spec = spec or _style.active()
72
+ if spec is None:
73
+ raise RuntimeError("No journal to check against; call pp.use('aps') or pass spec=.")
74
+
75
+ fig.canvas.draw() # realize tick labels / autosized artists
76
+ fp = spec.font_pt
77
+ # Cap-height is an info-level nag aimed at text the *user* shrank below the
78
+ # journal's own defaults; don't fire it on the spec's chosen sizes (e.g. APS
79
+ # 7.5pt ticks are ~1.9mm cap-height by design — reporting them is just noise).
80
+ spec_floor_pt = min(fp.base, fp.tick, fp.legend, fp.panel)
81
+ findings: list[Finding] = []
82
+
83
+ for i, ax in enumerate(fig.axes):
84
+ texts = [("title", ax.title), ("x-label", ax.xaxis.label),
85
+ ("y-label", ax.yaxis.label)]
86
+ texts += [("x-tick", t) for t in ax.get_xticklabels()]
87
+ texts += [("y-tick", t) for t in ax.get_yticklabels()]
88
+ leg = ax.get_legend()
89
+ if leg is not None:
90
+ texts += [("legend", t) for t in leg.get_texts()]
91
+
92
+ seen_kinds: set = set()
93
+ for kind, t in texts:
94
+ if not t.get_text():
95
+ continue
96
+ pt = t.get_fontsize()
97
+ loc = f"axes[{i}] {kind}"
98
+ if pt < fp.min_warn_pt:
99
+ findings.append(Finding(
100
+ "warn", "min_font", loc, round(pt, 1), fp.min_warn_pt,
101
+ f"{loc}: {pt:.1f}pt < {fp.min_warn_pt}pt minimum"))
102
+ elif (kind not in seen_kinds and pt < spec_floor_pt
103
+ and fp.cap_height_mm(pt) < fp.min_cap_height_mm):
104
+ cap = fp.cap_height_mm(pt)
105
+ findings.append(Finding(
106
+ "info", "min_font", loc, round(cap, 2), fp.min_cap_height_mm,
107
+ f"{loc}: cap-height {cap:.2f}mm < {fp.min_cap_height_mm}mm "
108
+ f"APS guideline (font {pt:.1f}pt)"))
109
+ seen_kinds.add(kind)
110
+
111
+ for j, line in enumerate(ax.get_lines()):
112
+ lw = line.get_linewidth()
113
+ if 0 < lw < spec.min_linewidth_pt:
114
+ findings.append(Finding(
115
+ "warn", "min_linewidth", f"axes[{i}] Line2D #{j}",
116
+ round(lw, 2), spec.min_linewidth_pt,
117
+ f"axes[{i}] line #{j}: {lw:.2f}pt < "
118
+ f"{spec.min_linewidth_pt}pt minimum weight"))
119
+
120
+ # collection-based artists (LineCollection from step/quiver/etc.) carry
121
+ # their own linewidths and would otherwise dodge the Line2D check.
122
+ for j, coll in enumerate(ax.collections):
123
+ try:
124
+ lws = [float(w) for w in coll.get_linewidths()]
125
+ except (TypeError, ValueError):
126
+ continue
127
+ thin = [w for w in lws if 0 < w < spec.min_linewidth_pt]
128
+ if thin:
129
+ w = min(thin)
130
+ findings.append(Finding(
131
+ "warn", "min_linewidth", f"axes[{i}] collection #{j}",
132
+ round(w, 2), spec.min_linewidth_pt,
133
+ f"axes[{i}] collection #{j}: {w:.2f}pt < "
134
+ f"{spec.min_linewidth_pt}pt minimum weight"))
135
+
136
+ # cycle-color grayscale separability (print is grayscale, APS H24)
137
+ try:
138
+ import matplotlib as mpl
139
+ from . import palettes
140
+ colors = mpl.rcParams["axes.prop_cycle"].by_key().get("color", [])
141
+ # Don't nag about the shipped default: Okabe-Ito is curated colorblind-safe,
142
+ # and its grayscale gaps are a known, accepted trade-off.
143
+ cur = [mcolors.to_hex(c).lower() for c in colors]
144
+ default = [mcolors.to_hex(c).lower() for c in palettes.OKABE_ITO]
145
+ is_default = bool(cur) and cur == default[:len(cur)]
146
+ lums = sorted(_luminance(c) for c in colors[:6])
147
+ gaps = [b - a for a, b in zip(lums, lums[1:])]
148
+ if not is_default and gaps and min(gaps) < 0.05:
149
+ findings.append(Finding(
150
+ "info", "gray_luminance", "color cycle", round(min(gaps), 3), 0.05,
151
+ "two cycle colors are near-identical in grayscale; check the "
152
+ "figure stays legible in print (APS H24)"))
153
+ except Exception:
154
+ pass
155
+
156
+ return Report(tuple(findings), spec_name=spec.name)
paperplot/mplstyle.py ADDED
@@ -0,0 +1,103 @@
1
+ """The zero-buy-in on-ramp: paperplot's look as a plain matplotlib style sheet.
2
+
3
+ ``figure()``/``save()`` are the product — they size to the real column width,
4
+ embed fonts, and preflight. But the *look* (type scale, Okabe-Ito cycle, tick
5
+ style, full box) is just rcParams, and rcParams travel as ``.mplstyle`` files.
6
+
7
+ ``export_mplstyle("aps")`` writes a style file; ``register_mplstyles()`` makes
8
+ ``plt.style.use("paperplot-aps")`` work in-process. Either way a user gets the
9
+ styling with no API commitment, then graduates to ``pp.figure()`` for the part a
10
+ style sheet *cannot* do — true sizing and preflight.
11
+
12
+ We do NOT register on import: paperplot's first import stays silent and mutates
13
+ no global matplotlib state. Registration is an explicit opt-in.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ from cycler import Cycler
21
+
22
+ from .registry import available, get_spec
23
+ from .style import rcparams
24
+
25
+ # Journal keys exposed as style sheets. Variants (prl/prx/prb) share APS styling,
26
+ # so they'd produce identical sheets — we skip them here to keep the list clean.
27
+ _STYLE_KEYS = ("aps", "nature", "ieee", "talk")
28
+
29
+
30
+ def _format_value(key: str, value) -> str:
31
+ """Serialize one rcParam value to matplotlib ``.mplstyle`` syntax."""
32
+ if key == "axes.prop_cycle" and isinstance(value, Cycler):
33
+ # '#' starts a comment in .mplstyle files, so hex colors are written
34
+ # bare (no leading '#') — exactly how matplotlib's own matplotlibrc does.
35
+ colors = (c.lstrip("#") for c in value.by_key().get("color", []))
36
+ inner = ", ".join(f"'{c}'" for c in colors)
37
+ return f"cycler('color', [{inner}])"
38
+ if isinstance(value, bool): # before int/float — bool is an int subclass
39
+ return "True" if value else "False"
40
+ if isinstance(value, (list, tuple)):
41
+ return ", ".join(str(v) for v in value)
42
+ return str(value)
43
+
44
+
45
+ def to_mplstyle_text(journal, *, serif: bool = False, usetex: bool = False,
46
+ palette=None, math: str = "cm",
47
+ font_scale: float = 1.0) -> str:
48
+ """Render a journal's rcParams as ``.mplstyle`` file text.
49
+
50
+ Accepts the same styling options as :func:`paperplot.use`. The result is the
51
+ look only — it carries the journal's *default* (single-column) figure size,
52
+ not per-figure sizing, which is what ``pp.figure(width=...)`` is for.
53
+ """
54
+ spec = get_spec(journal) if not hasattr(journal, "revision") else journal
55
+ rc = rcparams(spec, serif=serif, usetex=usetex, palette=palette, math=math,
56
+ font_scale=font_scale)
57
+ lines = [
58
+ f"# paperplot style for {spec.name} (revision {spec.revision}).",
59
+ "# Generated by paperplot.export_mplstyle — the look only.",
60
+ "# For journal-true sizing, font embedding, and preflight, use pp.figure()/pp.save().",
61
+ "",
62
+ ]
63
+ for key in sorted(rc):
64
+ lines.append(f"{key}: {_format_value(key, rc[key])}")
65
+ return "\n".join(lines) + "\n"
66
+
67
+
68
+ def export_mplstyle(journal, path: Optional[str] = None, **opts) -> str:
69
+ """Write (and return) a ``.mplstyle`` file for ``journal``.
70
+
71
+ With no ``path``, returns the text without writing. Pass styling options
72
+ (``serif``, ``usetex``, ``palette``, ``math``, ``font_scale``) just like
73
+ :func:`paperplot.use`.
74
+ """
75
+ text = to_mplstyle_text(journal, **opts)
76
+ if path is not None:
77
+ with open(path, "w", encoding="utf-8") as fh:
78
+ fh.write(text)
79
+ return text
80
+
81
+
82
+ def register_mplstyles() -> list[str]:
83
+ """Register ``paperplot-<journal>`` styles into matplotlib's style library.
84
+
85
+ After calling this, ``plt.style.use("paperplot-aps")`` works (and the names
86
+ appear in ``plt.style.available``). Opt-in — paperplot never does this on
87
+ import. Returns the registered style names.
88
+ """
89
+ import matplotlib.style as mstyle
90
+
91
+ names = []
92
+ known = set(available())
93
+ for key in _STYLE_KEYS:
94
+ if key not in known:
95
+ continue
96
+ spec = get_spec(key)
97
+ name = f"paperplot-{key}"
98
+ # Library entries are applied via rcParams.update, so the live dict
99
+ # (cycler object intact) works directly — no text round-trip needed.
100
+ mstyle.library[name] = rcparams(spec)
101
+ names.append(name)
102
+ mstyle.available[:] = sorted(mstyle.library)
103
+ return names
paperplot/palettes.py ADDED
@@ -0,0 +1,190 @@
1
+ """Colors: a colorblind-safe qualitative cycle, sequential/diverging colormaps,
2
+ and a small custom-palette registry. matplotlib-only (no seaborn).
3
+
4
+ The core rule: qualitative cycles (for categories) and sequential/diverging
5
+ colormaps (for ordered data) are *different things*. Using a sequential scheme
6
+ as a categorical cycle is an enforced anti-pattern, not just a docstring note.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ from typing import Dict, List, Sequence
13
+
14
+ import matplotlib.colors as mcolors
15
+ from matplotlib import colormaps
16
+
17
+ # Okabe-Ito colorblind-safe qualitative palette (default cycle).
18
+ OKABE_ITO: List[str] = [
19
+ "#0072B2", # blue
20
+ "#D55E00", # vermillion
21
+ "#009E73", # green
22
+ "#CC79A7", # reddish purple
23
+ "#E69F00", # orange
24
+ "#56B4E9", # sky blue
25
+ "#F0E442", # yellow
26
+ "#000000", # black
27
+ ]
28
+
29
+ # Seaborn's qualitative palettes, hardcoded so they're available without seaborn.
30
+ # The pairing that matters: MUTED for area/histogram *fills*, BRIGHT for *strokes*
31
+ # (lines, reference markers). Muted fills under bright strokes keeps overlapping
32
+ # filled shapes legible. See pp.fills() / pp.strokes().
33
+ MUTED: List[str] = [
34
+ "#4878D0", "#EE854A", "#6ACC64", "#D65F5F", "#956CB4",
35
+ "#8C613C", "#DC7EC0", "#797979", "#D5BB67", "#82C6E2",
36
+ ]
37
+ BRIGHT: List[str] = [
38
+ "#023EFF", "#FF7C00", "#1AC938", "#E8000B", "#8B2BE2",
39
+ "#9F4800", "#F14CC1", "#A3A3A3", "#FFC400", "#00D7FF",
40
+ ]
41
+ DEEP: List[str] = [
42
+ "#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3",
43
+ "#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD",
44
+ ]
45
+ COLORBLIND: List[str] = [
46
+ "#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC",
47
+ "#CA9161", "#FBAFE4", "#949494", "#ECE133", "#56B4E9",
48
+ ]
49
+ PASTEL: List[str] = [
50
+ "#A1C9F4", "#FFB482", "#8DE5A1", "#FF9F9B", "#D0BBFF",
51
+ "#DEBB9B", "#FAB0E4", "#CFCFCF", "#FFFEA3", "#B9F2F0",
52
+ ]
53
+ DARK: List[str] = [
54
+ "#001C7F", "#B1400D", "#12711C", "#8C0800", "#591E71",
55
+ "#592F0D", "#A23582", "#3C3C3C", "#B8850A", "#006374",
56
+ ]
57
+
58
+ # Named categorical palettes resolvable by pp.palette("name").
59
+ _BUILTIN: Dict[str, List[str]] = {
60
+ "muted": MUTED,
61
+ "bright": BRIGHT,
62
+ "deep": DEEP,
63
+ "colorblind": COLORBLIND,
64
+ "pastel": PASTEL,
65
+ "dark": DARK,
66
+ }
67
+
68
+ # Palettes designed to stay distinguishable under common color-vision deficiencies.
69
+ # The seaborn "muted"/"bright"/etc. families are NOT in this set — they pair well
70
+ # as fills/strokes but should not carry meaning across categorical *lines*.
71
+ _CB_SAFE = {"okabe-ito", "colorblind"}
72
+
73
+ # ColorBrewer + perceptual maps that are NOT valid categorical cycles.
74
+ _SEQUENTIAL = {
75
+ "Blues", "BuGn", "BuPu", "GnBu", "Greens", "Greys", "OrRd", "Oranges",
76
+ "PuBu", "PuBuGn", "PuRd", "Purples", "RdPu", "Reds", "YlGn", "YlGnBu",
77
+ "YlOrBr", "YlOrRd", "viridis", "plasma", "inferno", "magma", "cividis",
78
+ }
79
+ _DIVERGING = {
80
+ "BrBG", "PiYG", "PRGn", "PuOr", "RdBu", "RdGy", "RdYlBu", "RdYlGn",
81
+ "Spectral", "coolwarm", "bwr", "seismic",
82
+ }
83
+ # True qualitative matplotlib colormaps — safe to use as categorical cycles.
84
+ # (Continuous maps are stored as 256-entry ListedColormaps in modern matplotlib,
85
+ # so a `hasattr(cmap, "colors")` test wrongly treats them as qualitative.)
86
+ _QUALITATIVE = {
87
+ "Pastel1", "Pastel2", "Paired", "Accent", "Dark2",
88
+ "Set1", "Set2", "Set3", "tab10", "tab20", "tab20b", "tab20c",
89
+ }
90
+ _RESERVED = {"mpl", "okabe-ito", "okabe_ito"}
91
+
92
+ _custom: Dict[str, List[str]] = {}
93
+
94
+
95
+ def register_palette(name: str, colors: Sequence) -> None:
96
+ """Register a named categorical palette from a list of colors (hex or RGB)."""
97
+ key = name.strip().lower()
98
+ if key in _RESERVED:
99
+ raise ValueError(f"{name!r} is a reserved palette name.")
100
+ try:
101
+ hexed = [mcolors.to_hex(c) for c in colors]
102
+ except (ValueError, TypeError) as e:
103
+ raise ValueError(f"Invalid color in palette {name!r}: {e}") from None
104
+ if not hexed:
105
+ raise ValueError("A palette needs at least one color.")
106
+ _custom[key] = hexed
107
+
108
+
109
+ def cmap(name: str):
110
+ """Return a matplotlib Colormap for ordered/heatmap data."""
111
+ return colormaps[name] # modern API; cm.get_cmap was removed in mpl 3.9
112
+
113
+
114
+ def palette(name: str = "okabe-ito", n: int | None = None) -> List[str]:
115
+ """Return a list of categorical colors.
116
+
117
+ Registered names and qualitative schemes are returned directly; sequential or
118
+ diverging scheme names raise a warning (that's the anti-pattern) and are
119
+ sampled as a fallback.
120
+ """
121
+ key = name.strip().lower()
122
+ if key in _custom:
123
+ cols = list(_custom[key])
124
+ elif key in ("okabe-ito", "okabe_ito"):
125
+ cols = list(OKABE_ITO)
126
+ elif key == "mpl":
127
+ cols = list(mcolors.TABLEAU_COLORS.values())
128
+ elif key in _BUILTIN:
129
+ cols = list(_BUILTIN[key])
130
+ elif name in _QUALITATIVE:
131
+ # A true qualitative colormap (Set2, Dark2, tab10…): take its discrete swatches.
132
+ c = colormaps[name]
133
+ k = n or getattr(c, "N", 8)
134
+ cols = [mcolors.to_hex(c(i % c.N)) for i in range(k)]
135
+ else:
136
+ # Any continuous map used as a categorical cycle is the anti-pattern —
137
+ # whether it's a known sequential/diverging name or an unlisted one
138
+ # (turbo, jet, hsv…). Warn and sample a *small* even set, never dump c.N.
139
+ try:
140
+ c = colormaps[name]
141
+ except KeyError:
142
+ raise ValueError(
143
+ f"Unknown palette or colormap {name!r}. "
144
+ f"Categorical palettes: {available_palettes()}; "
145
+ f"for ordered data use pp.cmap({name!r})."
146
+ ) from None
147
+ warnings.warn(
148
+ f"{name!r} is a continuous colormap; using it as a categorical "
149
+ f"cycle is an anti-pattern (adjacent categories look ordered/"
150
+ f"low-contrast). Use pp.cmap({name!r}) for ordered data, or a "
151
+ f"qualitative palette for categories.",
152
+ stacklevel=2,
153
+ )
154
+ k = n or 6
155
+ cols = [mcolors.to_hex(c(i / max(k - 1, 1))) for i in range(k)]
156
+
157
+ if n is not None:
158
+ cols = [cols[i % len(cols)] for i in range(n)]
159
+ return cols
160
+
161
+
162
+ def fills(n: int | None = None) -> List[str]:
163
+ """Muted palette for area / histogram **fills** (pairs with ``strokes()``)."""
164
+ return palette("muted", n)
165
+
166
+
167
+ def strokes(n: int | None = None) -> List[str]:
168
+ """Bright palette for **strokes** — lines, outlines, reference markers."""
169
+ return palette("bright", n)
170
+
171
+
172
+ def is_colorblind_safe(name: str) -> bool:
173
+ """Whether a named palette stays distinguishable under color-vision deficiency."""
174
+ return name.strip().lower().replace("_", "-") in _CB_SAFE
175
+
176
+
177
+ def available_palettes() -> List[str]:
178
+ """Names of all resolvable categorical palettes (built-in + registered)."""
179
+ names = ["okabe-ito", *_BUILTIN, "mpl"]
180
+ names += [k for k in _custom if k not in names]
181
+ return names
182
+
183
+
184
+ def resolve_cycle(value) -> List[str]:
185
+ """Coerce a palette name / color list / None into a list of cycle colors."""
186
+ if value is None:
187
+ return list(OKABE_ITO)
188
+ if isinstance(value, str):
189
+ return palette(value)
190
+ return [mcolors.to_hex(c) for c in value]