Guide¶
This page is mechanics: the traps in matplotlib and in this package, and the incantation for each. For the other half — what makes a figure worth submitting, and how much of that the tool can carry — see figure design.
Panels of different sizes¶
jf.mosaic sketches the layout as text: one character per grid cell, repeated where a
panel should span, . where it should stay empty. width_ratios and height_ratios then set how large the
columns and rows are relative to each other, so panels need not be multiples of one cell.
fig, panels = jf.mosaic(
"nature", "AAB\n" # a big map on the left, spanning two rows and two columns,
"AAC\n" # two small panels stacked beside it,
"DDD", # and a wide strip underneath
width="double", ratio=0.72,
width_ratios=[1, 1, 1.15], height_ratios=[1, 1, 0.85],
)
panels["A"].imshow(density)
panels["D"].plot(r, gr)
jf.panel_labels(panels) # a, b, c, d in reading order — pass labels=list(panels) for A, B, C, D
panels maps each name to its axes, in the order the names first appear reading left to right, top to
bottom, which is the order jf.panel_labels letters them.
Colour bars in a mosaic¶
Put the panel that carries a colour bar in the outermost column, not an interior one. In the sketch
above, A is interior — column 1 of 3 — and the wide strip D spans the whole grid. Constrained layout
then reserves the bar's width in column 1's colour-bar margin, and match_submerged_margins, which equalises
the gutters a spanning panel crosses so its columns line up, adds that reserved width into the ordinary
margin as well. The bar is counted twice and ends up floating roughly midway between its own panel and the
next one — a gap of about 0.10 of the figure width instead of 0.02. Mirroring the sketch to "ABB\nCBB\nDDD"
puts the map in the last column, whose margin no spanning panel submerges, and the bar sits against it:
fig, panels = jf.mosaic(
"nature", "ABB\n" # small panel, then a big map spanning two rows and two columns,
"CBB\n" # the second small panel beneath the first,
"DDD", # and a wide strip underneath
width="double", ratio=0.72,
width_ratios=[1.15, 1, 1], height_ratios=[1, 1, 0.85],
)
mesh = panels["B"].imshow(density)
fig.colorbar(mesh, ax=panels["B"], pad=0.02)
This is a matplotlib layout behaviour, not a journalfig one — it applies to any
constrained_layout figure, and jf.check has nothing to say about it because no publisher states a rule
about colour-bar gaps. demo.py uses this arrangement for the same reason.
For layouts easier to express as slices than as a sketch,
jf.gridspec hands back the figure and an empty grid:
fig, gs = jf.gridspec("aps", 3, 3, width="double", ratio=0.8, height_ratios=[1, 1, 0.6])
main = fig.add_subplot(gs[:2, :2])
side = fig.add_subplot(gs[:2, 2])
strip = fig.add_subplot(gs[2, :])
Both size and pin the figure exactly like jf.subplots, so jf.save and jf.check behave the same. For a
plain grid with unequal columns there is no need for either — jf.subplots forwards width_ratios /
height_ratios straight to matplotlib:
fig, axs = jf.subplots("elsevier", 1, 2, width="double", width_ratios=[3, 1])
Two things bite in multi-panel figures
Height still defaults to the golden ratio of the width, which is far too short once there is more
than one row — pass ratio= or height_mm= deliberately. And a colour bar in an interior column
lands in the wrong place, for the reason in Colour bars in a mosaic
above; fraction does not fix it, because aspect sets the bar's width regardless.
Using it in a notebook¶
Put this once at the top, next to %matplotlib inline:
%config InlineBackend.print_figure_kwargs = {"bbox_inches": None}
Jupyter crops every inline preview with bbox_inches="tight". That defeats the exact column widths this
package exists to guarantee, and because the themes sit the axes close to the canvas edge, the crop slices
through the top and right spines — they preview at 40% and 20% of their true weight, so the frame looks
lopsided even though the figure is correct. Saved files were never affected: jf.save() forces
bbox_inches=None.
Six things worth knowing¶
Nature caps text at 7 pt; APS requires ≥ 2 mm lettering, which forces 9 pt. These are irreconcilable,
which is exactly why there is a theme per publisher rather than one. The APS size is not a guess — Times New Roman
digits measure 1.94 mm at 8 pt (fails) and 2.18 mm at 9 pt (passes), measured with TextPath.
A missing font is substituted silently, and that is the easiest way to submit a non-compliant figure.
Ask for Arial on a machine that has no Arial and matplotlib draws DejaVu Sans instead — the figure looks
fine, and nothing in matplotlib treats it as an error. This bites when you develop on a laptop that has the
font and render on a cluster that does not. jf.check() reports it as a font
violation, and jf.fonts() shows what each face resolved to:
>>> jf.use("nature"); jf.fonts()["text"]
FontStatus(requested=('Arial', 'Helvetica', ...), resolved='Arial', path='...', status='exact')
status is exact for a face the publisher names, substitute for a metrically compatible stand-in
(Liberation Sans, Nimbus Sans, Arimo for Arial; STIXGeneral, Nimbus Roman, Liberation Serif for Times), and
fallback for anything else. Substitutes pass jf.check() — they were cut to the same widths, so the
layout is identical. On a bare Linux box, apt install fonts-liberation is usually the whole fix; the APS
theme already lands on matplotlib's bundled STIXGeneral without any install.
matplotlib shrinks maths sub/superscripts to 0.7×. A 7 pt Elsevier label renders $x_a$ at 4.9 pt
against a hard 6 pt floor. Use jf.use("elsevier", base_size=9) for maths-heavy figures. Nature's case is
unsatisfiable — 7 pt is their maximum, yet any subscript then falls below their 5 pt minimum.
jf.check() reports the real rendered size so the decision is yours rather than silent.
savefig.bbox="tight" breaks column widths. It crops a requested 89 mm figure to 87.9 mm
(252.3 → 249.2 pt). The themes use constrained_layout with savefig.bbox="standard", and jf.save()
forces bbox_inches=None, so what you ask for is what lands in the file.
Interactive backends round a new figure to whole device pixels. On the default macOS backend,
plt.subplots(figsize=jf.figsize("nature", "double")) yields 182.88 mm rather than 183.0 mm, because the
canvas snaps 7.2047 in × 150 dpi = 1080.7 px down to 1080 px. jf.subplots() / jf.figure() re-apply the
size with forward=False to skip that round-trip, and jf.save() re-asserts it before writing. This is
invisible under Agg, so it will not show up in a headless test — it only bites in real use.
In LaTeX, include theme figures at their natural size. \includegraphics[width=\linewidth]{...}
rescales them and destroys the font sizing: elsarticle in preprint mode has a 137 mm \textwidth, so a
190 mm double-column figure is scaled by 0.72 and its compliant 7 pt labels land at ~5 pt. Size the figure
with jf.figsize/jf.subplots and then use a bare \includegraphics{fig.pdf}.
Output formats¶
Every theme writes PDF, SVG and PNG — the file you submit, one to edit, one to show:
jf.save(fig, "fig1") # fig1.pdf, fig1.svg, fig1.png
jf.save(fig, "fig1", formats=["tiff"]) # Elsevier TIFF, LZW-compressed
jf.save(fig, "fig1", formats=["pdf", "eps"]) # APS colour-online-only figure
The draft warning fires once per call, and only when nothing written can be submitted — a PNG beside a PDF is silent, a PNG alone is not:
>>> jf.save(fig, "fig1", formats=["png", "svg"]) # Nature
JournalFigWarning: none of PNG, SVG can be submitted to Nature Portfolio as final artwork;
it takes PDF - treat these as drafts
No theme writes EPS by default: the PostScript backend cannot express transparency, so any artist with
alpha < 1 is flattened to opaque and the EPS stops matching the PDF of the same figure. APS still accepts
it, so formats=["pdf", "eps"] is there when a colour-online-only figure needs it.
SVG is written with live, editable text (svg.fonttype: none) rather than glyph outlines, which is what
makes it worth having — but it therefore renders correctly only on a machine with the theme's typeface
installed. jf.fonts() reports what that will be. Convert to whatever the publisher wants from there.
Keeping vector files sane¶
imshow embeds a bitmap, so it is never the problem. pcolormesh and dense scatters are: one vector
path per cell or point, which on a 400x400 mesh means a PDF holding 160,000 paths — slow to open,
large enough for a submission system to refuse, and prone to hairline seams where antialiasing leaves
gaps between neighbouring quads.
rasterized=True converts that one artist to a bitmap at savefig.dpi while the axes, ticks and
labels stay live vector text. Measured on that mesh: 3.7 MB → 108 kB.
ax.pcolormesh(x, y, z, rasterized=True)
ax.scatter(a, b, rasterized=True)
jf.check() reports an un-rasterized artist past
jf.VECTOR_ELEMENT_LIMIT (50,000 elements):
[vector] a QuadMesh holds 160,000 elements and is not rasterized; the vector file
will be very large — pass rasterized=True to that call so only it becomes a
bitmap, leaving axes and text as text
The one rule that is not a publisher's
Every other check comes from a publisher document. This one is the package's own judgement about what makes a file impractical, so its threshold sits well past what an ordinary plot produces.
Labelling lines without a legend¶
A legend costs space and makes the reader look up a colour before they can read the plot. At 89 mm
that lookup is the most expensive thing on the page. jf.label_lines() puts
each name beside its own line:
ax.margins(x=0.22) # the labels sit outside the axes, so leave room
jf.label_lines(ax)
Each label takes its line's colour, so in greyscale the association survives only because the themes' property cycle varies linestyle too.
Handing an SVG to someone else¶
SVG is written with live, editable text, which is the reason to want one — but it then renders correctly only where the theme's typeface is installed. For a file leaving your machine:
jf.save(fig, "fig1", formats=["svg"], svg_text="path")
That outlines the glyphs, so the file renders identically anywhere and converts safely, at the cost of the text no longer being text. The setting is scoped to that write and does not leak into later figures.
Logging¶
jf.save() reports the size it wrote through the journalfig logger at INFO level, so
it stays quiet inside a pipeline. To see it:
import logging
logging.basicConfig(format="%(message)s")
logging.getLogger("journalfig").setLevel(logging.INFO)
# Elsevier: 90.0 x 55.6 mm -> fig_structure_factor.pdf, fig_structure_factor.svg, fig_structure_factor.png
Raise that one logger rather than calling logging.basicConfig(level="INFO"), which also switches on
fontTools — it logs a dozen subsetting lines every time a PDF is written.
Silencing compliance warnings¶
jf.check() warns by default, and jf.save() calls it. Every warning the package
raises uses JournalFigWarning, so they can be filtered without silencing
anything else:
import warnings
warnings.filterwarnings("ignore", category=jf.JournalFigWarning)
Passing validate=False to jf.save(), or warn=False to jf.check(), turns the check off entirely
rather than hiding its output.
Checking figures from the command line¶
Everything above assumes you are writing the figure. You are not always: a co-author's script, a figure
from an older paper, or one drawn by a different library still has to clear the same requirements. The
CLI runs jf.check() against whatever a Python file or callable produces, so no
part of this depends on having adopted the themes.
$ journalfig check figures.py --journal aps
figure 1 (162.6 x 121.9 mm)
[width] figure is 162.6 mm wide; American Physical Society (PRB/PRL) uses double 178 mm, onehalf 135 mm, single 86 mm
[line] a line is 0.30 pt, below the 0.50 pt minimum
[marker] marker diameter is 0.35 mm, below the 1.0 mm minimum
figure 2 (85.7 x 53.0 mm) OK
2 figure(s), 3 violations
The target is either a path to a script — every figure it leaves open is checked, which is what an
ordinary plotting script produces without being written to return anything — or module:function, which
imports and calls it. --journal defaults to the theme jf.use() last applied, so a script that sets its
own theme needs no flag. journalfig journals lists the keys.
Exit codes make it usable as a build step: 0 when every figure complies, 1 when any violation was
found, and 2 when the target could not be loaded at all — a missing file or a script that raised is not
the same answer as "your figures are fine".
- run: journalfig check paper/figures.py --journal nature
python -m journalfig check ... is equivalent, and works from a checkout before the console script has
been installed.
This does not read PDFs
A matplotlib Figure cannot be reconstructed from a saved PDF, so checking one is a different and
much narrower question — page geometry and embedded fonts, not line weights or marker diameters. The
CLI runs the real checks by re-running the code that draws the figures, and does not pretend a file
on disk can be inspected the same way.