paper-ink-charts
Warm paper, one ink, one accent. Scientific figures that read as typeset pages
rather than as machine output.
When to use
Use this for figures that carry a claim: results sections, thesis chapters,
lab reports, conference posters. Anywhere a reader will compare values and
decide whether to believe them.
Do not use it for dashboards, exploratory analysis, or anything where the
reader needs continuous axes to read intermediate values off. The style
deliberately removes the y-axis; if your figure's job is "let the reader
interpolate," it is the wrong tool.
Prerequisites
- Python with
matplotlib and numpy. Pillow is not required.
- A CJK serif on the machine. Verified:
NotoSerifSC-VF.ttf, 30,928 glyphs,
zero missing across a 30-term scientific sample (2026-09-12). Fallbacks are
listed in design-system/typography.json but have not been coverage-tested.
Workflow
- Write the data and the claim first. The accented element is whichever mark
supports the sentence you are going to write in the caption. If you cannot
name that mark, you do not yet have a figure — you have a data dump.
- Build with
scripts/ink_chart.py. Never pass a raw matplotlib color; call
ink_chart role constants so the palette stays single-sourced.
- Register every artist that carries data via
f.register(...). The halftone
layer uses this registry to stay out of the data's way. Scatters and lines
are excluded per element — one box per marker, one per short piece of the
line — so registering a trend line clears a corridor along it rather than
the whole rectangle it spans.
- Register text that sits near data separately, with
pad=5.0. Text over
halftone dots reads as an accident even when it is technically unoccluded.
- Render, then look at the output. Do not trust that it worked. If the
halftone looks like it is missing, the counts returned by
save() will say
whether it was drawn or excluded.
Verification
python scripts/validate.py # token + product gates
python examples/make_examples.py # regenerate the gallery
The validator enforces both gates: the token files are internally consistent,
and every rendered example obeys the palette and the halftone constraint. The
palette test is the warm invariant — every declared color has red ≥ green ≥
blue, and any blend of two keeps that ordering, so a viridis or jet figure
fails on the first sample.
Gotchas
Noto Serif SC is not in matplotlib's font list by default. Use
FontProperties(fname=...), not family=. A family= lookup silently falls
back to DejaVu and you get boxes for every CJK glyph. This is the single most
common failure here.
- Measure, do not guess, when placing anything in the margin. A fixed
fig.text(0.085, 0.038, ...) collides with two-line tick labels. The footer
is placed against the measured lowest drawn element for this reason.
- Register before saving, not after. The halftone runs inside
save().
Artists registered afterwards are ignored, and you get dots on your bars
with no error.
- Halftone over a noisy heatmap is invisible. On a speckle field the dots
vanish into the noise and you have spent complexity for nothing. Omit the
halftone there;
charts.json says so explicitly.
- matplotlib's
fig.savefig path must be a real OS path. Under Git Bash on
Windows, /tmp/x.png resolves to a directory that Windows Python cannot
write to. Use a drive-letter path.
- Never pass a named matplotlib colormap.
imshow(..., cmap="Greys") ends
at pure white and pure black, both forbidden, and the product gate rejects
the figure. Call cmap("ink") or cmap("diverging") instead — those are
built from the declared ramps and are the only way to get one in.
- The accent is scarce on purpose. Two accented series is the same as zero
accented series: the reader is told twice to look nowhere. In a multi-panel
figure the numeral is the accent's only job — colour it on the panel that
carries the finding, graphite on every other panel.
- Do not write a caption your data does not show. Three noise fields with
no target in any of them cannot support "panel 3 resolves the structure,"
and contouring a structureless matrix outlines noise. Generate data with the
structure the sentence claims, or change the sentence. A figure whose
caption overstates it is worse than no figure.
Files
design-system/*.json — normative tokens. These are the source; prose here
only explains them. Change a value there, not in a script.
design-system/charts.json — the per-chart-type mapping from data to ink.
This is the layer that decides what a bar, a line, or a box looks like.
scripts/ink_chart.py — the renderer. Small on purpose; the rules live in
the JSON.
examples/ — the gallery, regenerated by make_examples.py.
1---2name: paper-ink-charts3description: Render scientific figures — bar, line, scatter, box, heatmap, multi-panel — as warm-paper single-ink charts. Activate when producing figures for a paper, thesis, lab report, or poster; when a chart needs to read as typeset rather than as default matplotlib; when the user asks for 墨迹/暖纸/单墨/论文插图风格 or complains that a figure looks like default output; when replacing viridis/tab10 defaults, adding error bars, layering significance markers, or laying out shared-scale multi-panel comparisons.4---56# paper-ink-charts78Warm paper, one ink, one accent. Scientific figures that read as typeset pages9rather than as machine output.1011## When to use1213Use this for figures that carry a claim: results sections, thesis chapters,14lab reports, conference posters. Anywhere a reader will compare values and15decide whether to believe them.1617Do not use it for dashboards, exploratory analysis, or anything where the18reader needs continuous axes to read intermediate values off. The style19deliberately removes the y-axis; if your figure's job is "let the reader20interpolate," it is the wrong tool.2122## Prerequisites2324- Python with `matplotlib` and `numpy`. Pillow is not required.25- A CJK serif on the machine. Verified: `NotoSerifSC-VF.ttf`, 30,928 glyphs,26 zero missing across a 30-term scientific sample (2026-09-12). Fallbacks are27 listed in `design-system/typography.json` but have not been coverage-tested.2829## Workflow30311. Write the data and the claim first. The accented element is whichever mark32 supports the sentence you are going to write in the caption. If you cannot33 name that mark, you do not yet have a figure — you have a data dump.342. Build with `scripts/ink_chart.py`. Never pass a raw matplotlib color; call35 `ink_chart` role constants so the palette stays single-sourced.363. Register every artist that carries data via `f.register(...)`. The halftone37 layer uses this registry to stay out of the data's way. Scatters and lines38 are excluded per element — one box per marker, one per short piece of the39 line — so registering a trend line clears a corridor along it rather than40 the whole rectangle it spans.414. Register text that sits near data separately, with `pad=5.0`. Text over42 halftone dots reads as an accident even when it is technically unoccluded.435. Render, then look at the output. Do not trust that it worked. If the44 halftone looks like it is missing, the counts returned by `save()` will say45 whether it was drawn or excluded.4647## Verification4849```bash50python scripts/validate.py # token + product gates51python examples/make_examples.py # regenerate the gallery52```5354The validator enforces both gates: the token files are internally consistent,55and every rendered example obeys the palette and the halftone constraint. The56palette test is the warm invariant — every declared color has red ≥ green ≥57blue, and any blend of two keeps that ordering, so a viridis or jet figure58fails on the first sample.5960## Gotchas6162- **`Noto Serif SC` is not in matplotlib's font list by default.** Use63 `FontProperties(fname=...)`, not `family=`. A `family=` lookup silently falls64 back to DejaVu and you get boxes for every CJK glyph. This is the single most65 common failure here.66- **Measure, do not guess, when placing anything in the margin.** A fixed67 `fig.text(0.085, 0.038, ...)` collides with two-line tick labels. The footer68 is placed against the measured lowest drawn element for this reason.69- **Register before saving, not after.** The halftone runs inside `save()`.70 Artists registered afterwards are ignored, and you get dots on your bars71 with no error.72- **Halftone over a noisy heatmap is invisible.** On a speckle field the dots73 vanish into the noise and you have spent complexity for nothing. Omit the74 halftone there; `charts.json` says so explicitly.75- **matplotlib's `fig.savefig` path must be a real OS path.** Under Git Bash on76 Windows, `/tmp/x.png` resolves to a directory that Windows Python cannot77 write to. Use a drive-letter path.78- **Never pass a named matplotlib colormap.** `imshow(..., cmap="Greys")` ends79 at pure white and pure black, both forbidden, and the product gate rejects80 the figure. Call `cmap("ink")` or `cmap("diverging")` instead — those are81 built from the declared ramps and are the only way to get one in.82- **The accent is scarce on purpose.** Two accented series is the same as zero83 accented series: the reader is told twice to look nowhere. In a multi-panel84 figure the numeral is the accent's only job — colour it on the panel that85 carries the finding, graphite on every other panel.86- **Do not write a caption your data does not show.** Three noise fields with87 no target in any of them cannot support "panel 3 resolves the structure,"88 and contouring a structureless matrix outlines noise. Generate data with the89 structure the sentence claims, or change the sentence. A figure whose90 caption overstates it is worse than no figure.9192## Files9394- `design-system/*.json` — normative tokens. These are the source; prose here95 only explains them. Change a value there, not in a script.96- `design-system/charts.json` — the per-chart-type mapping from data to ink.97 This is the layer that decides what a bar, a line, or a box looks like.98- `scripts/ink_chart.py` — the renderer. Small on purpose; the rules live in99 the JSON.100- `examples/` — the gallery, regenerated by `make_examples.py`.