Source code for habit.viz.style

# Copyright (c) 2024-2026 Li Chao, Dong Mengshi and HABIT Contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Journal style presets, applied as a matplotlib context.

HABIT's figures are drawn ONCE and published in MANY places, and each journal
has its own physical constraints: column width, minimum resolution, whether
sans or serif type is expected. Encoding those as named presets -- applied
through a context manager that touches nothing outside the figure -- is what
lets the same plot function serve a draft and a camera-ready figure without a
single edit to the plotting code.

The presets are deliberately conservative: a white background, no chartjunk,
and a colour palette that stays distinguishable in greyscale and for the most
common colour-vision deficiencies. A preset is a **geometry / typography
starting point**, not a full submission checklist (file format, panel-letter
case, de-identification, figure legends, and venue redraw policies still
belong to the author).

Built-in numbers are taken from public author guides (verify before
submission; venues change specs):

* ``nature`` — Nature final-submission / final-artwork guides
  (https://www.nature.com/nature/for-authors/final-submission,
  https://www.nature.com/documents/nature-final-artwork.pdf):
  89 mm / 183 mm; Helvetica or Arial; other text max 7 pt / min 5 pt.
* ``radiology`` — RSNA *Radiology* Instructions for Authors
  (https://pubs.rsna.org/page/radiology/author-instructions):
  imaging panels 3–7 inches wide at ≥300 dpi; graphs/illustrations at
  1200 dpi; sans-serif (Arial) labels.
* ``lancet`` — Lancet family / *The Lancet Digital Health* Information for
  Authors (figures ≥300 dpi and ≥107 mm wide; Times New Roman for figure
  headings/legends; most line art is redrawn in house):
  https://www.thelancet.com/pb-assets/Lancet/authors/tldh-info-for-authors-1778587678573.pdf
"""

from __future__ import annotations

from contextlib import contextmanager
from dataclasses import dataclass, field, replace
from typing import Dict, Iterator, Tuple

from habit.exceptions import HABITAPIError
from habit.utils.optional_deps import require

__all__ = [
    "StyleSpec",
    "use_style",
    "get_style",
    "register_style",
    "available_styles",
]

#: Millimetres per inch, for converting journal column widths to matplotlib's
#: inch-based figure size.
_MM_PER_INCH = 25.4

#: Prefer journal-named faces first; DejaVu remains a portable fallback so
#: headless CI without Arial/Times still renders.
_SANS_FONT_LIST = ("Arial", "Helvetica", "DejaVu Sans")
_SERIF_FONT_LIST = ("Times New Roman", "Times", "DejaVu Serif")

#: Okabe–Ito colour-blind-safe palette (also survives greyscale printing).
_OKABE_ITO: Tuple[str, ...] = (
    "#0072B2",  # blue
    "#D55E00",  # vermillion
    "#009E73",  # bluish green
    "#CC79A7",  # reddish purple
    "#E69F00",  # orange
    "#56B4E9",  # sky blue
    "#F0E442",  # yellow
    "#000000",  # black
)


[docs] @dataclass(frozen=True) class StyleSpec: """ A named bundle of figure-geometry and typography choices. Attributes: name: Preset identifier used with :func:`use_style`. single_column_mm: Width of a single-column (or minimum submission) figure in millimetres. double_column_mm: Width of a full-width figure in millimetres. dpi: Default ``savefig.dpi`` hint for raster export. Callers may override at ``savefig`` time (e.g. RSNA line art at 1200 dpi). font_family: ``"sans-serif"`` or ``"serif"``. font_size: Base font size in points; tick/legend sizes scale from it. line_width: Base line width in points. palette: Colour cycle for groups/models. extra_rcparams: Any further rcParams the preset overrides. """ name: str single_column_mm: float = 89.0 double_column_mm: float = 183.0 dpi: int = 300 font_family: str = "sans-serif" font_size: float = 8.0 line_width: float = 1.0 palette: Tuple[str, ...] = field(default=_OKABE_ITO) extra_rcparams: Dict[str, object] = field(default_factory=dict)
[docs] def rcparams(self) -> Dict[str, object]: """Translate the spec into a matplotlib rcParams mapping.""" # Do NOT set savefig.bbox='tight': tight cropping changes the physical # width so the figure no longer matches the journal column millimetres. base: Dict[str, object] = { "figure.dpi": 100.0, # on-screen; the saved DPI is set at savefig "savefig.dpi": float(self.dpi), "font.family": self.font_family, "font.size": self.font_size, "axes.titlesize": self.font_size, "axes.labelsize": self.font_size, "axes.linewidth": self.line_width, "axes.edgecolor": "#333333", "axes.grid": False, "axes.prop_cycle": _color_cycle(self.palette), "xtick.labelsize": max(self.font_size - 1.0, 5.0), "ytick.labelsize": max(self.font_size - 1.0, 5.0), "xtick.major.width": self.line_width, "ytick.major.width": self.line_width, "legend.fontsize": max(self.font_size - 1.0, 5.0), "legend.frameon": False, "lines.linewidth": self.line_width, "figure.facecolor": "white", "axes.facecolor": "white", "savefig.facecolor": "white", "pdf.fonttype": 42, # embed TrueType so editors can re-typeset "ps.fonttype": 42, "svg.fonttype": "none", # keep text as text in SVG # ASCII hyphen-minus on ticks (U+2212 is not journal-safe here). "axes.unicode_minus": False, } if self.font_family == "serif": base["font.serif"] = list(_SERIF_FONT_LIST) else: base["font.sans-serif"] = list(_SANS_FONT_LIST) base.update(self.extra_rcparams) return base
[docs] def figsize(self, *, columns: int = 1, height_mm: float | None = None) -> Tuple[float, float]: """ Return an inch figure size for a one- or two-column figure. Args: columns: ``1`` for a single-column-width figure, ``2`` for full text width. height_mm: Optional explicit height in millimetres; defaults to a 4:3-ish proportion of the chosen width. Returns: ``(width_in, height_in)`` suitable for ``matplotlib``. """ width_mm = self.single_column_mm if columns == 1 else self.double_column_mm if height_mm is None: height_mm = width_mm * 0.75 return (width_mm / _MM_PER_INCH, height_mm / _MM_PER_INCH)
def _color_cycle(palette: Tuple[str, ...]): """Build an rcParams colour cycler without importing matplotlib eagerly.""" from cycler import cycler return cycler(color=list(palette)) # --------------------------------------------------------------------------- # Built-in presets # --------------------------------------------------------------------------- _REGISTRY: Dict[str, StyleSpec] = {} def _register_defaults() -> None: """Populate the built-in presets once, at import.""" # Draft / generic: Nature-like geometry at a lighter raster DPI. default = StyleSpec(name="default") # RSNA Radiology author instructions (imaging 3–7 in @ ≥300 dpi; # graphs/illustrations 1200 dpi; Arial sans-serif labels). # savefig.dpi=600 is a practical combo-figure default; override to 1200 # for pure line art when submitting revisions. # Imaging callouts in RSNA guides use ≥12 pt Arial; axis / tick text stays # at 8 pt here — enlarge annotation artists separately when labelling # medical-image panels. radiology = replace( default, name="radiology", single_column_mm=3.0 * _MM_PER_INCH, # 76.2 mm — official minimum double_column_mm=7.0 * _MM_PER_INCH, # 177.8 mm — official maximum dpi=600, font_family="sans-serif", font_size=8.0, ) # Nature final artwork: 89 / 183 mm; Helvetica or Arial; other text ≤7 pt. nature = replace( default, name="nature", single_column_mm=89.0, double_column_mm=183.0, dpi=600, font_family="sans-serif", font_size=7.0, line_width=0.8, # Nature guidance: strokes ~0.25–1 pt at final size ) # Lancet Digital Health / Lancet family author guides: figures ≥107 mm # wide at ≥300 dpi; Times New Roman for headings/legends; no box around # graphs; line art is typically redrawn in house. Double width uses a # full-page layout (~180 mm) because the public PDF does not publish a # second column millimetre for charts (only the 107 mm minimum). lancet = replace( default, name="lancet", single_column_mm=107.0, double_column_mm=180.0, dpi=600, font_family="serif", font_size=8.0, extra_rcparams={ "axes.spines.top": False, "axes.spines.right": False, }, ) for spec in (default, radiology, nature, lancet): _REGISTRY[spec.name] = spec
[docs] def register_style(spec: StyleSpec) -> None: """ Register a custom preset, making it usable by name in :func:`use_style`. Args: spec: The preset to register. Re-registering an existing name replaces it, so a project can ship its own house style. """ _REGISTRY[spec.name] = spec
[docs] def get_style(name: str) -> StyleSpec: """ Return the preset registered under ``name``. Args: name: Preset identifier, e.g. ``"radiology"``. Returns: The :class:`StyleSpec`. Raises: HABITAPIError: If the name is not registered. """ try: return _REGISTRY[name] except KeyError as exc: raise HABITAPIError( f"Unknown viz style {name!r}. Available: {available_styles()}. " "Register a custom one with habit.viz.register_style()." ) from exc
[docs] def available_styles() -> Tuple[str, ...]: """Return the registered preset names, sorted.""" return tuple(sorted(_REGISTRY))
[docs] @contextmanager def use_style(name: str | StyleSpec) -> Iterator[StyleSpec]: """ Apply a preset's rcParams for the duration of the block. Args: name: A registered preset name, or a :class:`StyleSpec` for a one-off style that need not be registered. Yields: The active :class:`StyleSpec`, so the block can read sizes and palette from it. Example: >>> from habit.viz import use_style, plot_kaplan_meier # doctest: +SKIP >>> with use_style("radiology") as style: ... fig = plot_kaplan_meier(...) >>> fig.savefig("km.tiff", dpi=style.dpi) # doctest: +SKIP """ spec = name if isinstance(name, StyleSpec) else get_style(name) # matplotlib is a required dependency. Style # presets can be registered and inspected without it; only ACTIVATING one # touches rcParams, so the gate sits here. mpl = require("matplotlib", extra="viz", purpose="applying a figure style preset") with mpl.rc_context(spec.rcparams()): yield spec
_register_defaults()