Skip to content

Match Matplotlib text weight defaults [mpl compatibility]#270

Draft
sselvakumaran wants to merge 2 commits into
agent/matplotlib-text-fidelityfrom
agent/mpl-text-weight-defaults
Draft

Match Matplotlib text weight defaults [mpl compatibility]#270
sselvakumaran wants to merge 2 commits into
agent/matplotlib-text-fidelityfrom
agent/mpl-text-weight-defaults

Conversation

@sselvakumaran

Copy link
Copy Markdown
Contributor

Summary

xy renders chart text heavier than Matplotlib everywhere. This sets the default to normal (400) in all three renderers and adds the two Matplotlib rcParams that were missing entirely:

  • chart title 600 → 400, axis labels 500 → 400, annotation labels 500 → 400, legend title 600 → 400, colorbar title 500 → 400
  • fixed in the browser client (js/src/20_theme.ts, js/src/50_chartview.ts), the SVG exporter (python/xy/_svg.py) and the native raster exporter (python/xy/_raster.py), so the three agree
  • adds axes.titleweight and axes.labelweight to the shim, wired to all three renderers, so explicit Matplotlib weights work

Stack

This is two levels deep: #243#255 → this PR, branched from and targeting agent/matplotlib-text-fidelity.

Branching from main would have collided with #255 in the same lines. #255 makes the title/label weights overridable but leaves the defaults at 600/500, and it touches the exact expressions this PR changes (title_style.get("font-weight", 600), axis_style.get("label_font_weight", 500)). Stacking makes the two changes compose — #255 supplies the override plumbing, this PR corrects the value it falls back to — instead of conflicting.

The stacking is also load-bearing for correctness, not just for merge hygiene. See Root cause.

Root cause

Two independent defects that this stack turns into one regression.

1. The defaults were never Matplotlib's. Measured against real matplotlib 3.11.1, artist-level get_fontweight() on PDSH cell 22 (the Matplotlib reference for cell 23):

axes title        : 'normal'
x axis label      : 'normal'
y axis label      : 'normal'
tick label        : 'normal'
legend entry text : 'normal'
legend title      : 'normal'
colorbar label    : 'normal'
annotations       : 'normal'  (all 6)
font-weight tokens in mpl SVG output: none — mpl emits no font-weight when normal

axes.titleweight, axes.labelweight, figure.titleweight and font.weight are all 'normal' in matplotlib.rcParams. xy had no axes.titleweight / axes.labelweight keys at all, so a user could not even opt into Matplotlib's own knobs — setting them warned and was ignored.

2. The stack was about to make it worse. On main, python/xy/_raster.py contains no weight handling whatsoever — grep -n 'bold\|font_weight' main:python/xy/_raster.py returns nothing — so exported PNGs looked accidentally correct. #243 adds _native_font_emphasis with bold = float(weight) >= 600, and the title default of 600 hits that threshold exactly. Measured on the #255 tip, before this PR:

'USA births by day of year (1969-1988)' STYLED bold=True

So the raster title is genuinely bold at the #255 tip where it was normal on main. That is a regression away from Matplotlib introduced by the stack, and it is why this PR belongs inside the stack rather than after it. Axis labels escaped only because 500 < 600.

js/src/20_theme.ts line 124 (annotation_label, weight 500) is untouched by all 16 open PRs.

Measured before → after

Real matplotlib 3.11.1 is normal (400) for every row.

Element Browser SVG Native raster
chart title 600 → 400 600 → 400 bold → no emphasis
axis labels 500 → 400 500 → 400 no emphasis (500 was below the 600 bold threshold)
annotation labels 500 → 400 unset → unset (400) no emphasis
legend title 600 → 400 600 → 400 no emphasis
colorbar title 500 → 400 unset → unset (400) no emphasis
tick labels 400 unset (400) no emphasis
legend entries 400 unset (400) no emphasis

Two things worth naming from that table:

  • Annotations and colorbar titles were a browser-only divergence. The SVG exporter never emitted a weight for them, so only the render client was heavy. The three renderers disagreed with each other before this PR, not just with Matplotlib.
  • Legend title and colorbar title are beyond the three elements in the brief (title, axis labels, annotations). I included them because the measurement says Matplotlib renders both at normal, because the native raster path already rendered them normal — so SVG and browser were the odd ones out — and because the source-level guard is only writable as "no chrome text default exceeds 400" if every one of them is fixed.

Impact on core charts — yes, these are core defaults

Core (non-pyplot) xy.Figure charts change appearance. These are engine defaults, not shim values, so a plain xy.chart(...) title now renders at normal weight instead of semibold, and core axis labels/annotations/legend titles lose their 500–600 weight. That is a deliberate, visible change to every existing core chart, and it is the thing to push back on if the intent is that core keeps a distinct typographic voice from the Matplotlib shim.

The argument for changing core rather than only the shim: the defaults live in one place per renderer, xy.pyplot compiles onto the same core Figure, and splitting them would mean core and shim disagreeing about what styles={"title": ...} falls back to. Heavier text stays available everywhere via styles[slot], label_font_weight, or the rcParams.

Non-goals: no change to font size, family, style, color, or layout; no change to how an explicit weight is resolved (that is #255's work, which this composes with).

Comparison images

Matplotlib reference / before / after. Both are the native PNG export path — npm/node are not installed in this environment and python/xy/static/ does not exist, so I could not build the client bundles to capture the browser path. The native path is the one that regresses, so it is also the right path to show.

PDSH 04.09 cell 23

PDSH cell 23: Matplotlib reference, before, after

Plain core chart

Real data/births.csv (15,548 rows) through the core xy.Figure API rather than the shim, so the core-default change is visible on its own.

Core chart: Matplotlib reference, before, after

Validation

Python suite, this branch vs. its pristine base, same command both sides:

base:  157 failed, 1884 passed, 11 skipped in 77.84s
this:  157 failed, 1897 passed, 11 skipped in 74.20s

The two failure sets are byte-identical (diff of the sorted FAILED/ERROR lines is empty). All 157 are pre-existing on a pristine tree and come from the absent git-ignored JS bundles and anywidget; five further modules cannot be collected at all for the same reason and are excluded on both sides. Net: +13 passed, 0 regressions.

New tests (13):

  • tests/test_text_weight_defaults.py (10) — per-element font-weight in SVG output; per-element emphasis decoded out of the native raster command stream by locating each text record from its UTF-8 payload and reading the opcode/flags byte; SVG-vs-raster agreement on the axis-label default; source-level guards on the TypeScript defaults, both the 20_theme.ts slot rules and the ten inline weights in 50_chartview.ts
  • tests/pyplot/test_rc_chrome_contracts.py (3) — the two rcParams default to normal and put nothing on the wire; overrides reach chrome_styles (browser) and the axis style (SVG/raster); an explicit set_title(fontweight=) still wins

Negative control on the new tests. Reverted just the source changes, kept the tests: 5 of 10 fail, covering the SVG defaults, the raster bold flag, SVG/raster agreement, the theme stylesheet and the chartview inline weights. The other 5 assert #255's override behavior and correctly stay green. So these tests fail on the defect and pass on the fix.

One existing expectation updated: tests/test_css_mark_styles.py::test_axis_style_reaches_svg_and_native_renderers asserted font-weight="500" for an axis label whose style sets no weight — it pinned the old default.

rcParams verified end-to-end against all three renderers:

--- defaults: axes.titleweight/labelweight = normal ---
  TITLE    svg=400    raster=plain TEXT_OP (no emphasis flags)
  XLABEL   svg=400    raster=plain TEXT_OP (no emphasis flags)
--- axes.titleweight=bold, axes.labelweight=bold ---
  TITLE    svg=bold   raster=STYLED bold=True
  XLABEL   svg=bold   raster=STYLED bold=True
--- axes.titleweight=700, axes.labelweight=700 ---
  TITLE    svg=700    raster=STYLED bold=True
  XLABEL   svg=700    raster=STYLED bold=True

Other checks:

  • cargo build --release --offline — clean (no Rust change in this PR)
  • ruff check . — all checks passed; ruff format --check . — 364 files already formatted
  • pre-commit run --all-files — ruff check, ruff format, docs-app-codespell all pass
  • Matplotlib ground truth measured with a real matplotlib==3.11.1 in a throwaway env, not from memory

Not verified

  • TypeScript is unbuilt and untypechecked. npm and node are not installed anywhere in this environment, so node js/build.mjs cannot run and python/xy/static/ does not exist. I am not claiming a tsc pass. As a labelled proxy I ran deno check --sloppy-imports over the module graph from js/src/60_entries.ts with js/tsconfig.json's flags (strict:false, noImplicitAny:false, noImplicitThis:false): clean. Negative control — injecting const probe: number = "deliberate type error" into 20_theme.ts produced TS2322, so the proxy does have teeth. It is still a proxy, not the project's real typecheck, and the three TypeScript weight changes are unexercised at runtime here.
  • The browser weights are asserted at source level only. Nothing in this PR re-measures getComputedStyle in a real browser; that needs the bundles.
  • The native font atlas holds one regular and one bold face, so weights >= 600 all collapse onto the same bold glyphs in PNG output. Intermediate weights are not distinguishable there. Documented rather than fixed.

Spec

  • spec/api/styling.md — new Chrome text weight section: the 400 default, the per-renderer table of where it lives, the opt-in forms, and the native-PNG bold approximation. Axis style table gains the label_font_* keys.
  • spec/matplotlib/compat.mdrcParams row notes the two new keys and that they reach all three renderers; new Text weight row.
  • spec/matplotlib/shim-todo.md — the "exporter chrome stays fixed regardless of rcParams" known-inconsistency now carves out the two weight keys, which I measured reaching single-chart SVG and PNG.

No compat-changelog.md entry: that file scopes itself to changes in the upstream compatibility target and in the meaning of the approximation levels, and neither moves here.

Coordination

A sibling agent is concurrently preparing agent/mpl-annotation-box-text-fidelity (also stacked on #243) for annotation bbox alpha, plain-text colour and corner radius. That work overlaps this one on annotation text but on different properties — theirs is paint and box geometry, this is weight only. The only shared line is the annotation_label slot rule in 20_theme.ts.

Draft because it intentionally depends on #255; retarget to main once #243 and #255 land. The pr-assets/text-weight/ commit is temporary and should be dropped before merge.

Matplotlib 3.11's axes.titleweight, axes.labelweight and font.weight all
default to "normal", and its legend titles and colorbar labels are normal
too. xy rendered chart titles at 600 and axis labels, annotations and
colorbar titles at 500, so every chart came out heavier than the same
script under Matplotlib.

Set the default to 400 in all three renderers so they agree:

- _svg.py: title, axis-title and legend-title font-weight attributes
- _raster.py: the title and axis-label weights fed to _native_font_emphasis
- 20_theme.ts: the title, colorbar_title and annotation_label slot rules
- 50_chartview.ts: the nine inline axis-label weights and the legend title
  (inline styles beat the slot stylesheet, so both had to change)

Add the axes.titleweight and axes.labelweight rcParams to the pyplot shim
so Matplotlib's own knobs work. Both publish onto chrome_styles for the
browser and onto the axis style for the SVG/raster exporters, and only
travel when they differ from "normal" — the value every renderer already
defaults to.

This changes core (non-pyplot) chart appearance: core titles now render at
normal weight instead of semibold.

tests/test_text_weight_defaults.py asserts the emitted weight per element
in the SVG output and in the native raster command stream, and guards the
TypeScript defaults at source level, since the client bundles are a
generated, git-ignored artifact.
Matplotlib reference / before / after three-ups for the PDSH 04.09 cell 23
figure and a plain core chart. Temporary: remove before merge.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: de2bdd04-3fde-44ee-bfbc-0b064ab40a37

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/mpl-text-weight-defaults

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 102 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing agent/mpl-text-weight-defaults (e2a9621) with agent/matplotlib-text-fidelity (7f1899a)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@sselvakumaran

Copy link
Copy Markdown
Contributor Author
  • probably Match Matplotlib gallery text fidelity [mpl compatibility] #255 should fix this, but the text boxes are too short / misaligned. the independence day is too short on both ends; thanksgiving is too short on the right, christmas on the left (which i assume means that the box is anchored at the right position but is sized incorrectly.

i think this is a fix from another PR but should be verified - thanksgiving and christmas borders are also not correct
also the arrow for new year’s day has the wrong bend - should bend upwards rather than downwards.

also the margins are still an issue but should be fixed in another PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant