Match Matplotlib annotation box and text fidelity [mpl compatibility] - #271
Match Matplotlib annotation box and text fidelity [mpl compatibility]#271sselvakumaran wants to merge 4 commits into
Conversation
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.
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
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.ipynbwiththe real
examples/pdsh/data/births.csv(15,548 rows):alphanow dims the edge as well as the face. Matplotlib'salphais the patch alpha — its SVG backend emits it as element
opacity, whichdims face and edge together — so
bbox=dict(boxstyle="round", alpha=0.1)leaves the default black edge invisible. Shim only; core charts unchanged.
text/annotatelabel is painted withrcParams["text.color"]. Only the callout branch pinnedlabel_color, so aplain
kind:"text"annotation reached each renderer with no colour and pickedup that renderer's own annotation-label design token. Shim only; core charts
unchanged.
boxstyle="round"box has rounded corners. Fix pie labels and boxed text [mpl compatibility] #243 made the boxappear in SVG/PNG at all, but neither emitter consumed
border_radius, soexports were square where the browser was already rounded. Core exporters —
see impact below.
No
js/src/*.tschange was needed, and none is included._stylePropertyNamealready maps
border_radius→ CSSborder-radiusand_stylePropertyValuealready appends
pxto numbers, and51_annotations.tsalready resolveslabel_color || color; the first two fixes change the style values the shimemits, so the browser picks them up unchanged. That is fortunate here, because
npm/nodeare unavailable in this environment andpython/xy/static/doesnot exist — any TS edit would have been build- and typecheck-unverified.
Root cause
D1 —
_bbox_label_style(python/xy/pyplot/_axes.py) compositedalphainto the face only:
so the DOM and both exporters carried
border: 1px solid rgb(0,0,0)besidebg: rgba(31,119,180,0.1). The alpha compositing is now a localwith_alphahelper used for both channels. CSS paints
backgroundandborderseparately,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_coloronly on the callout branch, and the threerenderers' fallbacks are not the same colour:
--chart-annotation-text→--chart-text_svg.py:1853)#667085rgb(102,112,133)_raster.py:1225via_TEXT)rgba(32,32,32,.85)rgb(65,65,65)Fixed at the shim end, deliberately. Black is
rcParams["text.color"], amatplotlib default, whereas
#667085andrgba(32,32,32,.85)are the engine'sown 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_colorin 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.mdinstead.D3 —
_svg_text_box()and_emit_text_box()(added by #243) never readborder_radius. Both now share a_box_corner_radius()helper that clamps tohalf the shorter side like CSS; SVG gains
rx, and the rasterizer draws a_round_rect_pts()polygon (4 segments per quarter turn) because the nativedisplay list has polygons, not paths.
Impact on core (non-pyplot) charts
D1 and D2 are entirely inside
python/xy/pyplot/, so a core chart cannotchange. D3 is in
_svg.py/_raster.pyand does change core output, in onebounded way: a non-pyplot chart that sets
border_radiuson an annotation labelnow 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_radiusis byte-identical (_box_corner_radiusreturns 0.0 and_round_rect_ptsfalls 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.
opacity: 0.1onfill:#1f77b4+stroke:#000000stroke="black"stroke="rgba(0,0,0,0.1)"(221,224,226)(0,0,0)(219,223,225)fill="#667085"fill="black"(32,32,32,217)(0,0,0,255)(0,0,0)(65,65,65)(0,0,0)round, 10 pt)rxrx="5"(100,100,100)(255,255,255)Christmas — bbox edge alpha and corner radius (D1, D3)
Labor Day Weekend — arrow-less label colour (D2)
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:
round4corner radius is still coarse. Fix pie labels and boxed text [mpl compatibility] #243's constants are 5 px(
round) and 8 px (round4); matplotlib derivespad × fontsize, measuring4.17 px for
roundat 10 pt and 5.42 px at 13 pt — so the 5 px constant iswithin ~1 px across both sizes, but
round4is 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.resolve_colorpassescolour names through verbatim and
_rgba_floatsparses only hex/rgb/rgba pluseight basic names, so
ec="gray", alpha=0.5keeps a solid edge wherematplotlib 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.centring, and the Thanksgiving connector shape (both below).
Validation
tests/pyplot: 565 passed, 76 failed — against 552 passed, 76 failedon Fix pie labels and boxed text [mpl compatibility] #243's head. Same 76-failure set, +13 new tests.
on Fix pie labels and boxed text [mpl compatibility] #243's head.
commover the sorted failure sets shows zero newfailures and zero accidental fixes.
npm/nodeare unavailable here so
python/xy/static/does not exist. Both numbers weretaken from clean
git archiveexports of each revision, not from stashing.<rect>/<text>attributes from the actual export, and decoded
FILL/STROKE/TEXTcommands 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 .andruff format --check .clean;git diff --checkclean.cargo build --release --offlineclean (the prebuilt dylib reports ABI 37against 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.tsandjs/src/51_annotations.tssource, 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
.venvcarries a_xy.pthpointing at the main checkout — so measuring through it readsmain,not a worktree, and
_find_library()then falls through to main'starget/release/.xy.__file__asserted under the intended tree; native library resolved,sha256'd, and asserted equal to that tree's own build.
e112782c…(ABI 40) vs main's4509266e…(ABI 37) — theydiffer, and because the ABI gate compares them a wrong load would have raised
ImportErrorhere rather than passing silently.PYTHONPATHandXY_NATIVE_LIBboth pinned: every valuein the table above reproduced exactly, and the re-rendered PNG/SVG are
byte-identical (
acaa50a4…,8782750c…) to the artifacts the pixelreadings were taken from. No number changed.
zero Rust changes (
src/tree hash26b52eec…is identical betweenFix 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.
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 asibling of #255, not a child. I checked
gh pr diff 255: it touches four ofthe same files (
_svg.py,_raster.py,pyplot/_axes.py,js/src/50_chartview.ts) but no hunk overlaps. #255 is axis-label andfigure-title font styling, mathtext italic spans, and the font atlas
(
src/font.rs,scripts/gen_font.py); its_axes.pyhunks are_apply_axis_label_kwargs/_pop_text_style_kwargs, not_bbox_label_styleorthe annotation branch. Its
styling.mdedit is the native-glyph-coverageparagraph; 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 newfile, deliberately, to keep the two from colliding. A textual conflict is
possible in
compat.mdif #255 later edits the annotate row, but neither PRedits 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 areuntouched here.
Neither #243 nor #255 was rebased or force-pushed.
Two adjacent defects, deliberately not fixed here
Both are separate PRs, not omissions:
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.ConnectionStyledirectly for the path grammar:angle3,angleA=0,angleB=-90M33— one quadraticarc3,rad=-0.2M33— one quadraticangle,angleA=0,angleB=80,rad=20ML33L— line, fillet, lineangle,...,rad=0MLL— hard elbowSo xy's quadratic is correct for
angle3andarc3and wrong only forangle— and_connection_curveconflates them(
if name in ("angle3", "angle")) while discardingradentirely.Fixing it needs a polyline-plus-fillet primitive in the shared
python/xy/_arrowgeom.py, a new corner-radius key on the wire, the mirroredchange in
js/src/51_annotations.ts, and a spec update to the connectorgrammar. 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.
Figure title centring.
_svg.pycentres the title atwidth / 2(measured
x="600"), while matplotlib centres it on the axes (measuredaxes centre
615.0in the same 1200 space) — a 15 px offset. This affectsevery 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.