# Paper Ink Charts

> 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.

- Skill: `gkw77/paper-ink-charts` (Agent Skill)
- Install (CLI): `npx skillmds@latest add gkw77/paper-ink-charts`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gkw77/paper-ink-charts/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: gkw77 (https://skillmd.com/u/gkw77)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/gkw77/paper-ink-charts

---


# 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

1. 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.
2. Build with `scripts/ink_chart.py`. Never pass a raw matplotlib color; call
   `ink_chart` role constants so the palette stays single-sourced.
3. 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.
4. 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.
5. 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

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

