Skip to content

API reference

Everything below is re-exported at the top level: import journalfig as jf then jf.subplots(...).

journalfig

Publication-ready matplotlib themes for twelve scientific publishers.

Each theme encodes the figure requirements the publisher actually documents -- column widths, font sizes, panel label conventions, minimum line weights, and accepted file formats -- so a figure can be retargeted from one journal to another by changing a single string.

Example

import matplotlib.pyplot as plt import journalfig as jf jf.use("elsevier") fig, ax = plt.subplots(figsize=jf.figsize("elsevier", "single")) _ = ax.plot([1, 2, 3], [1, 4, 9], label=r"$S(q)$")

Author: Achraf Atila (achraf.atila@bam.de)

JournalSpec dataclass

Figure requirements for one publisher.

Attributes:

Name Type Description
name str

Human-readable publisher name.

widths_mm dict[str, float]

Allowed figure widths keyed by layout name.

max_height_mm float | None

Maximum figure height, or None if the publisher does not state one.

min_width_mm float | None

Minimum figure width, or None if unstated.

text_min_pt float | None

Smallest permitted size for normal text, or None if unstated.

text_max_pt float | None

Largest permitted size for normal text, or None if unstated.

sub_min_pt float | None

Smallest permitted size for sub/superscripts, or None if unstated.

min_lettering_mm float | None

Minimum printed glyph height, or None if unstated.

min_marker_mm float | None

Minimum printed data-point diameter, or None if unstated.

min_linewidth_pt float | None

Minimum printed line weight, or None if unstated.

panel_label_fmt str

Format string for panel labels, e.g. "{}" or "({})".

panel_label_upper bool

Label panels A, B, C rather than a, b, c. Science asks for uppercase; most publishers that state a convention at all ask for lowercase.

panel_label_pt float

Panel label font size.

raster_dpi int | None

The publisher's stated minimum resolution for raster artwork, and the resolution :func:journalfig.save writes at unless a higher one is asked for. Exceeding it is compliant -- the themes set savefig.dpi to 600 for that reason -- and only falling below it is a violation, which :func:journalfig.save warns about. None where the publisher states no resolution at all, as IOP does not: there is then no floor to enforce and :func:journalfig.save writes at the theme's own 600 dpi.

formats tuple[str, ...]

File formats :func:journalfig.save writes by default. The same three everywhere -- PDF to submit, SVG to edit, PNG to drop into a talk -- since one figure is usually wanted in all three. Any other format the backend supports can be asked for explicitly.

submission_formats tuple[str, ...]

What the publisher accepts as final artwork, which is not the same question as what is useful to have on disk. Drives the draft warning in :func:journalfig.save, which fires only when nothing written is submittable: a PNG beside a PDF is silent, a PNG on its own is not.

font_families tuple[str, ...]

Typefaces the publisher asks for, most preferred first.

font_substitutes tuple[str, ...]

Metrically compatible stand-ins. Type designers cut these to the same widths, so a figure set in one is indistinguishable in layout from the real thing; :func:journalfig.check accepts them and :func:journalfig.fonts names them.

tiff_compression str | None

Pillow compression passed through when a TIFF is written, or None to write it uncompressed.

style ThemeStyle

Rendering choices for this theme. Deliberately a separate object: nothing in it comes from the publisher, and :func:journalfig.check never validates against it.

Source code in src/journalfig/_specs.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
@dataclass(frozen=True)
class JournalSpec:
    """Figure requirements for one publisher.

    Attributes:
        name: Human-readable publisher name.
        widths_mm: Allowed figure widths keyed by layout name.
        max_height_mm: Maximum figure height, or ``None`` if the publisher does not state one.
        min_width_mm: Minimum figure width, or ``None`` if unstated.
        text_min_pt: Smallest permitted size for normal text, or ``None`` if unstated.
        text_max_pt: Largest permitted size for normal text, or ``None`` if unstated.
        sub_min_pt: Smallest permitted size for sub/superscripts, or ``None`` if unstated.
        min_lettering_mm: Minimum printed glyph height, or ``None`` if unstated.
        min_marker_mm: Minimum printed data-point diameter, or ``None`` if unstated.
        min_linewidth_pt: Minimum printed line weight, or ``None`` if unstated.
        panel_label_fmt: Format string for panel labels, e.g. ``"{}"`` or ``"({})"``.
        panel_label_upper: Label panels ``A, B, C`` rather than ``a, b, c``. Science asks for
            uppercase; most publishers that state a convention at all ask for lowercase.
        panel_label_pt: Panel label font size.
        raster_dpi: The publisher's stated *minimum* resolution for raster artwork, and the resolution
            :func:`journalfig.save` writes at unless a higher one is asked for. Exceeding it is
            compliant -- the themes set ``savefig.dpi`` to 600 for that reason -- and only falling
            below it is a violation, which :func:`journalfig.save` warns about. ``None`` where the
            publisher states no resolution at all, as IOP does not: there is then no floor to enforce
            and :func:`journalfig.save` writes at the theme's own 600 dpi.
        formats: File formats :func:`journalfig.save` writes by default. The same three everywhere --
            PDF to submit, SVG to edit, PNG to drop into a talk -- since one figure is usually wanted
            in all three. Any other format the backend supports can be asked for explicitly.
        submission_formats: What the publisher accepts as *final artwork*, which is not the same
            question as what is useful to have on disk. Drives the draft warning in
            :func:`journalfig.save`, which fires only when nothing written is submittable: a PNG
            beside a PDF is silent, a PNG on its own is not.
        font_families: Typefaces the publisher asks for, most preferred first.
        font_substitutes: Metrically compatible stand-ins. Type designers cut these to the same
            widths, so a figure set in one is indistinguishable in layout from the real thing;
            :func:`journalfig.check` accepts them and :func:`journalfig.fonts` names them.
        tiff_compression: Pillow compression passed through when a TIFF is written, or ``None`` to
            write it uncompressed.
        style: Rendering choices for this theme. Deliberately a separate object: nothing in it comes
            from the publisher, and :func:`journalfig.check` never validates against it.
    """

    name: str
    widths_mm: dict[str, float]
    max_height_mm: float | None
    min_width_mm: float | None
    text_min_pt: float | None
    text_max_pt: float | None
    sub_min_pt: float | None
    min_lettering_mm: float | None
    min_marker_mm: float | None
    min_linewidth_pt: float | None
    panel_label_fmt: str
    panel_label_pt: float
    raster_dpi: int | None
    panel_label_upper: bool = False
    formats: tuple[str, ...] = DEFAULT_FORMATS
    submission_formats: tuple[str, ...] = ("pdf",)
    font_sizes: dict[str, float] = field(default_factory=dict)
    font_families: tuple[str, ...] = ()
    font_substitutes: tuple[str, ...] = ()
    # LZW is lossless, so the decoded pixels are identical to an uncompressed write and compliance
    # cannot change. Elsevier recommends TIFF but documents no compression scheme either way; this
    # is our choice, not theirs. Pass pil_kwargs={} to journalfig.save to turn it off.
    tiff_compression: str | None = "tiff_lzw"
    style: ThemeStyle = field(default_factory=ThemeStyle)
    #: Reasoning a generator cannot derive from the numbers -- why a size was chosen, or a trap the
    #: publisher's rules set. Emitted into the generated stylesheet header beneath the source citation.
    notes: tuple[str, ...] = ()

ThemeStyle dataclass

How a theme draws, as opposed to what its publisher requires.

Nothing in this class is quoted from an author guide. No publisher states a tick length, a marker diameter, or a legend handle length; these are journalfig's own choices about what looks right at the sizes the publisher does state, and they are kept out of :class:JournalSpec so that "every number is traceable to a publisher document" stays literally true of that class. :func:journalfig.check validates against :class:JournalSpec only, never against this.

Values are in points unless the name says otherwise. The defaults are the Nature theme's, which is the most conservative of the three; a theme built for a larger base font wants larger ticks.

Attributes:

Name Type Description
axes_linewidth float

Weight of the axes frame, and of patch and hatch edges with it.

grid_linewidth float

Weight of grid lines, and of minor tick marks with them.

tick_major_size float

Length of major tick marks. Applied to both axes -- x and y are never given different tick geometry, and hand-writing them separately invited asymmetry.

tick_minor_size float

Length of minor tick marks, both axes.

tick_major_width float

Weight of major tick marks, both axes.

tick_minor_width float

Weight of minor tick marks, both axes.

tick_pad float

Gap between a tick mark and its label, both axes.

marker_size float

Default marker diameter for line plots.

marker_edge_width float

Default marker edge weight.

errorbar_capsize float

Half-width of an errorbar cap.

legend_handlelength float

Length of the sample line in a legend entry.

title_weight str

"bold" or "normal" for axes titles.

title_location str

"left", "center" or "right" for axes titles.

mathtext_fontset str

"custom" to pin maths to the theme's own face, or a matplotlib set such as "stix" where that face already matches the publisher's typeface.

font_family_category str

"sans-serif" or "serif" -- which matplotlib family the stack below is registered under.

font_stack tuple[str, ...]

The concrete fallback chain matplotlib searches, most preferred first, ending in a face matplotlib bundles so the theme degrades rather than fails. This is a third thing from JournalSpec.font_families (what the publisher asks for) and font_substitutes (what :func:journalfig.check accepts): Elsevier permits Times, for instance, yet the theme renders all-sans, and the stack lists only the substitutes worth actually trying.

Source code in src/journalfig/_specs.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
@dataclass(frozen=True)
class ThemeStyle:
    """How a theme draws, as opposed to what its publisher requires.

    Nothing in this class is quoted from an author guide. No publisher states a tick length, a marker
    diameter, or a legend handle length; these are journalfig's own choices about what looks right at
    the sizes the publisher *does* state, and they are kept out of :class:`JournalSpec` so that
    "every number is traceable to a publisher document" stays literally true of that class.
    :func:`journalfig.check` validates against :class:`JournalSpec` only, never against this.

    Values are in points unless the name says otherwise. The defaults are the Nature theme's, which is
    the most conservative of the three; a theme built for a larger base font wants larger ticks.

    Attributes:
        axes_linewidth: Weight of the axes frame, and of patch and hatch edges with it.
        grid_linewidth: Weight of grid lines, and of minor tick marks with them.
        tick_major_size: Length of major tick marks. Applied to both axes -- x and y are never
            given different tick geometry, and hand-writing them separately invited asymmetry.
        tick_minor_size: Length of minor tick marks, both axes.
        tick_major_width: Weight of major tick marks, both axes.
        tick_minor_width: Weight of minor tick marks, both axes.
        tick_pad: Gap between a tick mark and its label, both axes.
        marker_size: Default marker diameter for line plots.
        marker_edge_width: Default marker edge weight.
        errorbar_capsize: Half-width of an errorbar cap.
        legend_handlelength: Length of the sample line in a legend entry.
        title_weight: ``"bold"`` or ``"normal"`` for axes titles.
        title_location: ``"left"``, ``"center"`` or ``"right"`` for axes titles.
        mathtext_fontset: ``"custom"`` to pin maths to the theme's own face, or a matplotlib set
            such as ``"stix"`` where that face already matches the publisher's typeface.
        font_family_category: ``"sans-serif"`` or ``"serif"`` -- which matplotlib family the stack
            below is registered under.
        font_stack: The concrete fallback chain matplotlib searches, most preferred first, ending in
            a face matplotlib bundles so the theme degrades rather than fails. This is a third thing
            from ``JournalSpec.font_families`` (what the publisher asks for) and
            ``font_substitutes`` (what :func:`journalfig.check` accepts): Elsevier permits Times, for
            instance, yet the theme renders all-sans, and the stack lists only the substitutes worth
            actually trying.
    """

    axes_linewidth: float = 0.5
    grid_linewidth: float = 0.4
    tick_major_size: float = 2.5
    tick_minor_size: float = 1.5
    tick_major_width: float = 0.5
    tick_minor_width: float = 0.4
    tick_pad: float = 2.0
    marker_size: float = 3.0
    marker_edge_width: float = 0.6
    errorbar_capsize: float = 1.5
    legend_handlelength: float = 1.6
    title_weight: str = "bold"
    title_location: str = "left"
    mathtext_fontset: str = "custom"
    font_family_category: str = "sans-serif"
    font_stack: tuple[str, ...] = (
        # Arial leads: it exposes separate regular/italic/bold faces, whereas macOS Helvetica
        # collapses every variant into a single .ttc. Ends in DejaVu Sans, which matplotlib bundles,
        # so the theme degrades rather than fails off macOS.
        "Arial",
        "Helvetica",
        "Helvetica Neue",
        "Nimbus Sans",
        "Liberation Sans",
        "DejaVu Sans",
    )

Source dataclass

The publisher document a theme's numbers were read from.

Publishers revise their artwork guidelines, so a specification is only as trustworthy as the date it was last checked against the source. retrieved records that date.

Attributes:

Name Type Description
url str

Where the document was obtained.

title str

The document's own title.

version str | None

The document's stated edition or date, or None if it carries neither.

retrieved str

ISO date on which the numbers in :data:SPECS were last checked against it.

Source code in src/journalfig/_specs.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass(frozen=True)
class Source:
    """The publisher document a theme's numbers were read from.

    Publishers revise their artwork guidelines, so a specification is only as trustworthy as the date
    it was last checked against the source. ``retrieved`` records that date.

    Attributes:
        url: Where the document was obtained.
        title: The document's own title.
        version: The document's stated edition or date, or ``None`` if it carries neither.
        retrieved: ISO date on which the numbers in :data:`SPECS` were last checked against it.
    """

    url: str
    title: str
    version: str | None
    retrieved: str

    def __str__(self) -> str:
        edition = f", {self.version}" if self.version else ""
        return f"{self.title}{edition} (retrieved {self.retrieved})"

Violation dataclass

A single departure from a publisher's figure requirements.

Attributes:

Name Type Description
kind str

Short category, e.g. "text", "line", "width".

message str

Human-readable description including the measured and required values.

Source code in src/journalfig/_core.py
653
654
655
656
657
658
659
660
661
662
663
664
665
666
@dataclass(frozen=True)
class Violation:
    """A single departure from a publisher's figure requirements.

    Attributes:
        kind: Short category, e.g. ``"text"``, ``"line"``, ``"width"``.
        message: Human-readable description including the measured and required values.
    """

    kind: str
    message: str

    def __str__(self) -> str:
        return f"[{self.kind}] {self.message}"

FontStatus dataclass

What a requested typeface actually resolved to on this machine.

Attributes:

Name Type Description
requested tuple[str, ...]

The families asked for, in the order matplotlib searches them.

resolved str

The family that will really be drawn.

path str

The font file backing resolved.

status str

"exact" for a face the publisher names, "substitute" for a metrically compatible stand-in, "unrestricted" where the publisher names no typeface at all, and "fallback" for a face that misses a stated requirement.

Source code in src/journalfig/_core.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
@dataclass(frozen=True)
class FontStatus:
    """What a requested typeface actually resolved to on this machine.

    Attributes:
        requested: The families asked for, in the order matplotlib searches them.
        resolved: The family that will really be drawn.
        path: The font file backing ``resolved``.
        status: ``"exact"`` for a face the publisher names, ``"substitute"`` for a metrically
            compatible stand-in, ``"unrestricted"`` where the publisher names no typeface at all,
            and ``"fallback"`` for a face that misses a stated requirement.
    """

    requested: tuple[str, ...]
    resolved: str
    path: str
    status: str

    def __str__(self) -> str:
        return f"{self.resolved} [{self.status}] <- {', '.join(self.requested)}"

JournalFigWarning

Bases: UserWarning

Warning raised for a figure that departs from a publisher's requirements.

A dedicated class so these can be filtered without silencing every other UserWarning: warnings.filterwarnings("ignore", category=journalfig.JournalFigWarning).

Source code in src/journalfig/_core.py
50
51
52
53
54
55
class JournalFigWarning(UserWarning):
    """Warning raised for a figure that departs from a publisher's requirements.

    A dedicated class so these can be filtered without silencing every other ``UserWarning``:
    ``warnings.filterwarnings("ignore", category=journalfig.JournalFigWarning)``.
    """

use

use(journal: str, *, usetex: bool = False, base_size: float | None = None) -> None

Apply a journal theme to the global matplotlib state.

Parameters:

Name Type Description Default
journal str

Theme key ("nature", "aps", "elsevier") or a journal alias such as "Acta Materialia" or "PRB".

required
usetex bool

Render text with the system LaTeX installation instead of mathtext. Slower, and requires latex plus dvipng on PATH. The APS preamble uses mathptmx for Times maths, which needs matplotlib >= 3.10 on TeX installations whose pdftex.map carries no Type 1 entry for it; on 3.8 and 3.9 the same setup raises LookupError at save time. Nature and Elsevier are unaffected.

False
base_size float | None

Override the theme's base font size in points; every other font size is scaled by the same factor. Useful for maths-heavy Elsevier figures, where the publisher's 6 pt sub/superscript floor needs a base of at least 8.6 pt.

None
Example

use("elsevier", base_size=9) plt.rcParams["font.size"] 9.0

Source code in src/journalfig/_core.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def use(journal: str, *, usetex: bool = False, base_size: float | None = None) -> None:
    """Apply a journal theme to the global matplotlib state.

    Args:
        journal: Theme key (``"nature"``, ``"aps"``, ``"elsevier"``) or a journal alias
            such as ``"Acta Materialia"`` or ``"PRB"``.
        usetex: Render text with the system LaTeX installation instead of mathtext. Slower, and
            requires ``latex`` plus ``dvipng`` on ``PATH``. The APS preamble uses ``mathptmx`` for
            Times maths, which needs matplotlib >= 3.10 on TeX installations whose ``pdftex.map``
            carries no Type 1 entry for it; on 3.8 and 3.9 the same setup raises ``LookupError`` at
            save time. Nature and Elsevier are unaffected.
        base_size: Override the theme's base font size in points; every other font size is scaled
            by the same factor. Useful for maths-heavy Elsevier figures, where the publisher's
            6 pt sub/superscript floor needs a base of at least 8.6 pt.

    Example:
        >>> use("elsevier", base_size=9)
        >>> plt.rcParams["font.size"]
        9.0
    """
    global _ACTIVE
    key = resolve(journal)
    spec = get_spec(key)
    # Base first, then the journal's overlay on top: the overlay carries only what differs.
    plt.style.use([str(_BASE_STYLE), str(style_file(key))])

    if base_size is not None:
        _scale_fonts(float(base_size) / spec.font_sizes["base"])

    if usetex:
        plt.rcParams["text.usetex"] = True
        plt.rcParams["text.latex.preamble"] = _usetex_preamble(spec)

    _ACTIVE = key

context

context(journal: str, **kwargs: Any) -> Iterator[None]

Apply a theme temporarily, restoring the previous rcParams on exit.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
**kwargs Any

Forwarded to :func:use.

{}

Yields:

Type Description
None

None, with the theme active for the duration of the block.

Example

plt.rcParams["font.size"] = 11.0 with context("nature"): ... pass plt.rcParams["font.size"] 11.0

Source code in src/journalfig/_core.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
@contextmanager
def context(journal: str, **kwargs: Any) -> Iterator[None]:
    """Apply a theme temporarily, restoring the previous rcParams on exit.

    Args:
        journal: Theme key or journal alias.
        **kwargs: Forwarded to :func:`use`.

    Yields:
        ``None``, with the theme active for the duration of the block.

    Example:
        >>> plt.rcParams["font.size"] = 11.0
        >>> with context("nature"):
        ...     pass
        >>> plt.rcParams["font.size"]
        11.0
    """
    global _ACTIVE
    saved = plt.rcParams.copy()
    saved_active = _ACTIVE
    try:
        use(journal, **kwargs)
        yield
    finally:
        plt.rcParams.update(saved)
        _ACTIVE = saved_active

figsize

figsize(journal: str, width: str | float = 'single', *, ratio: float = GOLDEN, height_mm: float | None = None) -> tuple[float, float]

Return an exact figure size in inches for a journal column width.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
width str | float

A layout name from the journal's spec ("single", "onehalf", "double", and for Nature also "onehalf_wide"), or an explicit width in millimetres.

'single'
ratio float

Height divided by width. Defaults to the inverse golden ratio.

GOLDEN
height_mm float | None

Explicit height in millimetres, overriding ratio.

None

Returns:

Type Description
tuple[float, float]

(width_inches, height_inches).

Raises:

Type Description
KeyError

If width names a layout the journal does not define.

Example

tuple(round(v, 4) for v in figsize("nature", "single")) (3.5039, 2.1656) tuple(round(v, 4) for v in figsize("aps", "double", ratio=0.4)) (7.0, 2.8)

Source code in src/journalfig/_core.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def figsize(
    journal: str,
    width: str | float = "single",
    *,
    ratio: float = GOLDEN,
    height_mm: float | None = None,
) -> tuple[float, float]:
    """Return an exact figure size in inches for a journal column width.

    Args:
        journal: Theme key or journal alias.
        width: A layout name from the journal's spec (``"single"``, ``"onehalf"``, ``"double"``,
            and for Nature also ``"onehalf_wide"``), or an explicit width in millimetres.
        ratio: Height divided by width. Defaults to the inverse golden ratio.
        height_mm: Explicit height in millimetres, overriding ``ratio``.

    Returns:
        ``(width_inches, height_inches)``.

    Raises:
        KeyError: If ``width`` names a layout the journal does not define.

    Example:
        >>> tuple(round(v, 4) for v in figsize("nature", "single"))
        (3.5039, 2.1656)
        >>> tuple(round(v, 4) for v in figsize("aps", "double", ratio=0.4))
        (7.0, 2.8)
    """
    spec = get_spec(journal)
    if isinstance(width, str):
        if width not in spec.widths_mm:
            raise KeyError(f"{spec.name} has no width {width!r}; available: {sorted(spec.widths_mm)}")
        width_mm = spec.widths_mm[width]
    else:
        width_mm = float(width)

    if spec.min_width_mm is not None and width_mm < spec.min_width_mm:
        warnings.warn(
            f"{width_mm:.1f} mm is below the {spec.name} minimum of {spec.min_width_mm:.0f} mm",
            category=JournalFigWarning,
            stacklevel=2,
        )

    h_mm = height_mm if height_mm is not None else width_mm * ratio
    if spec.max_height_mm is not None and h_mm > spec.max_height_mm:
        warnings.warn(
            f"height {h_mm:.1f} mm exceeds the {spec.name} maximum of {spec.max_height_mm:.0f} mm; clamping",
            category=JournalFigWarning,
            stacklevel=2,
        )
        h_mm = spec.max_height_mm

    return mm_to_inch(width_mm), mm_to_inch(h_mm)

figure

figure(journal: str, width: str | float = 'single', *, ratio: float = GOLDEN, height_mm: float | None = None, **kwargs: Any) -> Figure

Create a figure at an exact journal column width.

Prefer this over plt.figure(figsize=figsize(...)): it survives the pixel rounding that interactive backends apply, and it records the requested size so :func:save can re-assert it.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
width str | float

Layout name or an explicit width in millimetres.

'single'
ratio float

Height divided by width.

GOLDEN
height_mm float | None

Explicit height in millimetres, overriding ratio.

None
**kwargs Any

Forwarded to plt.figure.

{}

Returns:

Type Description
Figure

The new figure, sized exactly.

Example

use("nature") round(float(figure("nature", "double").get_size_inches()[0]) * 25.4, 4) 183.0

Source code in src/journalfig/_core.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def figure(
    journal: str,
    width: str | float = "single",
    *,
    ratio: float = GOLDEN,
    height_mm: float | None = None,
    **kwargs: Any,
) -> Figure:
    """Create a figure at an exact journal column width.

    Prefer this over ``plt.figure(figsize=figsize(...))``: it survives the pixel rounding that
    interactive backends apply, and it records the requested size so :func:`save` can re-assert it.

    Args:
        journal: Theme key or journal alias.
        width: Layout name or an explicit width in millimetres.
        ratio: Height divided by width.
        height_mm: Explicit height in millimetres, overriding ``ratio``.
        **kwargs: Forwarded to ``plt.figure``.

    Returns:
        The new figure, sized exactly.

    Example:
        >>> use("nature")
        >>> round(float(figure("nature", "double").get_size_inches()[0]) * 25.4, 4)
        183.0
    """
    size = figsize(journal, width, ratio=ratio, height_mm=height_mm)
    fig = plt.figure(figsize=size, **kwargs)
    _pin_size(fig, size)
    return fig

subplots

subplots(journal: str, nrows: int = 1, ncols: int = 1, *, width: str | float = 'single', ratio: float = GOLDEN, height_mm: float | None = None, **kwargs: Any) -> tuple[Figure, Any]

Create a figure and axes grid at an exact journal column width.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
nrows int

Number of subplot rows.

1
ncols int

Number of subplot columns.

1
width str | float

Layout name or an explicit width in millimetres.

'single'
ratio float

Height divided by width.

GOLDEN
height_mm float | None

Explicit height in millimetres, overriding ratio.

None
**kwargs Any

Forwarded to plt.subplots.

{}

Returns:

Type Description
tuple[Figure, Any]

(figure, axes), exactly as plt.subplots returns them.

Example

use("elsevier") fig, axs = subplots("elsevier", 2, 2, width="double") round(float(fig.get_size_inches()[0]) * 25.4, 4) 190.0

Source code in src/journalfig/_core.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def subplots(
    journal: str,
    nrows: int = 1,
    ncols: int = 1,
    *,
    width: str | float = "single",
    ratio: float = GOLDEN,
    height_mm: float | None = None,
    **kwargs: Any,
) -> tuple[Figure, Any]:
    """Create a figure and axes grid at an exact journal column width.

    Args:
        journal: Theme key or journal alias.
        nrows: Number of subplot rows.
        ncols: Number of subplot columns.
        width: Layout name or an explicit width in millimetres.
        ratio: Height divided by width.
        height_mm: Explicit height in millimetres, overriding ``ratio``.
        **kwargs: Forwarded to ``plt.subplots``.

    Returns:
        ``(figure, axes)``, exactly as ``plt.subplots`` returns them.

    Example:
        >>> use("elsevier")
        >>> fig, axs = subplots("elsevier", 2, 2, width="double")
        >>> round(float(fig.get_size_inches()[0]) * 25.4, 4)
        190.0
    """
    size = figsize(journal, width, ratio=ratio, height_mm=height_mm)
    fig, axes = plt.subplots(nrows, ncols, figsize=size, **kwargs)
    _pin_size(fig, size)
    return fig, axes

mosaic

mosaic(journal: str, layout: str | HashableList[Hashable], *, width: str | float = 'single', ratio: float = GOLDEN, height_mm: float | None = None, width_ratios: Sequence[float] | None = None, height_ratios: Sequence[float] | None = None, **kwargs: Any) -> tuple[Figure, dict[str, Axes]]

Create panels of unequal size from an ASCII sketch of the layout.

Each cell of the sketch names the panel that occupies it; repeating a name over neighbouring cells makes that panel span them, and "." leaves a cell empty. width_ratios and height_ratios then set how large the underlying grid's columns and rows are relative to each other, so panels need not be multiples of one cell size.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
layout str | HashableList[Hashable]

Panel layout, either a multi-line string with one character per cell or nested lists of panel names (use lists for names longer than one character).

required
width str | float

Layout name or an explicit width in millimetres.

'single'
ratio float

Height divided by width. Layouts of more than one row usually need a value well above the default inverse golden ratio.

GOLDEN
height_mm float | None

Explicit height in millimetres, overriding ratio.

None
width_ratios Sequence[float] | None

Relative widths of the grid columns; one entry per column.

None
height_ratios Sequence[float] | None

Relative heights of the grid rows; one entry per row.

None
**kwargs Any

Forwarded to plt.subplot_mosaic, e.g. sharex or per_subplot_kw.

{}

Returns:

Type Description
Figure

(figure, panels), where panels maps each name in the sketch to its axes, in the

dict[str, Axes]

order the names first appear when reading the sketch left to right, top to bottom.

Example

use("nature") fig, panels = mosaic("nature", "AAB\nCCB", width="double", width_ratios=[1, 1, 1.4]) list(panels) ['A', 'B', 'C'] round(float(fig.get_size_inches()[0]) * 25.4, 4) 183.0

Source code in src/journalfig/_core.py
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
def mosaic(
    journal: str,
    layout: str | HashableList[Hashable],
    *,
    width: str | float = "single",
    ratio: float = GOLDEN,
    height_mm: float | None = None,
    width_ratios: Sequence[float] | None = None,
    height_ratios: Sequence[float] | None = None,
    **kwargs: Any,
) -> tuple[Figure, dict[str, Axes]]:
    """Create panels of unequal size from an ASCII sketch of the layout.

    Each cell of the sketch names the panel that occupies it; repeating a name over neighbouring
    cells makes that panel span them, and ``"."`` leaves a cell empty. ``width_ratios`` and
    ``height_ratios`` then set how large the underlying grid's columns and rows are relative to each
    other, so panels need not be multiples of one cell size.

    Args:
        journal: Theme key or journal alias.
        layout: Panel layout, either a multi-line string with one character per cell or nested lists
            of panel names (use lists for names longer than one character).
        width: Layout name or an explicit width in millimetres.
        ratio: Height divided by width. Layouts of more than one row usually need a value well above
            the default inverse golden ratio.
        height_mm: Explicit height in millimetres, overriding ``ratio``.
        width_ratios: Relative widths of the grid columns; one entry per column.
        height_ratios: Relative heights of the grid rows; one entry per row.
        **kwargs: Forwarded to ``plt.subplot_mosaic``, e.g. ``sharex`` or ``per_subplot_kw``.

    Returns:
        ``(figure, panels)``, where ``panels`` maps each name in the sketch to its axes, in the
        order the names first appear when reading the sketch left to right, top to bottom.

    Example:
        >>> use("nature")
        >>> fig, panels = mosaic("nature", "AAB\\nCCB", width="double", width_ratios=[1, 1, 1.4])
        >>> list(panels)
        ['A', 'B', 'C']
        >>> round(float(fig.get_size_inches()[0]) * 25.4, 4)
        183.0
    """
    size = figsize(journal, width, ratio=ratio, height_mm=height_mm)
    gridspec_kw = _merge_ratios(kwargs, width_ratios, height_ratios)
    # mypy cannot match a union against subplot_mosaic's overload set: it has one overload for str and
    # one for the nested-list form, and no overload accepting either. Both members are individually
    # valid, which is what the annotation documents.
    fig, panels = plt.subplot_mosaic(
        layout,  # type: ignore[arg-type]
        figsize=size,
        gridspec_kw=gridspec_kw or None,
        **kwargs,
    )
    _pin_size(fig, size)
    return fig, panels

gridspec

gridspec(journal: str, nrows: int = 1, ncols: int = 1, *, width: str | float = 'single', ratio: float = GOLDEN, height_mm: float | None = None, width_ratios: Sequence[float] | None = None, height_ratios: Sequence[float] | None = None, **kwargs: Any) -> tuple[Figure, GridSpec]

Create a figure and an empty grid to place spanning panels on by hand.

Use this when a layout is easier to express as slices than as a sketch -- a panel spanning columns 0 and 1 of row 0 is fig.add_subplot(gs[0, :2]). For anything that fits in a sketch, :func:mosaic is shorter. Both size the figure identically.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required
nrows int

Number of grid rows.

1
ncols int

Number of grid columns.

1
width str | float

Layout name or an explicit width in millimetres.

'single'
ratio float

Height divided by width.

GOLDEN
height_mm float | None

Explicit height in millimetres, overriding ratio.

None
width_ratios Sequence[float] | None

Relative widths of the grid columns; one entry per column.

None
height_ratios Sequence[float] | None

Relative heights of the grid rows; one entry per row.

None
**kwargs Any

Forwarded to Figure.add_gridspec.

{}

Returns:

Type Description
tuple[Figure, GridSpec]

(figure, gridspec). The grid holds no axes until you add them.

Example

use("aps") fig, gs = gridspec("aps", 2, 3, width="double", height_ratios=[1, 1.5]) wide = fig.add_subplot(gs[0, :2]) tall = fig.add_subplot(gs[:, 2]) gs.get_geometry() (2, 3)

Source code in src/journalfig/_core.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def gridspec(
    journal: str,
    nrows: int = 1,
    ncols: int = 1,
    *,
    width: str | float = "single",
    ratio: float = GOLDEN,
    height_mm: float | None = None,
    width_ratios: Sequence[float] | None = None,
    height_ratios: Sequence[float] | None = None,
    **kwargs: Any,
) -> tuple[Figure, GridSpec]:
    """Create a figure and an empty grid to place spanning panels on by hand.

    Use this when a layout is easier to express as slices than as a sketch -- a panel spanning
    columns 0 and 1 of row 0 is ``fig.add_subplot(gs[0, :2])``. For anything that fits in a sketch,
    :func:`mosaic` is shorter. Both size the figure identically.

    Args:
        journal: Theme key or journal alias.
        nrows: Number of grid rows.
        ncols: Number of grid columns.
        width: Layout name or an explicit width in millimetres.
        ratio: Height divided by width.
        height_mm: Explicit height in millimetres, overriding ``ratio``.
        width_ratios: Relative widths of the grid columns; one entry per column.
        height_ratios: Relative heights of the grid rows; one entry per row.
        **kwargs: Forwarded to ``Figure.add_gridspec``.

    Returns:
        ``(figure, gridspec)``. The grid holds no axes until you add them.

    Example:
        >>> use("aps")
        >>> fig, gs = gridspec("aps", 2, 3, width="double", height_ratios=[1, 1.5])
        >>> wide = fig.add_subplot(gs[0, :2])
        >>> tall = fig.add_subplot(gs[:, 2])
        >>> gs.get_geometry()
        (2, 3)
    """
    size = figsize(journal, width, ratio=ratio, height_mm=height_mm)
    fig = plt.figure(figsize=size)
    grid = fig.add_gridspec(
        nrows,
        ncols,
        width_ratios=list(width_ratios) if width_ratios is not None else None,
        height_ratios=list(height_ratios) if height_ratios is not None else None,
        **kwargs,
    )
    _pin_size(fig, size)
    return fig, grid

panel_labels

panel_labels(axes: Iterable[Axes] | Axes, *, journal: str | None = None, fmt: str | None = None, labels: Iterable[str] | None = None, dx: float = -16.0, dy: float = 3.0, weight: str = 'bold', size: float | None = None, **kwargs: Any) -> list[Text]

Add journal-styled panel labels to a set of axes.

Nature wants bare lowercase letters (a, b); APS and Elsevier want parentheses ((a), (b)). The label is offset in points from each axes' top-left corner, so it stays put regardless of figure size.

Parameters:

Name Type Description Default
axes Iterable[Axes] | Axes

A single axes or an iterable of them, in reading order. Accepts the array returned by plt.subplots and the name-to-axes mapping returned by :func:mosaic, which is already in reading order; pass labels=list(panels) to letter a mosaic by its own names instead.

required
journal str | None

Theme key or alias. Defaults to the active theme.

None
fmt str | None

Format string applied to each letter, overriding the journal default.

None
labels Iterable[str] | None

Explicit label strings, overriding the generated letters.

None
dx float

Horizontal offset in points from the axes' left edge.

-16.0
dy float

Vertical offset in points from the axes' top edge.

3.0
weight str

Font weight.

'bold'
size float | None

Font size in points. Defaults to the journal's panel label size.

None
**kwargs Any

Forwarded to Axes.annotate.

{}

Returns:

Type Description
list[Text]

The created text artists, in the order the axes were given.

Example

use("aps") fig, axs = plt.subplots(1, 2) tuple(t.get_text() for t in panel_labels(axs)) ('(a)', '(b)')

Source code in src/journalfig/_core.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
def panel_labels(
    axes: Iterable[Axes] | Axes,
    *,
    journal: str | None = None,
    fmt: str | None = None,
    labels: Iterable[str] | None = None,
    dx: float = -16.0,
    dy: float = 3.0,
    weight: str = "bold",
    size: float | None = None,
    **kwargs: Any,
) -> list[Text]:
    """Add journal-styled panel labels to a set of axes.

    Nature wants bare lowercase letters (``a``, ``b``); APS and Elsevier want parentheses
    (``(a)``, ``(b)``). The label is offset in points from each axes' top-left corner, so it stays
    put regardless of figure size.

    Args:
        axes: A single axes or an iterable of them, in reading order. Accepts the array returned
            by ``plt.subplots`` and the name-to-axes mapping returned by :func:`mosaic`, which is
            already in reading order; pass ``labels=list(panels)`` to letter a mosaic by its own
            names instead.
        journal: Theme key or alias. Defaults to the active theme.
        fmt: Format string applied to each letter, overriding the journal default.
        labels: Explicit label strings, overriding the generated letters.
        dx: Horizontal offset in points from the axes' left edge.
        dy: Vertical offset in points from the axes' top edge.
        weight: Font weight.
        size: Font size in points. Defaults to the journal's panel label size.
        **kwargs: Forwarded to ``Axes.annotate``.

    Returns:
        The created text artists, in the order the axes were given.

    Example:
        >>> use("aps")
        >>> fig, axs = plt.subplots(1, 2)
        >>> tuple(t.get_text() for t in panel_labels(axs))
        ('(a)', '(b)')
    """
    spec = _spec_for(journal)
    axes_list = [axes] if isinstance(axes, Axes) else [ax for ax in _flatten(axes)]
    template = fmt if fmt is not None else spec.panel_label_fmt
    letters = ascii_lowercase[: len(axes_list)]
    if spec.panel_label_upper:
        letters = letters.upper()
    texts = list(labels) if labels is not None else [template.format(c) for c in letters]

    artists: list[Text] = []
    for ax, label in zip(axes_list, texts, strict=False):
        artist = ax.annotate(
            label,
            xy=(0.0, 1.0),
            xycoords="axes fraction",
            xytext=(dx, dy),
            textcoords="offset points",
            fontsize=size if size is not None else spec.panel_label_pt,
            fontweight=weight,
            va="bottom",
            ha="left",
            annotation_clip=False,
            **kwargs,
        )
        artist.set_gid(PANEL_LABEL_GID)
        artists.append(artist)
    return artists

label_lines

label_lines(ax: Axes, *, labels: Iterable[str] | None = None, offset: float = 3.0, **kwargs: Any) -> list[Text]

Label each line at its right-hand end instead of in a legend.

A legend costs space and makes the reader look up a colour before they can read the plot. At a single-column width that lookup is the most expensive thing on the page, and putting the name beside the line removes it. The label takes the line's own colour, so the association survives a greyscale print only if the linestyles differ too -- which the themes' property cycle ensures.

Labels are drawn outside the axes and are not clipped, so leave room for them: ax.margins(x=0.2) before calling, or set an explicit upper x-limit.

Parameters:

Name Type Description Default
ax Axes

The axes whose lines should be labelled.

required
labels Iterable[str] | None

Explicit label strings, overriding each line's own label. Must match the number of labelled lines.

None
offset float

Horizontal gap in points between the end of the line and its label.

3.0
**kwargs Any

Forwarded to Axes.annotate.

{}

Returns:

Type Description
list[Text]

The created text artists, in the order the lines were drawn.

Example

use("elsevier") fig, ax = subplots("elsevier") _ = ax.plot([1, 2, 3], [1, 4, 9], label="Series A") _ = ax.plot([1, 2, 3], [2, 5, 10], label="Series B") tuple(t.get_text() for t in label_lines(ax)) ('Series A', 'Series B')

Source code in src/journalfig/_core.py
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
def label_lines(
    ax: Axes,
    *,
    labels: Iterable[str] | None = None,
    offset: float = 3.0,
    **kwargs: Any,
) -> list[Text]:
    """Label each line at its right-hand end instead of in a legend.

    A legend costs space and makes the reader look up a colour before they can read the plot. At a
    single-column width that lookup is the most expensive thing on the page, and putting the name
    beside the line removes it. The label takes the line's own colour, so the association survives a
    greyscale print only if the linestyles differ too -- which the themes' property cycle ensures.

    Labels are drawn outside the axes and are not clipped, so leave room for them: ``ax.margins(x=0.2)``
    before calling, or set an explicit upper x-limit.

    Args:
        ax: The axes whose lines should be labelled.
        labels: Explicit label strings, overriding each line's own label. Must match the number of
            labelled lines.
        offset: Horizontal gap in points between the end of the line and its label.
        **kwargs: Forwarded to ``Axes.annotate``.

    Returns:
        The created text artists, in the order the lines were drawn.

    Example:
        >>> use("elsevier")
        >>> fig, ax = subplots("elsevier")
        >>> _ = ax.plot([1, 2, 3], [1, 4, 9], label="Series A")
        >>> _ = ax.plot([1, 2, 3], [2, 5, 10], label="Series B")
        >>> tuple(t.get_text() for t in label_lines(ax))
        ('Series A', 'Series B')
    """
    lines = [line for line in ax.lines if not str(line.get_label()).startswith("_")]
    texts = list(labels) if labels is not None else [str(line.get_label()) for line in lines]

    artists: list[Text] = []
    for line, text in zip(lines, texts, strict=False):
        xdata: Any = line.get_xdata()
        ydata: Any = line.get_ydata()
        finite = [(x, y) for x, y in zip(xdata, ydata, strict=False) if _is_finite(x) and _is_finite(y)]
        if not finite:
            continue
        x_end, y_end = finite[-1]
        artists.append(
            ax.annotate(
                text,
                xy=cast("tuple[float, float]", (x_end, y_end)),
                xytext=(offset, 0.0),
                textcoords="offset points",
                color=line.get_color(),
                va="center",
                ha="left",
                annotation_clip=False,
                **kwargs,
            )
        )
    return artists

check

check(fig: Figure, *, journal: str | None = None, warn: bool = True) -> list[Violation]

Check a figure against a publisher's stated figure requirements.

Inspects text sizes (including the 0.7x shrink matplotlib applies to maths sub/superscripts), printed glyph height, line weights, marker diameters, the figure's own dimensions, and which typeface the machine will actually draw with. A metrically compatible substitute passes; a silent fall back to something else does not. See :func:fonts for the full picture.

Parameters:

Name Type Description Default
fig Figure

The figure to inspect.

required
journal str | None

Theme key or alias. Defaults to the active theme.

None
warn bool

Emit a UserWarning summarising the violations found.

True

Returns:

Type Description
list[Violation]

Every violation found, in discovery order. Empty means the figure complies with the

list[Violation]

machine-checkable parts of the spec.

Example

use("aps") fig, ax = subplots("aps") _ = ax.set_xlabel("q", fontsize=4) sorted({v.kind for v in check(fig, warn=False)}) ['lettering']

Source code in src/journalfig/_core.py
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
def check(fig: Figure, *, journal: str | None = None, warn: bool = True) -> list[Violation]:
    """Check a figure against a publisher's stated figure requirements.

    Inspects text sizes (including the 0.7x shrink matplotlib applies to maths sub/superscripts),
    printed glyph height, line weights, marker diameters, the figure's own dimensions, and which
    typeface the machine will actually draw with. A metrically compatible substitute passes; a
    silent fall back to something else does not. See :func:`fonts` for the full picture.

    Args:
        fig: The figure to inspect.
        journal: Theme key or alias. Defaults to the active theme.
        warn: Emit a ``UserWarning`` summarising the violations found.

    Returns:
        Every violation found, in discovery order. Empty means the figure complies with the
        machine-checkable parts of the spec.

    Example:
        >>> use("aps")
        >>> fig, ax = subplots("aps")
        >>> _ = ax.set_xlabel("q", fontsize=4)
        >>> sorted({v.kind for v in check(fig, warn=False)})
        ['lettering']
    """
    spec = _spec_for(journal)
    found: list[Violation] = []

    width_in, height_in = _PINNED.get(fig, tuple(fig.get_size_inches()))
    width_mm = width_in * MM_PER_INCH
    if not any(abs(width_mm - allowed) <= 0.5 for allowed in spec.widths_mm.values()):
        allowed_str = ", ".join(f"{name} {value:.0f} mm" for name, value in sorted(spec.widths_mm.items()))
        found.append(Violation("width", f"figure is {width_mm:.1f} mm wide; {spec.name} uses {allowed_str}"))
    if spec.max_height_mm is not None and height_in * MM_PER_INCH > spec.max_height_mm + 0.5:
        found.append(
            Violation(
                "height",
                f"figure is {height_in * MM_PER_INCH:.1f} mm tall; {spec.name} allows {spec.max_height_mm:.0f} mm",
            )
        )

    for role, status in fonts(journal=journal, fig=fig).items():
        if status.status == "fallback":
            # A figure drawn elsewhere often asks for the face it already got, so "asks for X but
            # resolves to X" is the common case rather than the odd one. Say what happened instead.
            if status.requested[0] == status.resolved:
                rendered = f"{role} renders in {status.resolved}"
            else:
                rendered = f"{role} asks for {status.requested[0]} but resolves to {status.resolved}"
            found.append(
                Violation(
                    "font",
                    f"{rendered}; {spec.name} requires {', '.join(spec.font_families)} — install one "
                    f"of those, or a metric substitute ({', '.join(spec.font_substitutes)})",
                )
            )

    for text in fig.findobj(Text):
        content = text.get_text()
        if not content.strip() or not text.get_visible():
            continue
        is_panel_label = text.get_gid() == PANEL_LABEL_GID
        size = float(text.get_fontsize())

        if spec.text_min_pt is not None and size < spec.text_min_pt:
            found.append(
                Violation("text", f"{content!r} is {size:.1f} pt, below the {spec.text_min_pt:.0f} pt minimum")
            )
        if spec.text_max_pt is not None and not is_panel_label and size > spec.text_max_pt:
            found.append(
                Violation("text", f"{content!r} is {size:.1f} pt, above the {spec.text_max_pt:.0f} pt maximum")
            )

        if spec.sub_min_pt is not None and _has_shrinking_math(content):
            effective = size * MATHTEXT_SHRINK
            if effective < spec.sub_min_pt:
                found.append(
                    Violation(
                        "subscript",
                        f"{content!r} renders sub/superscripts at {effective:.2f} pt "
                        f"({size:.1f} pt x {MATHTEXT_SHRINK}), below the {spec.sub_min_pt:.0f} pt floor",
                    )
                )

        if spec.min_lettering_mm is not None:
            height_mm = _glyph_height_mm(text.get_fontproperties())
            if height_mm < spec.min_lettering_mm:
                found.append(
                    Violation(
                        "lettering",
                        f"{content!r} at {size:.1f} pt prints {height_mm:.2f} mm tall, "
                        f"below the {spec.min_lettering_mm:.1f} mm minimum",
                    )
                )

    if spec.min_linewidth_pt is not None:
        edge_width = float(plt.rcParams["axes.linewidth"])
        for ax in fig.get_axes():
            for spine in ax.spines.values():
                edge_width = min(edge_width, spine.get_linewidth())
        if edge_width < spec.min_linewidth_pt:
            found.append(
                Violation(
                    "line",
                    f"axes frame is {edge_width:.2f} pt, below the {spec.min_linewidth_pt:.2f} pt minimum",
                )
            )

    # Only data lines: fig.findobj(Line2D) would also pick up tick marks, whose "markersize"
    # is the tick length and would report as an undersized data point.
    for line in (line for ax in fig.get_axes() for line in ax.lines):
        xdata: Any = line.get_xdata()
        if not line.get_visible() or len(xdata) == 0:
            # An empty line draws nothing, so nothing about it can be too small. Axes.boxplot creates a
            # flier artist per box whether or not any point lies beyond the whiskers, and without this a
            # five-box plot reports five undersized-marker violations for the three that have fliers.
            continue
        if spec.min_linewidth_pt is not None and line.get_linestyle() not in ("None", "none", "", " "):
            width = line.get_linewidth()
            if 0 < width < spec.min_linewidth_pt:
                found.append(
                    Violation("line", f"a line is {width:.2f} pt, below the {spec.min_linewidth_pt:.2f} pt minimum")
                )
        if spec.min_marker_mm is not None and line.get_marker() not in ("None", "none", "", " ", None):
            diameter_mm = line.get_markersize() / PT_PER_MM
            if diameter_mm < spec.min_marker_mm:
                found.append(
                    Violation(
                        "marker",
                        f"marker diameter is {diameter_mm:.2f} mm, below the {spec.min_marker_mm:.1f} mm minimum",
                    )
                )

    # Axes.scatter produces a PathCollection rather than a Line2D, and sizes it in points squared.
    if spec.min_marker_mm is not None:
        for collection in (c for ax in fig.get_axes() for c in ax.collections):
            if not collection.get_visible() or not hasattr(collection, "get_sizes"):
                continue
            sizes = collection.get_sizes()
            if len(sizes) == 0:
                continue
            diameter_mm = sqrt(float(min(sizes))) / PT_PER_MM
            if diameter_mm < spec.min_marker_mm:
                found.append(
                    Violation(
                        "marker",
                        f"scatter marker is {diameter_mm:.2f} mm across (s={min(sizes):.3g} pt²), "
                        f"below the {spec.min_marker_mm:.1f} mm minimum",
                    )
                )

    # The one rule here that no publisher document states. A figure whose vector artists hold hundreds
    # of thousands of paths produces a PDF that is slow to open and large enough for a submission system
    # to reject, and rasterizing that one artist fixes it without touching the vector text or axes.
    for artist in _vector_heavy_artists(fig):
        found.append(
            Violation(
                "vector",
                f"a {type(artist).__name__} holds {_element_count(artist):,} elements and is not "
                f"rasterized; the vector file will be very large — pass rasterized=True to that call "
                f"so only it becomes a bitmap, leaving axes and text as text",
            )
        )

    if warn and found:
        summary = "\n  ".join(str(v) for v in found)
        warnings.warn(
            f"{spec.name} figure requirements not met:\n  {summary}",
            category=JournalFigWarning,
            stacklevel=2,
        )
    return found

fonts

fonts(journal: str | None = None, fig: Figure | None = None) -> dict[str, FontStatus]

Report the typefaces this machine will really draw with.

matplotlib substitutes silently when a requested family is missing. A theme asking for Arial or Times renders in DejaVu on a machine that has neither -- it still looks fine on screen, and the figure breaks the publisher's font requirement. Run this before trusting a figure produced somewhere other than where the theme was tested, an HPC node especially.

Parameters:

Name Type Description Default
journal str | None

Theme key or alias. Defaults to the active theme.

None
fig Figure | None

Read the families from this figure's own text artists instead of from the global rcParams. Pass it for any figure this process did not just draw under the theme -- without it the answer describes the caller's settings, not the figure. Note that an artist which inherited the generic name "sans-serif" is still expanded through the current rcParams, because the artist does not record what that name meant elsewhere; an artist given a concrete face is read exactly, which is the case rcParams cannot see.

None

Returns:

Name Type Description
One dict[str, FontStatus]

class:FontStatus per role. Reading the rcParams gives "text" for body text plus an

dict[str, FontStatus]

entry per custom mathtext face; reading a figure gives "text" for the first family found

dict[str, FontStatus]

and "text[<family>]" for any further ones. Empty when text.usetex is on, since LaTeX

dict[str, FontStatus]

chooses the fonts then, and empty for a figure carrying no visible text.

Example

use("nature") fonts()["text"].requested[0] 'Arial' fig, ax = subplots("nature") _ = ax.set_xlabel("q") fonts(fig=fig)["text"].requested[0] 'Arial'

Source code in src/journalfig/_core.py
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
def fonts(journal: str | None = None, fig: Figure | None = None) -> dict[str, FontStatus]:
    """Report the typefaces this machine will really draw with.

    matplotlib substitutes silently when a requested family is missing. A theme asking for Arial or
    Times renders in DejaVu on a machine that has neither -- it still looks fine on screen, and the
    figure breaks the publisher's font requirement. Run this before trusting a figure produced
    somewhere other than where the theme was tested, an HPC node especially.

    Args:
        journal: Theme key or alias. Defaults to the active theme.
        fig: Read the families from this figure's own text artists instead of from the global
            rcParams. Pass it for any figure this process did not just draw under the theme --
            without it the answer describes the caller's settings, not the figure. Note that an
            artist which inherited the generic name ``"sans-serif"`` is still expanded through the
            current rcParams, because the artist does not record what that name meant elsewhere; an
            artist given a concrete face is read exactly, which is the case rcParams cannot see.

    Returns:
        One :class:`FontStatus` per role. Reading the rcParams gives ``"text"`` for body text plus an
        entry per custom mathtext face; reading a figure gives ``"text"`` for the first family found
        and ``"text[<family>]"`` for any further ones. Empty when ``text.usetex`` is on, since LaTeX
        chooses the fonts then, and empty for a figure carrying no visible text.

    Example:
        >>> use("nature")
        >>> fonts()["text"].requested[0]
        'Arial'
        >>> fig, ax = subplots("nature")
        >>> _ = ax.set_xlabel("q")
        >>> fonts(fig=fig)["text"].requested[0]
        'Arial'
    """
    spec = _spec_for(journal)
    if plt.rcParams["text.usetex"]:
        return {}

    if fig is not None:
        return {
            ("text" if index == 0 else f"text[{families[0]}]"): _resolve_family(families, spec)
            for index, families in enumerate(_figure_families(fig))
        }

    report = {"text": _resolve_family(_expand_families(plt.rcParams["font.family"]), spec)}
    if plt.rcParams["mathtext.fontset"] == "custom":
        for key in ("mathtext.rm", "mathtext.it", "mathtext.bf", "mathtext.sf"):
            # "Arial:italic" names a face within the Arial family; only the family is looked up.
            family = str(plt.rcParams[key]).split(":")[0]
            report[key] = _resolve_family(_expand_families(family), spec)
    return report

save

save(fig: Figure, path: str | Path, *, journal: str | None = None, formats: Iterable[str] | None = None, dpi: int | None = None, validate: bool = True, pil_kwargs: dict[str, Any] | None = None, svg_text: str | None = None) -> list[Path]

Save a figure in the formats the target publisher accepts.

Writes with bbox_inches=None so the declared column width survives into the file; a tight bounding box would crop it by around one percent. TIFFs are LZW-compressed by default, which is lossless: an Elsevier single-column figure lands at a few hundred kB rather than 7.5 MB.

The size actually written is reported through the journalfig logger at INFO level, so it stays out of the way in a pipeline. Raise that one logger to see it -- logging.getLogger("journalfig").setLevel("INFO") -- rather than the root logger, which also switches on fontTools and its dozen subsetting lines per PDF.

Every journal writes PDF, SVG and PNG by default: the file you submit, one to edit in Inkscape or Illustrator, and one for a talk or a preview. Pass formats= for anything else the backend supports -- TIFF for Elsevier, EPS for an APS colour-online-only figure -- and convert from the SVG for whatever else a publisher asks for.

SVG keeps live, editable text (svg.fonttype: none), which means it renders correctly only where the theme's typeface is installed; :func:fonts reports what that will be. The draft warning fires once per call, and only when nothing written is a format the publisher takes as final artwork -- so a PNG beside a PDF is silent, and a PNG on its own is not.

Parameters:

Name Type Description Default
fig Figure

The figure to write.

required
path str | Path

Output path. Any extension is replaced by each requested format.

required
journal str | None

Theme key or alias. Defaults to the active theme.

None
formats Iterable[str] | None

File formats to write, overriding the journal defaults.

None
dpi int | None

Resolution for raster formats. Defaults to the journal's requirement.

None
validate bool

Run :func:check first and warn about any violations.

True
pil_kwargs dict[str, Any] | None

Passed to Pillow for raster formats, replacing the journal's TIFF compression. Pass {} to write an uncompressed TIFF.

None
svg_text str | None

How text is stored in an SVG. "none" (the theme default) keeps it live and editable, which is the reason to want an SVG at all -- but it then renders correctly only where the theme's typeface is installed. "path" converts glyphs to outlines, so the file renders identically anywhere and converts safely on any machine, at the cost of the text no longer being text. Applies to the SVG only; other formats are unaffected.

None

Returns:

Type Description
list[Path]

The paths written, in the order requested.

Example

import tempfile use("elsevier") fig, ax = subplots("elsevier") with tempfile.TemporaryDirectory() as d: ... # validate=False keeps this example independent of whether the machine running it has ... # Arial installed; leave it on in real use, where that is exactly what you want to know. ... tuple(p.suffix for p in save(fig, Path(d) / "fig1", formats=["pdf"], validate=False)) ('.pdf',)

Source code in src/journalfig/_core.py
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
def save(
    fig: Figure,
    path: str | Path,
    *,
    journal: str | None = None,
    formats: Iterable[str] | None = None,
    dpi: int | None = None,
    validate: bool = True,
    pil_kwargs: dict[str, Any] | None = None,
    svg_text: str | None = None,
) -> list[Path]:
    """Save a figure in the formats the target publisher accepts.

    Writes with ``bbox_inches=None`` so the declared column width survives into the file; a tight
    bounding box would crop it by around one percent. TIFFs are LZW-compressed by default, which is
    lossless: an Elsevier single-column figure lands at a few hundred kB rather than 7.5 MB.

    The size actually written is reported through the ``journalfig`` logger at INFO level, so it stays
    out of the way in a pipeline. Raise that one logger to see it --
    ``logging.getLogger("journalfig").setLevel("INFO")`` -- rather than the root logger, which also
    switches on ``fontTools`` and its dozen subsetting lines per PDF.

    Every journal writes PDF, SVG and PNG by default: the file you submit, one to edit in Inkscape or
    Illustrator, and one for a talk or a preview. Pass ``formats=`` for anything else the backend
    supports -- TIFF for Elsevier, EPS for an APS colour-online-only figure -- and convert from the
    SVG for whatever else a publisher asks for.

    SVG keeps live, editable text (``svg.fonttype: none``), which means it renders correctly only
    where the theme's typeface is installed; :func:`fonts` reports what that will be. The draft
    warning fires once per call, and only when *nothing* written is a format the publisher takes as
    final artwork -- so a PNG beside a PDF is silent, and a PNG on its own is not.

    Args:
        fig: The figure to write.
        path: Output path. Any extension is replaced by each requested format.
        journal: Theme key or alias. Defaults to the active theme.
        formats: File formats to write, overriding the journal defaults.
        dpi: Resolution for raster formats. Defaults to the journal's requirement.
        validate: Run :func:`check` first and warn about any violations.
        pil_kwargs: Passed to Pillow for raster formats, replacing the journal's TIFF compression.
            Pass ``{}`` to write an uncompressed TIFF.
        svg_text: How text is stored in an SVG. ``"none"`` (the theme default) keeps it live and
            editable, which is the reason to want an SVG at all -- but it then renders correctly only
            where the theme's typeface is installed. ``"path"`` converts glyphs to outlines, so the
            file renders identically anywhere and converts safely on any machine, at the cost of the
            text no longer being text. Applies to the SVG only; other formats are unaffected.

    Returns:
        The paths written, in the order requested.

    Example:
        >>> import tempfile
        >>> use("elsevier")
        >>> fig, ax = subplots("elsevier")
        >>> with tempfile.TemporaryDirectory() as d:
        ...     # validate=False keeps this example independent of whether the machine running it has
        ...     # Arial installed; leave it on in real use, where that is exactly what you want to know.
        ...     tuple(p.suffix for p in save(fig, Path(d) / "fig1", formats=["pdf"], validate=False))
        ('.pdf',)
    """
    spec = _spec_for(journal)
    pinned = _PINNED.get(fig)
    if pinned is not None:
        fig.set_size_inches(*pinned, forward=False)
    if validate:
        check(fig, journal=journal)

    chosen = tuple(formats) if formats is not None else spec.formats
    stem = Path(path)
    if stem.suffix.lstrip(".").lower() in {"pdf", "eps", "ps", "png", "tif", "tiff", "svg", "jpg", "jpeg"}:
        stem = stem.with_suffix("")

    # The warning belongs to the write as a whole, not to each file: a PNG or SVG companion beside a
    # submittable PDF is a working copy, not a compliance problem, and warning per format would make
    # asking for one permanently noisy. What matters is whether anything written can be submitted.
    if not any(fmt.lower() in spec.submission_formats for fmt in chosen):
        warnings.warn(
            f"none of {', '.join(f.upper() for f in chosen)} can be submitted to {spec.name} as final "
            f"artwork; it takes {', '.join(f.upper() for f in spec.submission_formats)} — treat these "
            f"as drafts",
            category=JournalFigWarning,
            stacklevel=2,
        )

    # spec.raster_dpi is a floor, not a target: a publisher states the resolution below which artwork is
    # rejected, so writing above it is fine and only falling short is a problem. Vector formats carry no
    # resolution, so an explicit dpi is simply irrelevant to them and must not warn.
    resolution = dpi if dpi is not None else spec.raster_dpi
    rasters = tuple(fmt for fmt in chosen if fmt.lower() in _RASTER_FORMATS)
    if rasters and spec.raster_dpi is not None and resolution is not None and resolution < spec.raster_dpi:
        warnings.warn(
            f"{', '.join(f.upper() for f in rasters)} at {resolution} dpi is below the {spec.name} "
            f"minimum of {spec.raster_dpi} dpi",
            category=JournalFigWarning,
            stacklevel=2,
        )

    written: list[Path] = []
    for fmt in chosen:
        target = stem.with_suffix(f".{fmt}")
        extra: dict[str, Any] = {}
        if pil_kwargs is not None:
            extra["pil_kwargs"] = pil_kwargs
        elif fmt.lower() in {"tif", "tiff"} and spec.tiff_compression is not None:
            extra["pil_kwargs"] = {"compression": spec.tiff_compression}
        # Scoped to this write: svg.fonttype is a global rcParam, and a caller asking for outlined text
        # in one file should not silently get it in every figure drawn afterwards.
        overrides = {"svg.fonttype": svg_text} if svg_text is not None and fmt.lower() == "svg" else {}
        with plt.rc_context(cast("Any", overrides)):
            # No stated floor and no explicit request leaves dpi to the theme's own savefig.dpi.
            written_dpi = dpi if dpi is not None else spec.raster_dpi
            if written_dpi is None:
                fig.savefig(target, format=fmt, bbox_inches=None, **extra)
            else:
                fig.savefig(target, format=fmt, dpi=written_dpi, bbox_inches=None, **extra)
        written.append(target)

    width_in, height_in = pinned if pinned is not None else tuple(fig.get_size_inches())
    _LOG.info(
        "%s: %.1f x %.1f mm -> %s",
        spec.name,
        width_in * MM_PER_INCH,
        height_in * MM_PER_INCH,
        ", ".join(p.name for p in written),
    )
    return written

active

active() -> str | None

Return the theme key applied by the most recent :func:use call.

Returns:

Type Description
str | None

The active theme key, or None if no theme has been applied.

Example

use("nature") active() 'nature'

Source code in src/journalfig/_core.py
163
164
165
166
167
168
169
170
171
172
173
174
def active() -> str | None:
    """Return the theme key applied by the most recent :func:`use` call.

    Returns:
        The active theme key, or ``None`` if no theme has been applied.

    Example:
        >>> use("nature")
        >>> active()
        'nature'
    """
    return _ACTIVE

register

register() -> None

Add the bundled themes to matplotlib's in-process style library.

Makes plt.style.use("nature") work without going through :func:use. This only touches the running interpreter; nothing is written to ~/.matplotlib/stylelib.

Example

register() "nature" in plt.style.available True

Source code in src/journalfig/_core.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def register() -> None:
    """Add the bundled themes to matplotlib's in-process style library.

    Makes ``plt.style.use("nature")`` work without going through :func:`use`. This only touches the
    running interpreter; nothing is written to ``~/.matplotlib/stylelib``.

    Example:
        >>> register()
        >>> "nature" in plt.style.available
        True
    """
    base = rc_params_from_file(_BASE_STYLE, use_default_template=False)
    for path in sorted(_STYLE_DIR.glob("*.mplstyle")):
        # A leading underscore marks a fragment, not a theme: _base.mplstyle is applied beneath every
        # overlay rather than offered to matplotlib as a style of its own.
        if path.name.startswith("_"):
            continue
        # RcParams.copy() rather than dict(base): matplotlib's style library holds RcParams, and a plain
        # dict works only because nothing downcasts it.
        merged = base.copy()
        merged.update(rc_params_from_file(path, use_default_template=False))
        mplstyle.library[path.stem] = merged
    mplstyle.available[:] = sorted(mplstyle.library)

style_file

style_file(journal: str) -> Path

Return the path to a theme's .mplstyle file.

Parameters:

Name Type Description Default
journal str

Theme key or journal alias.

required

Returns:

Type Description
Path

Path to the style file shipped with the package.

Example

style_file("prb").name 'aps.mplstyle'

Source code in src/journalfig/_core.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def style_file(journal: str) -> Path:
    """Return the path to a theme's ``.mplstyle`` file.

    Args:
        journal: Theme key or journal alias.

    Returns:
        Path to the style file shipped with the package.

    Example:
        >>> style_file("prb").name
        'aps.mplstyle'
    """
    return _STYLE_DIR / f"{resolve(journal)}.mplstyle"

resolve

resolve(journal: str) -> str

Resolve a journal name or alias to a theme key.

Parameters:

Name Type Description Default
journal str

Theme key or a known journal alias, case-insensitive.

required

Returns:

Type Description
str

One of "nature", "aps", "elsevier".

Raises:

Type Description
KeyError

If the name is not a known theme or alias.

Example

resolve("Acta Materialia") 'elsevier' resolve("PRB") 'aps'

Source code in src/journalfig/_specs.py
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
def resolve(journal: str) -> str:
    """Resolve a journal name or alias to a theme key.

    Args:
        journal: Theme key or a known journal alias, case-insensitive.

    Returns:
        One of ``"nature"``, ``"aps"``, ``"elsevier"``.

    Raises:
        KeyError: If the name is not a known theme or alias.

    Example:
        >>> resolve("Acta Materialia")
        'elsevier'
        >>> resolve("PRB")
        'aps'
    """
    key = ALIASES.get(journal.strip().lower())
    if key is None:
        raise KeyError(f"unknown journal {journal!r}; expected one of {JOURNALS} or an alias in {sorted(ALIASES)}")
    return key

get_spec

get_spec(journal: str) -> JournalSpec

Look up the :class:JournalSpec for a journal name or alias.

Parameters:

Name Type Description Default
journal str

Theme key or a known journal alias.

required

Returns:

Type Description
JournalSpec

The matching specification.

Example

get_spec("nature").widths_mm["single"] 89.0

Source code in src/journalfig/_specs.py
788
789
790
791
792
793
794
795
796
797
798
799
800
801
def get_spec(journal: str) -> JournalSpec:
    """Look up the :class:`JournalSpec` for a journal name or alias.

    Args:
        journal: Theme key or a known journal alias.

    Returns:
        The matching specification.

    Example:
        >>> get_spec("nature").widths_mm["single"]
        89.0
    """
    return SPECS[resolve(journal)]

source

source(journal: str) -> Source

Look up the publisher document a theme's numbers came from.

Parameters:

Name Type Description Default
journal str

Theme key or a known journal alias.

required

Returns:

Type Description
Source

The document, including the date its numbers were last checked.

Example

print(source("PRB")) APS Journals Style Guide for Authors, November 2024 (retrieved 2026-07-28)

Source code in src/journalfig/_specs.py
804
805
806
807
808
809
810
811
812
813
814
815
816
817
def source(journal: str) -> Source:
    """Look up the publisher document a theme's numbers came from.

    Args:
        journal: Theme key or a known journal alias.

    Returns:
        The document, including the date its numbers were last checked.

    Example:
        >>> print(source("PRB"))
        APS Journals Style Guide for Authors, November 2024 (retrieved 2026-07-28)
    """
    return SOURCES[resolve(journal)]

mm_to_inch

mm_to_inch(value_mm: float) -> float

Convert millimetres to inches.

Parameters:

Name Type Description Default
value_mm float

Length in millimetres.

required

Returns:

Type Description
float

The same length in inches.

Example

round(mm_to_inch(89.0), 4) 3.5039

Source code in src/journalfig/_specs.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def mm_to_inch(value_mm: float) -> float:
    """Convert millimetres to inches.

    Args:
        value_mm: Length in millimetres.

    Returns:
        The same length in inches.

    Example:
        >>> round(mm_to_inch(89.0), 4)
        3.5039
    """
    return value_mm / MM_PER_INCH