Reserve measured axis-label gutter [mpl compatibility]#273
Reserve measured axis-label gutter [mpl compatibility]#273sselvakumaran wants to merge 2 commits into
Conversation
The rotated y-axis title was drawn on top of the y tick labels. Two fixed constants caused it, and each is now measured from the ink it has to hold. `_axis_label_geometry` anchored the title's *baseline* at a 10 px canvas inset, while ChartView centers its rotated line *box* on that same inset — so static exporters drew the title one full ascent further toward the canvas edge than the browser does. Apply the same box-to-baseline correction the x-axis title already makes, shifting by (ascent - descent) / 2. `layout()` handed every chart a flat 46/62 px left gutter. Floor it at what the axis's own text measures: the title's line box, Matplotlib's 0.4 em title-to-tick gap, the tick padding, and the widest tick label's advance. Widths come from the DejaVu advances the Rust rasterizer blits, so the reservation is measured in the metrics of the font that draws the ink. `scripts/gen_font.py` now emits those advances as `python/xy/_fontmetrics.py` alongside `src/font.rs`, from one face in one run. The floor never overrides: an authored `padding` and the 46/62 default both stand whenever they already fit, exactly as the colorbar and right-axis room are additive rather than authoritative. It rises only where the alternative is ink off the canvas or over the tick labels, which a static export cannot recover from the way the DOM can. `_to_notebook_html` pins a padding to match Matplotlib's inline canvas, and that pin also became the browser's gutter. Ask `layout()` for the measured number first and ship that, widening the canvas by the shortfall so the plot box keeps Matplotlib's 0.775 width fraction. Browser, SVG, and PNG then share one reservation by construction instead of two implementations agreeing. This subsumes the shim-only `_explicit_y_tick_gutter`, which covered authored and categorical labels but returned 0 for a numeric axis and was skipped entirely once a padding was set.
TEMPORARY: PR review asset only, removed before merge. Matplotlib 3.11.1 reference / before / after for the y-title-over-tick-labels collision on examples/pdsh/pdsh_04_09_text_and_annotation.ipynb cell 23.
|
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
46/62 pxpython/xy/_fontmetrics.pyfromscripts/gen_font.py, so the reservation is measured in the metrics of the font that draws the ink_explicit_y_tick_gutteradded by Reserve canvas room for categorical tick labels [mpl compatibility] #256 rather than adding a second parallel mechanismStacked on #256 (
agent/gallery-canvas-gutters), which is itself on #240. See Why this base below.Root cause
Two fixed constants, in the shared static-layout path rather than in the shim.
1.
_axis_label_geometryanchored the wrong box edge (python/xy/_svg.py). ChartView renders the y title as a rotated DOM line box:left:10px; transform:rotate(-90deg) translateX(50%); transform-origin:leftputs the box's center on x = 10, spanning[10 − 1.2·font/2, 10 + 1.2·font/2]. The static exporters emittedoutside_x = 10as a baseline, so the ink spanned[10 − ascent, 10 + descent]— one full ascent further toward the canvas edge. At the shim's 13.89 px title font that is x −3.0…13.5 instead of the browser's 1.7…18.4, i.e. clipped. The x-axis title already carries exactly this correction (y += font_size * 0.82, "DOM labels use top positioning; static text commands use a baseline"); the y title never got it.2.
layout()'s gutter ignored the text it had to hold (python/xy/_svg.py).left = 46 if compact else 62fits 11 px numeric ticks under a 12 px title. Matplotlib's rcParams make both 13.89 px at 100 dpi, and 62 px cannot hold10 px inset + 8.3 px half-line-box + 5.6 px gap + 34.7 px of "5400" + 9.7 px tick pad = 68.2 px.3. The notebook pin also pinned the browser (
python/xy/pyplot/_mplfig.py)._to_notebook_htmlsetsnotebook_padding = [dpi*.15, dpi*.20, dpi*.34, dpi*.41]→ left = 41 px to match Matplotlib's inlinebbox_inches="tight"canvas.layout()is a Python function, so widening it there fixes the static exporters but not the browser, which readsspec.paddingdirectly. 41 px is 27 px short of the ink._explicit_y_tick_gutter(#256) could not reach this: it needstick_labels/categories/string data (a numeric axis returns0.0) andself._padding is None(false on the notebook path). Its reservation was also shim-only while the defect is general static-export layout, so this lifts the measurement into_svg.layout()— beside_colorbar_right_axis_room— and deletes the shim helper instead of running two mechanisms.Does core (non-pyplot) layout change?
Only where the text does not fit. The reservation is a floor, not an override —
paddingand the46/62default both stand whenever they already fit, exactly as the colorbar and right-axis room are additive rather than authoritative. A core chart at the default 11 px ticks / 12 px title needs10 + 7.1 + 4.8 + 27.5 + 4 = 53.4 px< 62, so it is laid out byte-identically;test_ordinary_numeric_axis_keeps_the_default_gutterandtest_short_category_ticks_keep_the_core_default_gutterpin that.What does change for core charts:
46/62gets a wider gutter and a narrower plot in SVG/PNG/PDF (largelabel_size/tick_label_size, long category names, wide authoredtick_labels)42/54). Their title is pinned plot-relative (plot-right + 40) rather than to a canvas inset, so widening only the static gutter would move their title away from the browser's. Documented as deliberate inspec/api/styling.md, not silently fixed.padding=Nonestill gets ChartView's own46/62;layout()cannot reach it. Only the pyplot notebook path lifts the number onto the wire. Also documented; the browser degrades differently there by design (it ellipsizes overlong categorical tick labels — an SVG has no such fallback, which is why the static side reserves).Validation
Matplotlib 3.11.1, real
examples/pdsh/data/births.csv(15,547 data rows), cell 23 drawn through one shareddraw()so both renderers get identical calls.Text.get_window_extentfor the reference; SVG text coordinates plus a pixel-ink scan of the native raster for xy.The gap lands on Matplotlib's 5.6 px exactly: it is reserved as
0.4 emof the title font, which is what 5.6 px measures at the shared 13.89 px default.The browser is fixed by the same number, not by a separate rule. The notebook path now ships
padding = [15.0, 20.0, 34.0, 68.2], and ChartView'sleft:10pxline box at 1.7…18.3 against tick labels pinned atplot.x − 9.7gives 23.8…58.5 — overlap +16.5 px → −5.5 px._svg.layout()on that same spec returnsplot.x = 68.2, identical by construction.Not claimed
_to_notebook_html'sdpi * 0.48/0.77constants, which this PR does not touch.Checks
179 failed, 1941 passed, 11 skipped— failure set identical to the pristine baseline (baseline180 failed, 1930 passed; the one extra baseline failure istest_examples_commit_no_static_chart_html, which only fails inside thegit archivecopy used for baselining). The ~180 failures are this environment's missingpython/xy/static/*.jsbundles, not regressions.tests/test_axis_title_gutter.py(7) +tests/pyplot/test_gallery_canvas_gutters.py(5) — all pass. No-overlap and not-clipped assertions on emitted SVG geometry for numeric and long-categorical labels, in both the default and notebook-padding paths, plus a real pixel-ink scan of the native raster's left gutter, plus a guard pinning_fontmetricsadvances tosrc/font.rsso a regenerated atlas cannot leave the reservation measuring a different font than the one drawn.cargo test:114 passed(no Rust change)ruff check,ruff format --check,pre-commit run --all-files: passty check: 11 diagnostics on the baseline, 10 here — no new ones; the baseline'sinvalid-argument-typeonsortedwent away with the deleted shim helper. The remaining 10 are pre-existing (unresolvedanywidget/traitlets/IPython.displayimports in this environment, plus 3 long-standing annotation diagnostics).Measurement provenance
Before and after were rendered from two pinned trees with
PYTHONPATHandXY_NATIVE_LIBset, verifying in-process thatxy.__file__and the resolved dylib are the intended tree. This PR touches no Rust, so the control is that the native core is constant: both panels loaded dylib sha256552d955f…,src/hashes tode61616e…in both trees,git status src/ Cargo.*is empty, andsrc/font.rs(the measuring ruler) is3fbeb135…in both. The Python layout sources provably differ (_svg.py9ba42c59…→7b67342e…), so the pixel and geometry deltas are the Python change.Not verified
js/src/50_chartview.tsis unchanged, and no JS was built or typechecked:npm/nodeare unavailable here andpython/xy/static/does not exist. That is deliberate rather than a gap left open — the browser'sleft:10pxis a canvas-edge inset on a centered line box, which is correct once the gutter holds the box, and the arithmetic above shows it landing within 0.2 px of the new static placement. Shipping an unverifiable client change to chase that 0.2 px would trade a measured fix for an unmeasured risk. The residual browser divergence (an over-wide authoredpaddingkeeps the browser's title at the canvas edge while the static exporters keep it beside the ticks) is recorded inspec/api/styling.md.Spec
spec/api/styling.md— new Chrome gutters and the rotated y-axis title: the gutter formula, the floor-not-override contract, the box-to-baseline correction, and both deliberate asymmetries (right-side axes, live core charts)spec/matplotlib/compat.md— theylabelrow now states the measured reservation and the 0.4 em reference gapWhy this base
Branched from
agent/gallery-canvas-gutters(#256) rather thanmainso this can replace its shim-only_explicit_y_tick_gutterinstead of racing a second gutter mechanism against it. Its long-category case is still covered — by the shared measurement, asserted on emitted geometry rather than on apadding[3] >= 100threshold. Neither #240 nor #256 was rebased or force-pushed.Adjacent: #272 (
agent/fix-pyplot-frame-geometry, on #206) also editspython/xy/pyplot/_mplfig.pyand also pins a plot rect against "the gutters the renderers reserve outside the padding". Different functions — #272 owns_axes_rect/get_positionand the multi-panel grid; this owns_to_notebook_html's single-axes branch and the corelayout()reservation — but whichever lands second should re-check that the measured left floor and the reported axes rectangle still agree.