Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 62 additions & 0 deletions python/xy/_fontmetrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Advance widths of the core's embedded DejaVu Sans face.

@generated by scripts/gen_font.py — do not edit by hand.

`src/font.rs` bakes the same numbers for the Rust rasterizer that draws the
text; this module is how `_svg.layout()` can size an axis gutter from the ink
it is about to reserve for. DejaVu Sans is also Matplotlib's default face, so
an advance measured here is the advance Matplotlib would lay out.
"""

from __future__ import annotations

BASE_PX = 16
CELL_H = 19
ASCENT = 15
DESCENT = 4
_FIRST = 32

# fmt: off
# Advances at BASE_PX for the printable ASCII range, `_FIRST` first.
_ASCII: tuple[int, ...] = (
5, 6, 7, 13, 10, 15, 12, 4, 6, 6, 8, 13, 5, 6, 5, 5, 10, 10, 10, 10,
10, 10, 10, 10, 10, 10, 5, 5, 13, 13, 13, 8, 16, 11, 11, 11, 12, 10, 9, 12,
12, 5, 5, 10, 9, 14, 12, 13, 10, 13, 11, 10, 10, 12, 11, 16, 11, 10, 11, 6,
5, 6, 13, 8, 8, 10, 10, 9, 10, 10, 6, 10, 10, 4, 4, 9, 4, 16, 10, 10,
10, 10, 7, 8, 6, 10, 9, 13, 9, 9, 8, 10, 5, 10, 13,
)

# Advances at BASE_PX for the non-ASCII glyphs, keyed by codepoint.
_EXTRA: dict[int, int] = {
176: 8, 177: 13, 178: 6, 179: 6, 181: 10, 183: 5, 185: 6, 215: 13,
915: 9, 916: 11, 920: 13, 923: 11, 926: 10, 928: 12, 931: 10, 933: 10,
934: 13, 936: 13, 937: 12, 945: 11, 946: 10, 947: 9, 948: 10, 949: 9,
950: 9, 951: 10, 952: 10, 953: 5, 954: 9, 955: 9, 956: 10, 957: 9,
958: 9, 959: 10, 960: 10, 961: 10, 963: 10, 964: 10, 965: 9, 966: 11,
967: 9, 968: 11, 969: 13, 7522: 3, 7523: 4, 7524: 6, 7525: 7, 8211: 8,
8212: 16, 8216: 5, 8217: 5, 8220: 8, 8221: 8, 8230: 16, 8304: 6, 8305: 3,
8308: 6, 8309: 6, 8310: 6, 8311: 6, 8312: 6, 8313: 6, 8314: 8, 8315: 8,
8316: 8, 8317: 4, 8318: 4, 8319: 6, 8320: 6, 8321: 6, 8322: 6, 8323: 6,
8324: 6, 8325: 6, 8326: 6, 8327: 6, 8328: 6, 8329: 6, 8330: 8, 8331: 8,
8332: 8, 8333: 4, 8334: 4, 8336: 6, 8337: 7, 8338: 7, 8339: 7, 8341: 6,
8342: 7, 8343: 3, 8344: 10, 8345: 6, 8346: 7, 8347: 6, 8348: 5, 8592: 13,
8594: 13, 8706: 8, 8711: 11, 8712: 14, 8722: 13, 8723: 13, 8730: 10, 8733: 11,
8734: 13, 8747: 8, 8776: 13, 8800: 13, 8804: 13, 8805: 13,
}
# fmt: on

# Fallback advance for a codepoint the atlas has no glyph for: the
# rasterizer skips it entirely, so it contributes no ink and no advance.
_MISSING = 0


def advance(text: str, font_size: float) -> float:
"""Advance width in px of `text` rendered at `font_size`."""
units = 0
for char in text:
code = ord(char)
if _FIRST <= code < _FIRST + len(_ASCII):
units += _ASCII[code - _FIRST]
else:
units += _EXTRA.get(code, _MISSING)
return font_size * units / BASE_PX
140 changes: 135 additions & 5 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

import numpy as np

from . import _native, _paint, _png
from . import _fontmetrics, _native, _paint, _png
from ._arrowgeom import arrow_shapes as _arrow_shapes
from .config import DEFAULT_PALETTE

Expand Down Expand Up @@ -1121,6 +1121,117 @@ def _colorbar_right_axis_room(
return 0.0


# Canvas inset the browser's rotated y-title line box is centered on
# (`left:10px` / `plot-right+40px` in ChartView), and the smallest gap left
# between the canvas edge and the outermost axis ink when no title claims that
# inset. Antialiased leading glyphs must not land on the export boundary.
_Y_TITLE_INSET = 10.0
_AXIS_TEXT_EDGE_PAD = 4.0
# Floor on the y title's leading ink, for a title whose line box is taller than
# twice the inset. Deliberately below `_AXIS_TEXT_EDGE_PAD`: at ordinary sizes
# the inset itself governs, and matching ChartView's placement matters more than
# a rounder outer margin (a 13.89 px title inks from x=1.75 in both renderers).
_Y_TITLE_MIN_INK = 1.0
# Gap between the y title's ink and the nearest tick label's ink, as a fraction
# of the title's font size. Matplotlib leaves 5.6 px at its 13.89 px (10 pt at
# 100 dpi) default — measured with `Text.get_window_extent` on 3.11.1.
_Y_TITLE_TICK_GAP = 0.4


def _text_cell(font_size: float) -> tuple[float, float]:
"""(ascent, descent) in px of the core's DejaVu face at `font_size`."""
return (
font_size * _fontmetrics.ASCENT / _fontmetrics.BASE_PX,
font_size * _fontmetrics.DESCENT / _fontmetrics.BASE_PX,
)


def _y_title_baseline(axis: dict[str, Any], plot_right: float) -> Optional[float]:
"""Baseline x of a quarter-turned y-axis title, or None when it has none.

ChartView positions the title as a rotated DOM line box *centered* on a
fixed canvas inset; a static exporter emits a baseline. The two differ by
half a line box, which is why the SVG/PNG title used to sit one full ascent
further toward the canvas edge than the browser draws it — the same
box-to-baseline correction the x-axis title already makes with
`font_size * 0.82`. Titles at any other angle keep the raw inset.
"""
if not axis.get("label"):
return None
raw_position = axis.get("label_position")
position = raw_position if isinstance(raw_position, str) else "center"
if position.replace("-", "_").startswith("inside_"):
return None # drawn over the plot; it needs no gutter
style = axis.get("style") or {}
font_size = float(style.get("label_size", 12))
side = axis.get("side", "left")
angle = float(axis.get("label_angle", 90.0 if side == "right" else -90.0))
ascent, descent = _text_cell(font_size)
# Ink is [x - ascent, x + descent] at -90° and [x - descent, x + ascent] at
# +90°, so centering the cell on the inset shifts the baseline by half the
# ascent/descent asymmetry, away from the plot in both cases.
shift = (ascent - descent) / 2 if abs(abs(angle) - 90.0) < 0.5 else 0.0
offset = float(axis.get("label_offset", 0.0))
if side == "right":
return plot_right + 40.0 - shift + offset
# Clamp so an oversized title cannot be pushed off the canvas by the inset.
return max(_Y_TITLE_MIN_INK + ascent, _Y_TITLE_INSET + shift) - offset


def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, float]:
"""(offset from the spine, widest tick-label extent) for a y axis, in px.

Measured from the advance widths of the strings that will actually be drawn,
using the same DejaVu metrics the Rust rasterizer blits (`src/font.rs`) —
which is also Matplotlib's default face, so an advance measured here is the
advance Matplotlib lays out.
"""
if _axis_tick_label_strategy(axis) in {"none", "off"}:
return 0.0, 0.0
font_size = _axis_tick_font_size(axis)
ascent, descent = _text_cell(font_size)
raw_angle = axis.get("tick_label_angle")
angle = abs(float(raw_angle or 0.0)) * math.pi / 180.0
_values, labels, step = axis_ticks(axis, plot_h, False)
room = 0.0
for value in labels:
advance = _fontmetrics.advance(str(_tick_text(axis, value, step)), font_size)
# A rotated label trades width for height about its pinned edge.
room = max(room, advance * math.cos(angle) + (ascent + descent) * math.sin(angle))
return _axis_tick_label_offset(axis), room


def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float:
"""Left gutter the y-axis text needs, measured rather than assumed.

`layout()`'s fixed 46/62 px default fits ordinary numeric ticks under a
12 px title. Matplotlib's rcParam fonts (13.89 px at 100 dpi), long category
names, and authored tick labels all exceed it, and the shortfall lands as a
title drawn on top of the tick labels — or off the canvas — instead of as a
wider gutter.

Right-side y axes deliberately keep the flat 42/54 px reservation above:
ChartView pins a right title plot-relative (`plot-right+40`) rather than to
a canvas inset, so widening only the static exporters' right gutter would
move their title away from the browser's. That asymmetry is recorded in
`spec/api/styling.md`, not silently fixed here.
"""
room = 0.0
for axis_id, axis in _axes_by_id(spec).items():
if not axis_id.startswith("y") or axis.get("side", "left") == "right":
continue
tick_offset, tick_room = _y_tick_label_room(axis, plot_h)
baseline = _y_title_baseline(axis, 0.0)
if baseline is None:
room = max(room, _AXIS_TEXT_EDGE_PAD + tick_offset + tick_room)
continue
label_size = float((axis.get("style") or {}).get("label_size", 12))
_ascent, descent = _text_cell(label_size)
gap = _Y_TITLE_TICK_GAP * label_size if tick_room else 0.0
room = max(room, baseline + descent + gap + tick_offset + tick_room)
return room


def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]:
"""Concrete pixel dimensions + plot rect from a spec — shared by the SVG and
native-PNG exporters so their chrome/plot geometry stays identical."""
Expand Down Expand Up @@ -1176,6 +1287,14 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]:
# secondary-y tick labels/title. Multiple right axes intentionally
# overlay in both renderers until offset axes become part of the API.
right += 42 if compact else 54
# Measured y-axis text room, applied last. The vertical extent is already
# final (only top/bottom feed it), so the tick density the reservation
# measures is the density that will be drawn. This raises a *floor*: an
# authored `padding` and the 46/62 default both stand whenever they already
# fit, exactly as the colorbar/right-axis room above is additive rather
# than authoritative. Reserving less than the ink is not an option — a
# static export has no ellipsis to fall back on the way the DOM does.
left = max(left, _y_axis_left_room(spec, max(40, height - top - bottom)))
plot = {
"x": left,
"y": top,
Expand Down Expand Up @@ -1412,10 +1531,21 @@ def _axis_label_geometry(
text_anchor = "start" if anchor == "start" else "end" if anchor == "end" else "middle"
angle = float(axis.get("label_angle", 0.0))
else:
outside_x = plot["x"] + plot["w"] + 40 if side == "right" else 10
inside_x = plot["x"] + plot["w"] - 12 if side == "right" else plot["x"] + 12
x = inside_x if inside else outside_x
x += (-offset if inside else offset) if side == "right" else (offset if inside else -offset)
if inside:
inside_x = plot["x"] + plot["w"] - 12 if side == "right" else plot["x"] + 12
x = inside_x + (-offset if side == "right" else offset)
else:
# The rotated title's *line box* is centered on ChartView's inset
# (`left:10px` / `plot-right+40px`); a static exporter emits a
# baseline. `_y_title_baseline` applies that half-line-box
# correction and the axis's own `label_offset`, and is the same
# function `layout()` reserves the gutter from.
baseline = _y_title_baseline(axis, plot["x"] + plot["w"])
x = (
baseline
if baseline is not None
else (plot["x"] + plot["w"] + 40 + offset if side == "right" else 10 - offset)
)
y = plot["y"] + plot["h"] * (1.0 - anchor_fraction)
text_anchor = "middle"
angle = float(axis.get("label_angle", 90.0 if side == "right" else -90.0))
Expand Down
77 changes: 5 additions & 72 deletions python/xy/pyplot/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4751,16 +4751,11 @@ def _build_chart(self, width: int, height: int) -> Any:
self._apply_tickers("x", x_props, auto_tick_counts["x"])
self._apply_tickers("y", y_props, auto_tick_counts["y"])
self._apply_auto_tick_density(x_props, y_props, auto_tick_counts)
if self._padding is None and y_props.get("side", "left") != "right":
compact = width < 520
default_padding = [6.0, 8.0, 36.0, 46.0] if compact else [10.0, 14.0, 42.0, 62.0]
required_left = _explicit_y_tick_gutter(y_props, self._entries)
effective_padding = (
default_padding if chart_padding is None else list(map(float, chart_padding))
)
if required_left > effective_padding[3]:
effective_padding[3] = required_left
chart_padding = effective_padding
# The left gutter is no longer reserved here. `_svg.layout()` measures
# it from the axis's own tick/title extents once the range is resolved,
# which covers numeric ticks (this shim's 13.89 px rcParam fonts overrun
# the 62 px default) as well as the categorical labels this branch used
# to special-case, and applies to an authored `padding` too.
children.append(_cached_axis("x", x_props))
children.append(_cached_axis("y", y_props))
for index, secondary in enumerate(self._secondary_axes, 1):
Expand Down Expand Up @@ -5341,68 +5336,6 @@ def _plain_text(value: Any) -> str:
return text.replace("_{", "").replace("^{", "^").replace("}", "")


def _explicit_y_tick_gutter(axis: dict[str, Any], entries: list[dict[str, Any]]) -> float:
"""Estimate the left gutter for authored/category tick labels.

The core renderer's 62 px default fits ordinary numeric ticks and a y-axis
title, but Matplotlib category labels can be substantially wider. Pyplot
owns those strings before rendering, so reserve their measured-like width
once here and send the same explicit padding to browser, SVG, and PNG.
"""
if axis.get("tick_label_strategy") in {"none", "off"}:
return 0.0
labels = axis.get("tick_labels")
if labels is None:
labels = axis.get("categories")
if labels is None:
inferred: list[str] = []
for entry in entries:
kwargs = entry.get("kwargs") or {}
if entry.get("kind") == "bar" and kwargs.get("orientation") == "horizontal":
values = entry.get("x")
else:
values = entry.get("y")
if values is None:
continue
array = np.asarray(values).reshape(-1)
if array.dtype.kind not in {"U", "S", "O"}:
continue
for value in array:
if isinstance(value, str) and value not in inferred:
inferred.append(value)
labels = inferred
if not labels:
return 0.0
style = axis.get("style") or {}
font_size = float(style.get("tick_label_size", style.get("tick_size", 11.0)))
max_width = max(_approx_text_width(str(label), font_size) for label in labels)
tick_length = max(0.0, float(style.get("tick_length", 0.0)))
direction = str(style.get("tick_direction", "out"))
outward = 0.0 if direction == "in" else tick_length / 2 if direction == "inout" else tick_length
tick_pad = float(style.get("tick_padding", style.get("tick_label_pad", 4.0)))
axis_label_room = 0.0
if axis.get("label"):
label_size = float(style.get("label_size", font_size))
axis_label_room = label_size * 1.15 + 8.0
# Keep Matplotlib-like outer whitespace beyond the label ink; this is
# visible in the default SubplotParams frame and prevents antialiased
# leading glyphs from landing on the export boundary.
return max_width + outward + tick_pad + axis_label_room + 24.0


def _approx_text_width(text: str, font_size: float) -> float:
"""Conservative sans-serif text width without a renderer round-trip."""
units = 0.0
for char in text:
if char in " ilI.,'`|!:":
units += 0.28
elif char in "MW@#%&":
units += 0.9
else:
units += 0.56
return units * font_size


def _masked_float(value: Any) -> np.ndarray:
return np.ma.asarray(value, dtype=np.float64).filled(np.nan)

Expand Down
32 changes: 32 additions & 0 deletions python/xy/pyplot/_mplfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@
from ._translate import check_unsupported, not_implemented


def _measured_left_gutter(ax: Axes, width: int, height: int) -> float:
"""The left gutter `_svg.layout()` will reserve for `ax`'s y-axis text.

Probes the real spec because the reservation is measured from the tick
labels the resolved range produces, which only exist after the payload is
built. The probe costs one extra payload build on the notebook display path;
the browser needs the number *before* the chart it renders is built, and a
second implementation of the measurement is the thing this must not become.
"""
from .. import _svg

spec, _buffers = ax._build_chart(width, height).figure().build_payload_split()
return float(_svg.layout(spec)[3]["x"])


def _png_with_metadata(data: bytes, metadata: dict[Any, Any]) -> bytes:
"""Insert standards-compliant PNG text chunks before IEND."""
from xy import _png
Expand Down Expand Up @@ -1023,6 +1038,23 @@ def _to_notebook_html(self) -> tuple[str, int, int]:
)
tight_width = max(120, min(tight_width, round(aspect_width)))
ax._padding = notebook_padding
# Matplotlib's inline bbox is derived from the ink, so the y
# title can never land on the tick labels there — but pinning a
# padding also pins the browser's gutter, and 41 px does not
# hold this shim's 13.89 px tick labels under a rotated title.
# Ask the shared static-layout path what the axis text measures
# (`_svg.layout` is the single authority for that reservation,
# so browser, SVG, and PNG stay on one number) and, when the pin
# is short, widen the *canvas* by the shortfall so the plot box
# keeps Matplotlib's 0.775 fraction instead of losing width.
measured_left = _measured_left_gutter(ax, tight_width, tight_height)
if measured_left > notebook_padding[3]:
tight_width = max(
120, tight_width + int(round(measured_left - notebook_padding[3]))
)
notebook_padding[3] = measured_left
ax._chart = None
ax._padding = notebook_padding
doc = ax._build_chart(tight_width, tight_height).to_html()
finally:
ax._chart = old_chart
Expand Down
Loading
Loading