# Paper Figure

> Build submission-grade manuscript figures in Python or R from one shared style spec, for bioinformatics data (single-cell, spatial omics, histology, model benchmarks). Use when creating, restyling, or QA-ing a figure or panel for a paper, thesis, or preprint - including UMAP/embedding plots, expression heatmaps, image plates, forest/effect plots, and multi-panel composites. Triggers on "figure", "panel", "plot for the paper", "publication quality", "Nature figure", "journal figure", "figure legend", "export figure", "colorblind safe", "300/600 dpi".

- Skill: `liranmao/paper-figure` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add liranmao/paper-figure`
- Raw SKILL.md: https://api.skillmd.com/api/skills/liranmao/paper-figure/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: liranmao (https://skillmd.com/u/liranmao)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/liranmao/paper-figure

---


# 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:

1. **Core conclusion** — the one sentence this figure must defend.
2. **Evidence chain** — map each panel to one unique claim question. Drop or merge
   any panel that only redraws another panel's evidence.
3. **Archetype** — `quantitative grid`, `image plate + quant`, `schematic-led
   composite`, or `asymmetric mixed-modality`.
4. **Backend** — chosen per figure (§2), not per session.
5. **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**

```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**

```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)`, or `cmap=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

```bash
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:

```bash
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 | PDF | 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`:

```bash
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

- **`.mplstyle` files 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 whose `cairo.so` cannot 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.py` will flag that, and the
  flag is correct. Install XQuartz to fix it properly.
- **ggplot2 `linewidth` is in mm, matplotlib's is in pt.** The generator converts;
  do not copy numbers between the two by hand.

