Skip to content

Match Matplotlib annotation box and text fidelity [mpl compatibility] - #271

Closed
sselvakumaran wants to merge 4 commits into
agent/gallery-compat-pie-text-vectorfrom
agent/mpl-annotation-box-text-fidelity
Closed

Match Matplotlib annotation box and text fidelity [mpl compatibility]#271
sselvakumaran wants to merge 4 commits into
agent/gallery-compat-pie-text-vectorfrom
agent/mpl-annotation-box-text-fidelity

Conversation

@sselvakumaran

@sselvakumaran sselvakumaran commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Three annotation/text fidelity defects, all measured against matplotlib 3.11.1
rendering cell 23 of examples/pdsh/pdsh_04_09_text_and_annotation.ipynb with
the real examples/pdsh/data/births.csv (15,548 rows):

  • bbox alpha now dims the edge as well as the face. Matplotlib's alpha
    is the patch alpha — its SVG backend emits it as element opacity, which
    dims face and edge together — so bbox=dict(boxstyle="round", alpha=0.1)
    leaves the default black edge invisible. Shim only; core charts unchanged.
  • An arrow-less text/annotate label is painted with
    rcParams["text.color"].
    Only the callout branch pinned label_color, so a
    plain kind:"text" annotation reached each renderer with no colour and picked
    up that renderer's own annotation-label design token. Shim only; core charts
    unchanged.
  • An exported boxstyle="round" box has rounded corners. Fix pie labels and boxed text [mpl compatibility] #243 made the box
    appear in SVG/PNG at all, but neither emitter consumed border_radius, so
    exports were square where the browser was already rounded. Core exporters —
    see impact below.

No js/src/*.ts change was needed, and none is included. _stylePropertyName
already maps border_radius → CSS border-radius and _stylePropertyValue
already appends px to numbers, and 51_annotations.ts already resolves
label_color || color; the first two fixes change the style values the shim
emits, so the browser picks them up unchanged. That is fortunate here, because
npm/node are unavailable in this environment and python/xy/static/ does
not exist — any TS edit would have been build- and typecheck-unverified.

Root cause

D1_bbox_label_style (python/xy/pyplot/_axes.py) composited alpha
into the face only:

style["background"] = f"rgba({r},{g},{b},{alpha * a:.3g})"   # alpha applied
...
style["border"] = f"{lw:g}px solid {resolve_color(edge)}"    # alpha dropped

so the DOM and both exporters carried border: 1px solid rgb(0,0,0) beside
bg: rgba(31,119,180,0.1). The alpha compositing is now a local with_alpha
helper used for both channels. CSS paints background and border separately,
so each has to carry the alpha itself — that is why this is two composited
colours rather than one element opacity.

D2 — the shim pinned label_color only on the callout branch, and the three
renderers' fallbacks are not the same colour:

Renderer Fallback Composited on white
browser --chart-annotation-text--chart-text black-ish (correct by accident)
SVG (_svg.py:1853) #667085 rgb(102,112,133)
native PNG (_raster.py:1225 via _TEXT) rgba(32,32,32,.85) rgb(65,65,65)

Fixed at the shim end, deliberately. Black is rcParams["text.color"], a
matplotlib default, whereas #667085 and rgba(32,32,32,.85) are the engine's
own design tokens for non-pyplot annotations. Changing either core fallback
would have repainted every non-pyplot chart's annotation labels — a silent
appearance change for existing users, to fix a pyplot-only defect. Pinning
label_color in the shim is also exactly what the callout branch already did,
so the two branches now agree. The core three-way divergence is real but is left
alone and is now written down in spec/api/styling.md instead.

D3_svg_text_box() and _emit_text_box() (added by #243) never read
border_radius. Both now share a _box_corner_radius() helper that clamps to
half the shorter side like CSS; SVG gains rx, and the rasterizer draws a
_round_rect_pts() polygon (4 segments per quarter turn) because the native
display list has polygons, not paths.

Impact on core (non-pyplot) charts

D1 and D2 are entirely inside python/xy/pyplot/, so a core chart cannot
change. D3 is in _svg.py/_raster.py and does change core output, in one
bounded way: a non-pyplot chart that sets border_radius on an annotation label
now exports with rounded corners instead of square. That is a convergence, not a
regression — the render client already rounded those corners, so the exporters
were the ones disagreeing with the live chart. A core chart that sets no
border_radius is byte-identical (_box_corner_radius returns 0.0 and
_round_rect_pts falls back to _rect_pts), which the two "square"
regression tests pin.

Measured before / after

Values from the real figure, matplotlib 3.11.1 as reference. mpl's SVG is an
864 pt viewBox and xy's a 1200 px one; positions below are normalised to 1200.

matplotlib 3.11.1 xy before xy after
D1 Christmas box edge, SVG opacity: 0.1 on fill:#1f77b4 + stroke:#000000 stroke="black" stroke="rgba(0,0,0,0.1)"
D1 Christmas top border, PNG (221,224,226) (0,0,0) (219,223,225)
D2 Labor Day label, SVG no fill → black fill="#667085" fill="black"
D2 Labor Day label, native cmd (32,32,32,217) (0,0,0,255)
D2 Labor Day glyph, PNG (0,0,0) (65,65,65) (0,0,0)
D3 box corner radius, SVG 4.17 px (round, 10 pt) no rx rx="5"
D3 Thanksgiving corner, PNG 12.0 px background run 0.0 px, corner pixel (100,100,100) 4.0 px, corner pixel (255,255,255)

Christmas — bbox edge alpha and corner radius (D1, D3)

Christmas

Labor Day Weekend — arrow-less label colour (D2)

Labor Day Weekend

Each panel is centred on its own annotation: xy places these labels further
right than matplotlib (the Christmas box is 88.8 px wide against matplotlib's
101.9 px), because the exporters size boxes from an estimated 0.48em-per-
character text width. That width estimate is untouched here and is not one of
the three defects. The Thanksgiving box visible in the corner of the Christmas
strip shows D3 a second time.

Honest residuals

These are advances past one defect into another, not parity:

  • round4 corner radius is still coarse. Fix pie labels and boxed text [mpl compatibility] #243's constants are 5 px
    (round) and 8 px (round4); matplotlib derives pad × fontsize, measuring
    4.17 px for round at 10 pt and 5.42 px at 13 pt — so the 5 px constant is
    within ~1 px across both sizes, but round4 is a different curve family
    (its leading offset measures 3.47 px) and 8 px is not a principled match. I
    consumed Fix pie labels and boxed text [mpl compatibility] #243's constants rather than re-deriving them, because they are that
    PR's contract. Recorded in spec/matplotlib/compat.md.
  • A CSS named edge colour still loses its alpha. resolve_color passes
    colour names through verbatim and _rgba_floats parses only hex/rgb/rgba plus
    eight basic names, so ec="gray", alpha=0.5 keeps a solid edge where
    matplotlib dims it. Keeping the edge beats dropping the border, and it is the
    pre-existing behaviour of the face-alpha path. Pinned by a test that names it
    a degradation, and recorded in compat.md. Matplotlib's grey shorthand
    (ec="0.4") — the form this corpus uses — does composite correctly.
  • The figure still differs from matplotlib in label x-placement, title
    centring, and the Thanksgiving connector shape (both below).

Validation

  • tests/pyplot: 565 passed, 76 failed — against 552 passed, 76 failed
    on Fix pie labels and boxed text [mpl compatibility] #243's head. Same 76-failure set, +13 new tests.
  • Whole suite: 1904 passed, 161 failed against 1891 passed, 161 failed
    on Fix pie labels and boxed text [mpl compatibility] #243's head. comm over the sorted failure sets shows zero new
    failures and zero accidental fixes.
  • The 76/161 failures are the documented bundle-absent baseline: npm/node
    are unavailable here so python/xy/static/ does not exist. Both numbers were
    taken from clean git archive exports of each revision, not from stashing.
  • The 13 new tests assert real emitted output — SVG <rect>/<text>
    attributes from the actual export, and decoded FILL/STROKE/TEXT
    commands out of the native display list. 6 of them fail on Fix pie labels and boxed text [mpl compatibility] #243's head
    (two per defect, one per renderer); the pre-fix values quoted in the table
    above are what those failures reported.
  • ruff check . and ruff format --check . clean; git diff --check clean.
  • cargo build --release --offline clean (the prebuilt dylib reports ABI 37
    against this branch's 40 and will not load without it).

Not verified: anything requiring the JS bundle or a browser. The browser claims
in this PR are read off js/src/50_chartview.ts and js/src/51_annotations.ts
source, not observed in a running client.

Measurement provenance

Every number above was re-measured with the tree pinned and asserted
inside the rendering process, because the repo's shared .venv carries a
_xy.pth pointing at the main checkout — so measuring through it reads main,
not a worktree, and _find_library() then falls through to main's
target/release/.

  • xy.__file__ asserted under the intended tree; native library resolved,
    sha256'd, and asserted equal to that tree's own build.
  • Worktree core e112782c… (ABI 40) vs main's 4509266e… (ABI 37) — they
    differ, and because the ABI gate compares them a wrong load would have raised
    ImportError here rather than passing silently.
  • Re-measured with PYTHONPATH and XY_NATIVE_LIB both pinned: every value
    in the table above reproduced exactly, and the re-rendered PNG/SVG are
    byte-identical (acaa50a4…, 8782750c…) to the artifacts the pixel
    readings were taken from. No number changed.
  • The before/after panels deliberately share one native core: this PR has
    zero Rust changes (src/ tree hash 26b52eec… is identical between
    Fix pie labels and boxed text [mpl compatibility] #243's head and HEAD), so the only variable is Python. Using two different
    dylibs would have introduced the confound here, not controlled for it.
  • The matplotlib reference comes from a separate venv running real
    matplotlib 3.11.1, independent of xy.

Relationship to the rest of the stack

Branched from and targeting agent/gallery-compat-pie-text-vector (#243)
D3 is a residual of that PR's box emission and is meaningless without it, and
D1/D2 touch the same annotation paths.

#255 (agent/matplotlib-text-fidelity) is also stacked on #243, so this is a
sibling of #255, not a child.
I checked gh pr diff 255: it touches four of
the same files (_svg.py, _raster.py, pyplot/_axes.py,
js/src/50_chartview.ts) but no hunk overlaps. #255 is axis-label and
figure-title font styling, mathtext italic spans, and the font atlas
(src/font.rs, scripts/gen_font.py); its _axes.py hunks are
_apply_axis_label_kwargs/_pop_text_style_kwargs, not _bbox_label_style or
the annotation branch. Its styling.md edit is the native-glyph-coverage
paragraph; mine adds a new label-box section and touches the annotation-colour
text. Its tests live in test_gallery_text_pie_compat.py; mine are in a new
file, deliberately, to keep the two from colliding. A textual conflict is
possible in compat.md if #255 later edits the annotate row, but neither PR
edits it today.

A sibling agent is concurrently preparing a PR for text weight defaults
(title 600→400, axis and annotation labels 500→400, plus axes.titleweight /
axes.labelweight), also stacked on #255. No overlap with this one: weights are
untouched here.

Neither #243 nor #255 was rebased or force-pushed.

Two adjacent defects, deliberately not fixed here

Both are separate PRs, not omissions:

  1. The Thanksgiving connector. connectionstyle="angle,angleA=0,angleB=80,rad=20"
    renders as a smooth parabola in xy against matplotlib's straight-run-then-elbow.
    Asked matplotlib.patches.ConnectionStyle directly for the path grammar:

    style matplotlib path codes
    angle3,angleA=0,angleB=-90 M33 — one quadratic
    arc3,rad=-0.2 M33 — one quadratic
    angle,angleA=0,angleB=80,rad=20 ML33L — line, fillet, line
    angle,...,rad=0 MLL — hard elbow

    So xy's quadratic is correct for angle3 and arc3 and wrong only for
    angle — and _connection_curve conflates them
    (if name in ("angle3", "angle")) while discarding rad entirely.
    Fixing it needs a polyline-plus-fillet primitive in the shared
    python/xy/_arrowgeom.py, a new corner-radius key on the wire, the mirrored
    change in js/src/51_annotations.ts, and a spec update to the connector
    grammar. That is a change to the arrow-geometry contract — wider blast radius
    than annotation text and boxes, and it needs a JS toolchain this environment
    does not have.

  2. Figure title centring. _svg.py centres the title at width / 2
    (measured x="600"), while matplotlib centres it on the axes (measured
    axes centre 615.0 in the same 1200 space) — a 15 px offset. This affects
    every titled chart including non-pyplot core charts, in both exporters and
    the client, so it is a much broader change than this PR. It should also land
    after Match Matplotlib gallery text fidelity [mpl compatibility] #255, which already rewrites the figure-title emission in both
    exporters; doing it here would conflict on those exact lines.

A text/annotate bbox and an arrow-less annotation label did not survive
the trip to the static exporters the way Matplotlib draws them:

- bbox alpha reached the face but not the edge. Matplotlib's alpha is the
  patch alpha — its SVG backend emits it as element opacity, dimming face
  and edge together, so bbox=dict(boxstyle="round", alpha=0.1) leaves the
  default black edge invisible. The shim built border from an unmodified
  resolve_color(edge), so the box gained a hard black outline.
- an arrow-less text/annotate label carried no colour, so each exporter
  fell back to its own annotation-label design token (SVG #667085, native
  rgba(32,32,32,.85)) instead of Matplotlib's rcParams["text.color"].
  Only the callout branch pinned label_color.
- boxstyle="round" set border_radius, which the browser applies as CSS
  border-radius, but neither exporter box emitter consumed it, so an
  exported box was square where the live one was rounded.

The first two are shim-local, so non-pyplot charts are untouched. The
third teaches the two exporter box emitters a style key the render client
already honoured; a core chart that sets border_radius on an annotation
now exports rounded, matching what it already displayed.
…dius

Assertions run against real emitted output rather than intermediate style
dicts: SVG <rect>/<text> attributes from the actual export, and decoded
FILL/STROKE/TEXT commands out of the native display list.

Six of the thirteen fail on #243's head — two per defect, one per
renderer — and the pre-fix values they report are the measured ones:
the edge came out "black" instead of rgba(0,0,0,0.1), the label reached
the rasterizer as (32,32,32,217) instead of (0,0,0,255), and the box
polygon had 4 vertices with no rx attribute.

The CSS-named-edge case is pinned as a documented degradation rather
than an aspiration: resolve_color passes colour names through and
_rgba_floats parses only hex/rgb/rgba plus eight basic names, so
ec="gray" with alpha keeps a solid edge where Matplotlib dims it.
spec/api/styling.md had no account of the four annotation label-box style
keys at all, and no note that the annotation label colour default differs
per renderer (browser --chart-annotation-text, SVG #667085, native
rgba(32,32,32,.85)) — the divergence that made an uncoloured pyplot label
export grey. Both are now written down, with the per-renderer mapping of
background/border/padding/border_radius.

spec/matplotlib/compat.md contradicted itself after #243: the annotate row
said bbox= boxes reach static exports while the approximations paragraph
still said they were "drawn only by the HTML label". The stale clause is
replaced by the approximation that is actually left — a fixed corner
radius per box style (5 px round, 8 px round4) against Matplotlib's
pad x fontsize path, 4.17 px for round at 10 pt in 3.11.1 — plus the
named-edge-colour alpha gap.
Three-up Matplotlib 3.11.1 / before / after zoom crops of the Christmas
and Labor Day Weekend callouts from cell 23 of
examples/pdsh/pdsh_04_09_text_and_annotation.ipynb, rendered from the real
examples/pdsh/data/births.csv. Temporary — for PR review only, to be
removed 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: a285ad85-725d-4ded-aa89-a861a3453c9e

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-annotation-box-text-fidelity

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-annotation-box-text-fidelity (f41ab6e) with agent/gallery-compat-pie-text-vector (2946fc6)

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.

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