paper-figure
One style spec, two backends, one auditor. spec/figure-spec.json is the only
place style numbers live; python/ and r/ are generated from it and must
never be hand-edited. This is what stops a matplotlib panel and a ggplot2 panel
in the same manuscript from disagreeing about font size, column width, or palette.
1. Write the figure contract before any code
A figure is a visual argument, not a rendered table. Establish these five points first, in the response, before writing plotting code:
- Core conclusion — the one sentence this figure must defend.
- Evidence chain — map each panel to one unique claim question. Drop or merge any panel that only redraws another panel's evidence.
- Archetype —
quantitative grid,image plate + quant,schematic-led composite, orasymmetric mixed-modality. - Backend — chosen per figure (§2), not per session.
- Export contract — column width, final dimensions, formats, and the 5 pt floor for every rendered glyph.
The chart serves the scientific logic. Aesthetic polish and layout cleverness are subordinate to making the conclusion clear, defensible, and reviewable.
Data-integrity gate. Use all provided observations unless an exclusion has a stated scientific justification. Never reduce data to make rendering easier — for large point clouds use rasterized marks, hexbin, or density, never a silent subsample. If anything is excluded, record before/after counts and the rule in the figure's QA notes.
Full method: references/figure-contract.md.
2. Choose the backend per figure
Both backends produce identical typography and palettes, so choose on what the panel actually needs. Do not ask the user to pick a language once and for all.
| Panel need | Backend | Why |
|---|---|---|
| Clustered/annotated heatmap, split rows, multiple annotation tracks | R | ComplexHeatmap has no real Python equivalent |
| Embedding scatter (UMAP/t-SNE/spatial) with >20k points | Python | rasterized=True on the artist keeps text vector and the file small |
| Scanpy / AnnData / scvi objects already in memory | Python | Stay in the object; do not export to CSV to re-plot |
| Seurat / SingleCellExperiment / DESeq2 objects | R | Same reason, other direction |
| Survival curves, forest plots, complex facets | R | ggplot2 + patchwork composition |
| Image plates (histology, spatial tissue) with quantification | Python | Precise axes-level image placement and rasterization control |
Mixing backends across panels of one figure is allowed here — the shared spec is what makes it safe — but assemble the final composite in one place, and keep each panel's source script next to its output. If a panel is redrawn in the other language, regenerate rather than hand-tweak.
Details and edge cases: references/backend-choice.md.
3. Build
Python
import sys; sys.path.insert(0, "<skill>/python")
from paper_style import (use_style, figsize_mm, save_figure, categorical,
panel_letter, RASTERIZE_OVER_N_POINTS, SEQUENTIAL, DIVERGING)
import matplotlib.pyplot as plt
use_style() # or: with style_context():
fig, axes = plt.subplots(1, 2, figsize=figsize_mm("double", aspect=0.38))
axes[0].scatter(x, y, s=0.5, c=categorical(4)[0], linewidths=0,
rasterized=len(x) > RASTERIZE_OVER_N_POINTS)
panel_letter(axes[0], "a")
save_figure(fig, "figures/fig2_umap") # writes .pdf, .svg, .tiff
R
library(ggplot2); library(patchwork)
source("<skill>/r/theme_paper.R")
p <- ggplot(df, aes(x, y, colour = group)) +
geom_point(size = 0.15, stroke = 0) +
scale_colour_paper() +
labs(x = "UMAP 1", y = "UMAP 2", tag = "a") +
theme_paper()
save_figure(p, "figures/fig2_umap", width = "double", aspect = 0.38)
figsize_mm()/save_figure() exist in both with the same arguments and the same
column keys (single 89mm, one_half 120mm, double 183mm).
Color rules
Multiple colors must be visibly distinct. Never pick categorical colors ad
hoc, and never let a library assign them by interpolating a continuous map (the
scanpy/seaborn default of sampling tab20 or a viridis ramp for clusters produces
adjacent, confusable hues). Always take them from categorical(n) /
paper_categorical(n), which return the smallest adequate palette in a fixed
order. Every pair in those palettes is verified at ΔE ≥ 15 (CIE76) by
build_styles.py --audit; the closest pair in any of them is ΔE 22.8.
These helpers raise past 12 groups — deliberately. More than ~12 categorical hues are not reliably distinguishable at any size, so label groups directly on the plot, or merge categories. Adding a 13th hue makes the figure less readable, not more.
Color is never the only encoding: add marker shape, line style, or a direct label.
Continuous ranges use turbo. SEQUENTIAL is turbo, in both backends
(image.cmap in Python, scale_fill_paper_c() / scale_colour_paper_c() in R).
It gives the widest dynamic range and the strongest feature discrimination, which
is what you want for expression, density, and spatial intensity.
Two cases where you must switch away from it:
- Grayscale print or colorblind readers — turbo is not monotonic in lightness
and not colorblind-safe. Use
SEQUENTIAL_ALT(viridis):scale_fill_paper_c(option = SEQUENTIAL_ALT), orcmap=SEQUENTIAL_ALT. - Signed data (z-scores, log fold change, differences) — a sequential map of
any kind hides the sign. Use the diverging scale centered at 0:
scale_fill_paper_div()in R,cmap=DIVERGING, norm=TwoSlopeNorm(vcenter=0)in Python.
Turbo's hue transitions can read as banding where the data are smooth; if a reviewer asks whether a boundary in your heatmap is real, that is the usual cause, and viridis is the answer.
4. Audit the exported file — always
python3 <skill>/scripts/audit_figure.py figures/fig2_umap.pdf figures/fig2_umap.tiff
Runs on the output, so it covers both backends identically: page size in mm against the allowed column widths, effective rendered font size against the 5 pt floor (text-matrix aware, so cairo_pdf output is measured correctly), font embedding, and text-still-being-text rather than outlined.
A pass is not a compliance claim. Verify live journal guidance for your target journal and article type before submission.
5. Changing the style
Edit spec/figure-spec.json, then:
python3 <skill>/scripts/build_styles.py # regenerate both backends
python3 <skill>/scripts/build_styles.py --check # CI: fail if generated files are stale
python3 <skill>/scripts/build_styles.py --audit # contrast + grayscale report
Never edit python/paper.mplstyle, python/paper_style.py, or r/theme_paper.R
directly — --check will catch it, and the next build overwrites it.
Domain recipes
references/domain-recipes.md covers the panels you actually build: UMAP/embedding
scatters at scale, expression heatmaps with annotation tracks, spatial tissue plots
with correct aspect and scale bars, histology image plates, and benchmark
comparison plots with uncertainty.
Getting real Arial in R
The spec requires Arial, and audit_figure.py enforces it against the font names
actually written into the file. Current state per output:
| Backend | SVG | TIFF | |
|---|---|---|---|
| Python | Arial, embedded + subsetted | Arial | Arial |
| R with cairo | Arial, embedded | Arial | Arial |
| R without cairo | Helvetica, not embedded | Arial | Arial |
svglite and ragg resolve fonts through systemfonts, so they use real Arial
regardless. Only grDevices::pdf() is restricted to the base-14 PostScript
families, which do not include Arial.
The fix is to install XQuartz, which restores cairo_pdf:
brew install --cask xquartz # then restart R
Verify with Rscript -e 'source("<skill>/r/theme_paper.R"); cat(HAS_CAIRO, PDF_FAMILY)'
— you want TRUE Arial, and no warning on export.
Until then R PDFs use Helvetica. It is metrically identical to Arial (same widths,
so layout is unaffected) but a different typeface, and it is referenced rather than
embedded. Some journals accept Helvetica and some name Arial specifically — check
your target. audit_figure.py will keep failing those PDFs, which is correct.
Do not try to fix this by remapping fonts with ghostscript: it converts the
text to outlines, which fails the editable-text requirement. (Tested; text_runs
drops to 0.)
Known environment traps
.mplstylefiles treat#as a comment — hex colors must be written bare (000000, not#000000). The generator handles this; hand-edits will not.capabilities("cairo")lies. It reports compile-time support and returns TRUE on macOS R builds whosecairo.socannot load (missing XQuartz). The R theme probes the real device instead. Without cairo, PDF export falls back to base-14 PostScript fonts (Helvetica, metrically compatible with Arial) and fonts are referenced rather than embedded —audit_figure.pywill flag that, and the flag is correct. Install XQuartz to fix it properly.- ggplot2
linewidthis in mm, matplotlib's is in pt. The generator converts; do not copy numbers between the two by hand.