diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts
index c9fb3fc2..a5472402 100644
--- a/js/src/50_chartview.ts
+++ b/js/src/50_chartview.ts
@@ -4441,7 +4441,22 @@ export class ChartView {
d.style.boxSizing = "border-box";
}
this._applySlot(d, kind === "label" ? "axis_title" : "tick_label");
- this._applyStyle(d, extraStyle);
+ const axisLabelStyle = kind === "label" ? {
+ "font-family": this._axisStyleValue(axis, "label_font_family"),
+ "font-style": this._axisStyleValue(axis, "label_font_style"),
+ "font-weight": this._axisStyleValue(axis, "label_font_weight"),
+ } : null;
+ if (axisLabelStyle) {
+ for (const key of Object.keys(axisLabelStyle)) {
+ if (axisLabelStyle[key] === undefined) delete axisLabelStyle[key];
+ }
+ }
+ this._applyStyle(
+ d,
+ axisLabelStyle || extraStyle
+ ? { ...(axisLabelStyle || {}), ...(extraStyle || {}) }
+ : null,
+ );
this.labels.appendChild(d);
if (kind === "tick" && axis && axis.kind === "category" && yPlacement) {
// Rotation contributes half the untransformed label height to each
diff --git a/python/xy/_raster.py b/python/xy/_raster.py
index cfd02581..b026e446 100644
--- a/python/xy/_raster.py
+++ b/python/xy/_raster.py
@@ -935,8 +935,6 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]:
_parse_color(_css(axis_style.get("tick_color"), default_axis)),
)
- text_c = _parse_color(default_text)
-
def rotation_flag(angle: float) -> int:
normalized = angle % 360.0
if abs(normalized - 90.0) < 1e-9:
@@ -999,13 +997,22 @@ def emit_tick_labels(
_ticks, tick_labels, step = extra_y_ticks[axis_id]
emit_tick_labels(axis, tick_labels, step, axis_scale, is_x=False)
if spec.get("title"):
+ title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
+ title_italic, title_bold = _native_font_emphasis(
+ {
+ "font_style": title_style.get("font-style"),
+ "font_weight": title_style.get("font-weight", 600),
+ }
+ )
cmd.text(
width / 2,
plot["y"] - plot["top_axis_room"] - (10 if compact else 12),
1,
- 14,
- text_c,
+ _px_size(title_style.get("font-size"), 14.0),
+ _parse_color(_css(title_style.get("color"), default_text)),
str(spec["title"]),
+ italic=title_italic,
+ bold=title_bold,
)
def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
@@ -1014,14 +1021,29 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
axis_style = axis.get("style") or {}
geometry = _axis_label_geometry(axis, plot, is_x=is_x)
anchor = {"start": 0, "middle": 1, "end": 2}[geometry["anchor"]]
- cmd.text(
+ italic, bold = _native_font_emphasis(
+ {
+ "font_style": axis_style.get("label_font_style"),
+ "font_weight": axis_style.get("label_font_weight", 500),
+ }
+ )
+ args = (
geometry["x"],
geometry["y"],
- anchor | rotation_flag(float(geometry["angle"])),
+ anchor,
geometry["font_size"],
_parse_color(_css(axis_style.get("label_color"), default_text)),
str(axis["label"]),
)
+ if italic or bold:
+ cmd.text(*args, angle=float(geometry["angle"]), italic=italic, bold=bold)
+ else:
+ cmd.text(
+ args[0],
+ args[1],
+ anchor | rotation_flag(float(geometry["angle"])),
+ *args[3:],
+ )
emit_axis_title(xa, is_x=True)
emit_axis_title(ya, is_x=False)
diff --git a/python/xy/_svg.py b/python/xy/_svg.py
index 187cee80..fe9d6ae5 100644
--- a/python/xy/_svg.py
+++ b/python/xy/_svg.py
@@ -1623,11 +1623,23 @@ def line_attrs(style: dict[str, Any], color: str) -> str:
# -- chrome text ----------------------------------------------------------
chrome: list[str] = []
if spec.get("title"):
+ title_style = ((spec.get("dom") or {}).get("styles") or {}).get("title") or {}
+ title_size = _px_size(title_style.get("font-size"), 14.0)
+ title_weight = title_style.get("font-weight", 600)
+ title_family = title_style.get("font-family")
+ title_font_style = title_style.get("font-style")
+ title_font_attrs = (
+ f' font-family="{escape(str(title_family))}"' if title_family is not None else ""
+ ) + (
+ f' font-style="{escape(str(title_font_style))}"' if title_font_style is not None else ""
+ )
chrome.append(
f'{escape(str(spec["title"]))}'
+ f'text-anchor="middle" font-size="{_num(title_size)}" '
+ f'font-weight="{escape(str(title_weight))}"{title_font_attrs} '
+ f'fill="{escape(_css(title_style.get("color"), default_text))}">'
+ f"{escape(str(spec['title']))}"
)
def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
@@ -1638,9 +1650,15 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None:
x, y = float(geometry["x"]), float(geometry["y"])
angle = float(geometry["angle"])
transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else ""
+ family = axis_style.get("label_font_family")
+ font_style = axis_style.get("label_font_style")
+ font_attrs = (f' font-family="{escape(str(family))}"' if family is not None else "") + (
+ f' font-style="{escape(str(font_style))}"' if font_style is not None else ""
+ )
chrome.append(
f''
f"{escape(str(axis['label']))}"
)
diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py
index 1fd870d6..549fe429 100644
--- a/python/xy/pyplot/_axes.py
+++ b/python/xy/pyplot/_axes.py
@@ -38,7 +38,7 @@
)
from ._colors import PROP_CYCLE, resolve_cmap, resolve_color, resolve_rgba_array, scalar_float
from ._fmt import parse_fmt
-from ._mathtext import mathtext_to_unicode
+from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode
from ._plot_types import PlotTypeMixin
from ._rc import RcParams, rcParams
from ._ticker import AutoLocator, Locator, NullLocator, ScalarFormatter, as_formatter
@@ -526,6 +526,7 @@ def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None:
self._containers: list[Any] = []
self._axis: dict[str, dict[str, Any]] = {"x": {}, "y": {}, "y2": {}}
self._title: Optional[str] = None
+ self._title_style: dict[str, Any] = {}
self._legend = False
self._legend_options: dict[str, Any] = {}
self._extra_legends: list[Any] = []
@@ -814,6 +815,7 @@ def clear(self) -> None:
self._tickers = {}
self._hidden_spines = set()
self._title = None
+ self._title_style = {}
self._legend = False
self._legend_options = {}
self._extra_legends = []
@@ -2462,7 +2464,7 @@ def set_xlabel(self, label: str, **kwargs: Any) -> None:
"""
props = self._axis_props("x")
props["label"] = _plain_text(label)
- _apply_axis_label_kwargs(props, kwargs, "set_xlabel()")
+ _apply_axis_label_kwargs(props, kwargs, "set_xlabel()", point_scale=self._point_scale())
self._invalidate()
def set_ylabel(self, label: str, **kwargs: Any) -> None:
@@ -2472,7 +2474,7 @@ def set_ylabel(self, label: str, **kwargs: Any) -> None:
"""
props = self._axis_props("y")
props["label"] = _plain_text(label)
- _apply_axis_label_kwargs(props, kwargs, "set_ylabel()")
+ _apply_axis_label_kwargs(props, kwargs, "set_ylabel()", point_scale=self._point_scale())
self._invalidate()
def set_title(self, title: str, **kwargs: Any) -> None:
@@ -2482,8 +2484,11 @@ def set_title(self, title: str, **kwargs: Any) -> None:
are accepted as compatibility inputs; anything else raises loudly.
Basic mathtext (``$...$``) is rendered.
"""
- _consume_text_kwargs(kwargs, "set_title()")
host = self._y2_of or self
+ host._title_style = _title_css_style(
+ _pop_text_style_kwargs(kwargs, "set_title()"),
+ point_scale=self._point_scale(),
+ )
host._title = _plain_text(title)
host._invalidate()
@@ -4705,13 +4710,19 @@ def _build_chart(self, width: int, height: int) -> Any:
tokens = dict(theme_tokens)
tokens["grid_color"] = self._grid_color if self._grid else "transparent"
children.append(xy.theme(style=self._theme_style, **tokens))
+ chrome_styles = self._chrome_styles
+ if self._title_style:
+ chrome_styles = {
+ **chrome_styles,
+ "title": {**chrome_styles.get("title", {}), **self._title_style},
+ }
self._chart = xy.chart(
*children,
title=self._title,
width=width,
height=height,
padding=chart_padding,
- styles=self._chrome_styles,
+ styles=chrome_styles,
)
core_figure = self._chart.figure()
core_figure.frame_sides = [
@@ -5155,10 +5166,27 @@ def _validate_margin(value: Any, axis: str) -> float:
return margin
-def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], context: str) -> None:
+def _apply_axis_label_kwargs(
+ props: dict[str, Any],
+ kwargs: dict[str, Any],
+ context: str,
+ *,
+ point_scale: float,
+) -> None:
labelpad = kwargs.pop("labelpad", None)
loc = kwargs.pop("loc", None)
- _consume_text_kwargs(kwargs, context)
+ text_style = _pop_text_style_kwargs(kwargs, context)
+ axis_style = props.setdefault("style", {})
+ for source, target in (
+ ("color", "label_color"),
+ ("font_size", "label_size"),
+ ("font_family", "label_font_family"),
+ ("font_weight", "label_font_weight"),
+ ("font_style", "label_font_style"),
+ ):
+ if source in text_style:
+ value = text_style[source]
+ axis_style[target] = float(value) * point_scale if source == "font_size" else value
if labelpad is not None:
props["label_offset"] = float(labelpad)
if loc is not None:
@@ -5174,21 +5202,28 @@ def _apply_axis_label_kwargs(props: dict[str, Any], kwargs: dict[str, Any], cont
props["label_position"] = positions[loc]
-def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None:
- # Accepted for Matplotlib-flavoured scripts. The native engine currently
- # inherits font styling from the chart theme, so these kwargs are validated
- # and retained as compatibility inputs rather than silently acting on data.
+def _pop_text_style_kwargs(kwargs: dict[str, Any], context: str) -> dict[str, Any]:
+ """Consume Matplotlib text kwargs and return renderer-backed font style."""
+ fontdict = kwargs.pop("fontdict", None)
+ if fontdict is not None:
+ if not isinstance(fontdict, Mapping):
+ raise TypeError(f"{context} fontdict must be a mapping or None")
+ kwargs = {**fontdict, **kwargs}
+
+ def pop_alias(primary: str, *aliases: str) -> Any:
+ value = kwargs.pop(primary, None)
+ for alias in aliases:
+ alias_value = kwargs.pop(alias, None)
+ if value is None:
+ value = alias_value
+ return value
+
+ size = pop_alias("fontsize", "size")
+ weight = pop_alias("fontweight", "weight")
+ family = pop_alias("fontfamily", "family")
+ font_style = pop_alias("fontstyle", "style")
+ color = kwargs.pop("color", None)
for key in (
- "fontsize",
- "size",
- "fontdict",
- "fontweight",
- "weight",
- "fontstyle",
- "style",
- "fontfamily",
- "family",
- "color",
"horizontalalignment",
"ha",
"verticalalignment",
@@ -5203,6 +5238,38 @@ def _consume_text_kwargs(kwargs: dict[str, Any], context: str) -> None:
if kwargs:
raise TypeError(f"{context} got unsupported keyword argument {next(iter(kwargs))!r}")
+ style: dict[str, Any] = {}
+ if size is not None:
+ style["font_size"] = _font_size_points(size, rcParams["font.size"])
+ if weight is not None:
+ style["font_weight"] = str(weight)
+ if family is not None:
+ style["font_family"] = str(family)
+ if font_style is not None:
+ font_style = str(font_style)
+ if font_style not in {"normal", "italic", "oblique"}:
+ raise ValueError(f"{context} style must be 'normal', 'italic', or 'oblique'")
+ style["font_style"] = font_style
+ if color is not None:
+ style["color"] = resolve_color(color)
+ return style
+
+
+def _title_css_style(style: dict[str, Any], *, point_scale: float) -> dict[str, Any]:
+ """Translate renderer text style keys to the chart title's CSS slot."""
+ result: dict[str, Any] = {}
+ for source, target in (
+ ("color", "color"),
+ ("font_family", "font-family"),
+ ("font_weight", "font-weight"),
+ ("font_style", "font-style"),
+ ):
+ if source in style:
+ result[target] = style[source]
+ if "font_size" in style:
+ result["font-size"] = f"{float(style['font_size']) * point_scale:g}px"
+ return result
+
def _tick_label_visibility(kwargs: dict[str, Any]) -> Optional[bool]:
values = []
@@ -5257,39 +5324,10 @@ def _plain_text(value: Any) -> str:
def _plain_text_with_math_italic_ranges(value: Any) -> tuple[str, list[tuple[int, int]]]:
"""Flatten simple mathtext while retaining its default italic spans."""
- source = str(value)
- parts: list[str] = []
- ranges: list[tuple[int, int]] = []
- cursor = 0
- output_length = 0
- while cursor < len(source):
- start = source.find("$", cursor)
- if start < 0:
- tail = _plain_text(source[cursor:])
- parts.append(tail)
- break
- prefix = _plain_text(source[cursor:start])
- parts.append(prefix)
- output_length += len(prefix)
- end = source.find("$", start + 1)
- if end < 0:
- tail = _plain_text(source[start:])
- parts.append(tail)
- break
- math = _plain_text(source[start : end + 1])
- parts.append(math)
- range_start: Optional[int] = None
- for index, character in enumerate(math):
- if character.isalpha() and range_start is None:
- range_start = output_length + index
- elif not character.isalpha() and range_start is not None:
- ranges.append((range_start, output_length + index))
- range_start = None
- if range_start is not None:
- ranges.append((range_start, output_length + len(math)))
- output_length += len(math)
- cursor = end + 1
- return "".join(parts), ranges
+ converted, ranges = mathtext_italic_ranges(str(value))
+ if converted != str(value):
+ return converted, ranges
+ return _plain_text(value), []
def _masked_float(value: Any) -> np.ndarray:
diff --git a/python/xy/pyplot/_mathtext.py b/python/xy/pyplot/_mathtext.py
index 50d618c1..195020a1 100644
--- a/python/xy/pyplot/_mathtext.py
+++ b/python/xy/pyplot/_mathtext.py
@@ -73,12 +73,50 @@
"in": "∈",
"percent": "%",
"%": "%",
- ",": " ",
- ";": " ",
- " ": " ",
+ # TeX ignores ordinary spaces in math mode. Explicit spacing commands
+ # survive through a sentinel until that whitespace has been removed.
+ ",": "\x01",
+ ";": "\x01",
+ " ": "\x01",
"!": "",
+ # Matplotlib's named functions are upright roman glyph runs, not unknown
+ # TeX commands and not italic variables.
+ "arccos": "arccos",
+ "arcsin": "arcsin",
+ "arctan": "arctan",
+ "arg": "arg",
+ "cos": "cos",
+ "cosh": "cosh",
+ "cot": "cot",
+ "csc": "csc",
+ "deg": "deg",
+ "det": "det",
+ "dim": "dim",
+ "exp": "exp",
+ "gcd": "gcd",
+ "hom": "hom",
+ "ker": "ker",
+ "lg": "lg",
+ "lim": "lim",
+ "liminf": "liminf",
+ "limsup": "limsup",
+ "ln": "ln",
+ "log": "log",
+ "max": "max",
+ "min": "min",
+ "sec": "sec",
+ "sin": "sin",
+ "sinh": "sinh",
+ "sup": "sup",
+ "tan": "tan",
}
+_UPRIGHT_WORDS = frozenset(
+ value
+ for name, value in _COMMANDS.items()
+ if name.isalpha() and value.isascii() and value.isalpha()
+)
+
# Wrappers whose braces disappear and whose contents pass through.
_WRAPPERS = ("mathdefault", "mathrm", "mathit", "mathbf", "text", "textrm", "operatorname")
@@ -130,7 +168,10 @@ def command(match: re.Match[str]) -> str:
out = _COMMAND.sub(command, out)
if "\x00" in out or "\\" in out:
return None
- return out.replace("{", "").replace("}", "")
+ # Unescaped spaces are insignificant in math mode. Match Matplotlib's
+ # Unicode minus while retaining deliberate ``\,``/``\;``/``\ `` gaps.
+ out = re.sub(r"\s+", "", out)
+ return out.replace("-", "−").replace("\x01", " ").replace("{", "").replace("}", "")
def mathtext_to_unicode(text: str) -> str:
@@ -148,3 +189,40 @@ def mathtext_to_unicode(text: str) -> str:
last = match.end()
pieces.append(text[last:])
return "".join(pieces)
+
+
+def mathtext_italic_ranges(text: str) -> tuple[str, list[tuple[int, int]]]:
+ r"""Flatten mathtext and return output ranges that use math italics.
+
+ Matplotlib defaults variables (including Greek letters) to italics while
+ named functions such as ``\cos`` and ``\exp`` remain upright. The
+ renderer-facing range list preserves that distinction without exposing a
+ full TeX layout engine.
+ """
+ converted = mathtext_to_unicode(text)
+ if converted == text or "$" not in text:
+ return converted, []
+
+ ranges: list[tuple[int, int]] = []
+ output_cursor = 0
+ source_cursor = 0
+ for match in _MATH_SPAN.finditer(text):
+ prefix = text[source_cursor : match.start()]
+ output_cursor += len(prefix)
+ body = _convert_math(match.group(1))
+ if body is None: # guarded by ``converted == text`` above
+ return converted, []
+ token_start: int | None = None
+ for index, character in enumerate(body + "\0"):
+ if character.isalpha():
+ if token_start is None:
+ token_start = index
+ continue
+ if token_start is not None:
+ token = body[token_start:index]
+ if token not in _UPRIGHT_WORDS:
+ ranges.append((output_cursor + token_start, output_cursor + index))
+ token_start = None
+ output_cursor += len(body)
+ source_cursor = match.end()
+ return converted, ranges
diff --git a/python/xy/styles.py b/python/xy/styles.py
index c24a1515..e2abc581 100644
--- a/python/xy/styles.py
+++ b/python/xy/styles.py
@@ -42,6 +42,7 @@
)
_AXIS_LENGTH_PROPERTIES = frozenset({"grid_width", "axis_width", "tick_length", "tick_width"})
_AXIS_SIZE_PROPERTIES = frozenset({"tick_size", "tick_label_size", "label_size"})
+_AXIS_FONT_PROPERTIES = frozenset({"label_font_family", "label_font_style", "label_font_weight"})
_AXIS_COMPAT_PROPERTIES = frozenset({"grid_dash", "grid_opacity"})
_AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"})
_AXIS_DIRECTIONS = frozenset({"in", "out", "inout"})
@@ -329,6 +330,7 @@ def _compile_axis_style(
_AXIS_COLOR_PROPERTIES
| _AXIS_LENGTH_PROPERTIES
| _AXIS_SIZE_PROPERTIES
+ | _AXIS_FONT_PROPERTIES
| _AXIS_COMPAT_PROPERTIES
| {"tick_direction", "tick_label_anchor"}
)
@@ -345,6 +347,14 @@ def _compile_axis_style(
parsed = _px(raw, f"{label}[{css_prop!r}]")
elif prop in _AXIS_SIZE_PROPERTIES:
parsed = _px(raw, f"{label}[{css_prop!r}]", positive=True)
+ elif prop == "label_font_style":
+ if not isinstance(raw, str) or raw not in {"normal", "italic", "oblique"}:
+ raise ValueError(f"{label}[{css_prop!r}] must be 'normal', 'italic', or 'oblique'")
+ parsed = raw
+ elif prop in {"label_font_family", "label_font_weight"}:
+ if not isinstance(raw, (str, int, float)) or isinstance(raw, bool):
+ raise ValueError(f"{label}[{css_prop!r}] must be a CSS font value")
+ parsed = raw
elif prop == "grid_opacity":
parsed = _opacity(raw, f"{label}[{css_prop!r}]")
elif prop == "grid_dash":
diff --git a/scripts/gen_font.py b/scripts/gen_font.py
index 9c29a2c6..35dda257 100644
--- a/scripts/gen_font.py
+++ b/scripts/gen_font.py
@@ -22,9 +22,11 @@
# Printable ASCII. Axis labels/titles/legends mostly stay within this set.
FIRST, LAST = 0x20, 0x7E
# Extra codepoints for the pyplot shim's TeX-subset → unicode conversion
-# (greek, super/subscripts, math operators) plus typography mpl emits (−, µ).
+# (greek, super/subscripts, math operators), typography mpl emits (−, µ), and
+# the precomposed umlauts used by Matplotlib's canonical text_commands example.
EXTRA = sorted(
set(
+ "öü"
"αβγδεζηθικλμνξοπρστυφχψω"
"ΓΔΘΛΞΠΣΥΦΨΩ"
"⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ"
@@ -33,6 +35,9 @@
)
)
PX = 16 # render size; runtime scales this coverage bitmap to the requested size.
+# Keep the two historical atlas advances stable across Matplotlib's bundled
+# DejaVu Sans revisions; changing them would move unrelated existing labels.
+STABLE_ADVANCES = {"?": 8, "K": 10}
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "src" / "font.rs"
@@ -54,7 +59,7 @@ def main() -> None:
for code in codepoints:
ch = chr(code)
# Advance width (horizontal pen movement).
- advance = round(face.getlength(ch))
+ advance = STABLE_ADVANCES.get(ch, round(face.getlength(ch)))
box = face.getbbox(ch) # (left, top, right, bottom) in the ascent-origin frame
if box is None or box[2] <= box[0] or box[3] <= box[1]:
glyphs.append((advance, 0, 0, 0, 0, b""))
diff --git a/spec/api/styling.md b/spec/api/styling.md
index e9785377..3504641f 100644
--- a/spec/api/styling.md
+++ b/spec/api/styling.md
@@ -582,8 +582,8 @@ so small labels are slightly less refined than a browser's.
**Native text coverage.** The atlas is a generated grayscale DejaVu Sans sheet
(`src/font.rs`, regenerated by `scripts/gen_font.py`) baked at a 16 px base
cell and blitted and scaled at runtime, so glyph coverage is a fixed set: ASCII
-32–126 plus 110 enumerated extras (Greek, common math operators, arrows,
-super/subscripts, typographic quotes and dashes). A codepoint outside that set
+32–126 plus 112 enumerated extras (Greek, common math operators, arrows,
+super/subscripts, typographic quotes and dashes, and `ö`/`ü`). A codepoint outside that set
— CJK, Cyrillic, Arabic, emoji, most accented Latin — has no glyph and is
**silently skipped**, with no tofu box and no advance reserved, so a title,
tick label, legend entry, or annotation containing one renders shortened rather
diff --git a/spec/design/rust-engine.md b/spec/design/rust-engine.md
index b8a2e3b5..8905d114 100644
--- a/spec/design/rust-engine.md
+++ b/spec/design/rust-engine.md
@@ -127,13 +127,14 @@ argminmax, tsdownsample-class speed) with the lean build as default.
### 2.1 Native text is a bounded subset, and misses are silent
Declining FreeType bought the single-cdylib property (§3.1) and paid for it in
-Unicode coverage. `font.rs` bakes exactly 205 glyphs: ASCII 32–126 (95) plus
-the 110 codepoints enumerated in `font::EXTRA_CODEPOINTS` — lowercase Greek
+Unicode coverage. `font.rs` bakes exactly 207 glyphs: ASCII 32–126 (95) plus
+the 112 codepoints enumerated in `font::EXTRA_CODEPOINTS` — lowercase Greek
(α–ω) and the eleven uppercase Greek letters that differ from Latin forms
(Γ Δ Θ Λ Ξ Π Σ Υ Φ Ψ Ω), math operators (`∂ ∇ ∈ − ∓ √ ∝ ∞ ∫ ≈ ≠ ≤ ≥`), the
left and right arrows only, super/subscript digits and a handful of subscript
-letters, typographic quotes, en/em dashes, and a few symbols (`° ± × · µ ²³¹ …`).
-Nothing else exists: no accented Latin at all, no Cyrillic, no CJK, no Arabic,
+letters, typographic quotes, en/em dashes, the `ö`/`ü` glyphs used by
+Matplotlib's canonical text example, and a few symbols (`° ± × · µ ²³¹ …`).
+Nothing else exists: no other accented Latin, no Cyrillic, no CJK, no Arabic,
no emoji.
The failure mode is worse than the coverage gap. `glyph_index`
@@ -141,8 +142,8 @@ The failure mode is worse than the coverage gap. `glyph_index`
loop's `let … else { continue; }` (`src/raster.rs:1236-1238`) drops that
character **before** the advance is applied — so the glyph is deleted, not
substituted, and the following glyphs close up over the hole. There is no tofu
-box, no fallback glyph, no warning, and no error. `"Müller"` rasterizes as
-`"Mller"`; a fully non-Latin label rasterizes as nothing. The anchoring pass
+box, no fallback glyph, no warning, and no error. `"café"` rasterizes as
+`"caf"`; a fully non-Latin label rasterizes as nothing. The anchoring pass
sums advances through the same `glyph_index` filter (`src/raster.rs:1200-1204`),
so a centered or right-anchored label is positioned on its *shortened* width —
the loss is self-consistent and therefore invisible in the output.
diff --git a/src/font.rs b/src/font.rs
index 18d05564..c3868d9f 100644
--- a/src/font.rs
+++ b/src/font.rs
@@ -10,15 +10,15 @@ pub const CELL_H: i32 = 19;
pub const ASCENT: i32 = 15;
/// Codepoints of the non-ASCII glyphs, sorted; GLYPHS row = 95 + index.
-pub static EXTRA_CODEPOINTS: [u32; 110] = [
- 176, 177, 178, 179, 181, 183, 185, 215, 915, 916, 920, 923, 926, 928, 931, 933, 934, 936, 937, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 963, 964, 965, 966, 967, 968, 969, 7522, 7523, 7524, 7525, 8211, 8212, 8216, 8217, 8220, 8221, 8230, 8304, 8305, 8308, 8309, 8310, 8311, 8312, 8313, 8314, 8315, 8316, 8317, 8318, 8319, 8320, 8321, 8322, 8323, 8324, 8325, 8326, 8327, 8328, 8329, 8330, 8331, 8332, 8333, 8334, 8336, 8337, 8338, 8339, 8341, 8342, 8343, 8344, 8345, 8346, 8347, 8348, 8592, 8594, 8706, 8711, 8712, 8722, 8723, 8730, 8733, 8734, 8747, 8776, 8800, 8804, 8805,
+pub static EXTRA_CODEPOINTS: [u32; 112] = [
+ 176, 177, 178, 179, 181, 183, 185, 215, 246, 252, 915, 916, 920, 923, 926, 928, 931, 933, 934, 936, 937, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 963, 964, 965, 966, 967, 968, 969, 7522, 7523, 7524, 7525, 8211, 8212, 8216, 8217, 8220, 8221, 8230, 8304, 8305, 8308, 8309, 8310, 8311, 8312, 8313, 8314, 8315, 8316, 8317, 8318, 8319, 8320, 8321, 8322, 8323, 8324, 8325, 8326, 8327, 8328, 8329, 8330, 8331, 8332, 8333, 8334, 8336, 8337, 8338, 8339, 8341, 8342, 8343, 8344, 8345, 8346, 8347, 8348, 8592, 8594, 8706, 8711, 8712, 8722, 8723, 8730, 8733, 8734, 8747, 8776, 8800, 8804, 8805,
];
/// Per-glyph metrics at BASE_PX: (advance, w, h, left, top, cov_off, cov_len).
/// `top` is the pixel offset of the glyph's top edge below the baseline
/// (negative = above). Coverage bytes live in `COVERAGE[cov_off..][..cov_len]`,
/// row-major w*h, 0..=255.
-pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 205] = [
+pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 207] = [
(5, 0, 0, 0, 0, 0, 0),
(6, 6, 12, 0, -12, 0, 72),
(7, 7, 12, 0, -12, 72, 84),
@@ -122,111 +122,113 @@ pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); 205] = [
(5, 5, 7, 0, -7, 10666, 35),
(6, 6, 12, 0, -12, 10701, 72),
(13, 13, 10, 0, -10, 10773, 130),
- (9, 9, 12, 0, -12, 10903, 108),
- (11, 11, 12, 0, -12, 11011, 132),
- (13, 13, 12, 0, -12, 11143, 156),
- (11, 11, 12, 0, -12, 11299, 132),
- (10, 10, 12, 0, -12, 11431, 120),
- (12, 12, 12, 0, -12, 11551, 144),
- (10, 10, 12, 0, -12, 11695, 120),
- (10, 11, 12, -1, -12, 11815, 132),
- (13, 13, 12, 0, -12, 11947, 156),
- (13, 13, 12, 0, -12, 12103, 156),
- (12, 12, 12, 0, -12, 12259, 144),
- (11, 11, 9, 0, -9, 12403, 99),
- (10, 10, 16, 0, -13, 12502, 160),
- (9, 9, 12, 0, -9, 12662, 108),
- (10, 10, 12, 0, -12, 12770, 120),
- (9, 9, 9, 0, -9, 12890, 81),
- (9, 9, 15, 0, -12, 12971, 135),
- (10, 10, 12, 0, -9, 13106, 120),
- (10, 10, 12, 0, -12, 13226, 120),
- (5, 5, 9, 0, -9, 13346, 45),
- (9, 10, 9, 0, -9, 13391, 90),
- (9, 9, 12, 0, -12, 13481, 108),
- (10, 10, 12, 0, -9, 13589, 120),
- (9, 9, 9, 0, -9, 13709, 81),
- (9, 9, 15, 0, -12, 13790, 135),
- (10, 10, 9, 0, -9, 13925, 90),
- (10, 10, 9, 0, -9, 14015, 90),
- (10, 10, 12, 0, -9, 14105, 120),
- (10, 10, 9, 0, -9, 14225, 90),
- (10, 10, 9, 0, -9, 14315, 90),
- (9, 9, 9, 0, -9, 14405, 81),
- (11, 11, 12, 0, -9, 14486, 132),
- (9, 9, 12, 0, -9, 14618, 108),
+ (10, 10, 12, 0, -12, 10903, 120),
+ (10, 10, 12, 0, -12, 11023, 120),
+ (9, 9, 12, 0, -12, 11143, 108),
+ (11, 11, 12, 0, -12, 11251, 132),
+ (13, 13, 12, 0, -12, 11383, 156),
+ (11, 11, 12, 0, -12, 11539, 132),
+ (10, 10, 12, 0, -12, 11671, 120),
+ (12, 12, 12, 0, -12, 11791, 144),
+ (10, 10, 12, 0, -12, 11935, 120),
+ (10, 11, 12, -1, -12, 12055, 132),
+ (13, 13, 12, 0, -12, 12187, 156),
+ (13, 13, 12, 0, -12, 12343, 156),
+ (12, 12, 12, 0, -12, 12499, 144),
+ (11, 11, 9, 0, -9, 12643, 99),
+ (10, 10, 16, 0, -13, 12742, 160),
+ (9, 9, 12, 0, -9, 12902, 108),
+ (10, 10, 12, 0, -12, 13010, 120),
+ (9, 9, 9, 0, -9, 13130, 81),
+ (9, 9, 15, 0, -12, 13211, 135),
+ (10, 10, 12, 0, -9, 13346, 120),
+ (10, 10, 12, 0, -12, 13466, 120),
+ (5, 5, 9, 0, -9, 13586, 45),
+ (9, 10, 9, 0, -9, 13631, 90),
+ (9, 9, 12, 0, -12, 13721, 108),
+ (10, 10, 12, 0, -9, 13829, 120),
+ (9, 9, 9, 0, -9, 13949, 81),
+ (9, 9, 15, 0, -12, 14030, 135),
+ (10, 10, 9, 0, -9, 14165, 90),
+ (10, 10, 9, 0, -9, 14255, 90),
+ (10, 10, 12, 0, -9, 14345, 120),
+ (10, 10, 9, 0, -9, 14465, 90),
+ (10, 10, 9, 0, -9, 14555, 90),
+ (9, 9, 9, 0, -9, 14645, 81),
(11, 11, 12, 0, -9, 14726, 132),
- (13, 13, 9, 0, -9, 14858, 117),
- (3, 3, 7, 0, -7, 14975, 21),
- (4, 5, 6, 0, -6, 14996, 30),
- (6, 6, 6, 0, -6, 15026, 36),
- (7, 7, 6, 0, -6, 15062, 42),
- (8, 8, 5, 0, -5, 15104, 40),
- (16, 16, 5, 0, -5, 15144, 80),
- (5, 5, 12, 0, -12, 15224, 60),
- (5, 5, 12, 0, -12, 15284, 60),
- (8, 8, 12, 0, -12, 15344, 96),
- (8, 8, 12, 0, -12, 15440, 96),
- (16, 16, 2, 0, -2, 15536, 32),
- (6, 6, 12, 0, -12, 15568, 72),
- (3, 3, 12, 0, -12, 15640, 36),
- (6, 6, 12, 0, -12, 15676, 72),
- (6, 6, 12, 0, -12, 15748, 72),
- (6, 6, 12, 0, -12, 15820, 72),
- (6, 6, 12, 0, -12, 15892, 72),
- (6, 6, 12, 0, -12, 15964, 72),
- (6, 6, 12, 0, -12, 16036, 72),
- (8, 8, 11, 0, -11, 16108, 88),
- (8, 8, 9, 0, -9, 16196, 72),
- (8, 8, 10, 0, -10, 16268, 80),
- (4, 4, 13, 0, -13, 16348, 52),
- (4, 4, 13, 0, -13, 16400, 52),
- (6, 6, 10, 0, -10, 16452, 60),
- (6, 6, 7, 0, -7, 16512, 42),
- (6, 6, 7, 0, -7, 16554, 42),
- (6, 6, 7, 0, -7, 16596, 42),
- (6, 6, 7, 0, -7, 16638, 42),
- (6, 6, 7, 0, -7, 16680, 42),
- (6, 6, 7, 0, -7, 16722, 42),
- (6, 6, 7, 0, -7, 16764, 42),
- (6, 6, 7, 0, -7, 16806, 42),
- (6, 6, 7, 0, -7, 16848, 42),
- (6, 6, 7, 0, -7, 16890, 42),
- (8, 8, 6, 0, -6, 16932, 48),
- (8, 8, 4, 0, -4, 16980, 32),
- (8, 8, 5, 0, -5, 17012, 40),
- (4, 4, 9, 0, -8, 17052, 36),
- (4, 4, 9, 0, -8, 17088, 36),
- (6, 6, 6, 0, -6, 17124, 36),
- (7, 7, 6, 0, -6, 17160, 42),
- (7, 7, 5, 0, -5, 17202, 35),
- (7, 7, 6, 0, -6, 17237, 42),
- (6, 6, 7, 0, -7, 17279, 42),
- (7, 7, 8, 0, -8, 17321, 56),
- (3, 3, 7, 0, -7, 17377, 21),
- (10, 10, 5, 0, -5, 17398, 50),
- (6, 6, 5, 0, -5, 17448, 30),
- (7, 7, 8, 0, -6, 17478, 56),
- (6, 6, 6, 0, -6, 17534, 36),
- (5, 5, 7, 0, -7, 17570, 35),
- (13, 13, 8, 0, -8, 17605, 104),
- (13, 13, 8, 0, -8, 17709, 104),
- (8, 8, 12, 0, -11, 17813, 96),
- (11, 12, 12, -1, -12, 17909, 144),
- (14, 14, 13, 0, -12, 18053, 182),
- (13, 13, 5, 0, -5, 18235, 65),
- (13, 13, 11, 0, -11, 18300, 143),
- (10, 11, 13, 0, -13, 18443, 143),
- (11, 11, 8, 0, -8, 18586, 88),
- (13, 13, 8, 0, -8, 18674, 104),
- (8, 8, 17, 0, -13, 18778, 136),
- (13, 13, 9, 0, -9, 18914, 117),
- (13, 13, 9, 0, -9, 19031, 117),
- (13, 13, 9, 0, -9, 19148, 117),
- (13, 13, 9, 0, -9, 19265, 117),
+ (9, 9, 12, 0, -9, 14858, 108),
+ (11, 11, 12, 0, -9, 14966, 132),
+ (13, 13, 9, 0, -9, 15098, 117),
+ (3, 3, 7, 0, -7, 15215, 21),
+ (4, 5, 6, 0, -6, 15236, 30),
+ (6, 6, 6, 0, -6, 15266, 36),
+ (7, 7, 6, 0, -6, 15302, 42),
+ (8, 8, 5, 0, -5, 15344, 40),
+ (16, 16, 5, 0, -5, 15384, 80),
+ (5, 5, 12, 0, -12, 15464, 60),
+ (5, 5, 12, 0, -12, 15524, 60),
+ (8, 8, 12, 0, -12, 15584, 96),
+ (8, 8, 12, 0, -12, 15680, 96),
+ (16, 16, 2, 0, -2, 15776, 32),
+ (6, 6, 12, 0, -12, 15808, 72),
+ (3, 3, 12, 0, -12, 15880, 36),
+ (6, 6, 12, 0, -12, 15916, 72),
+ (6, 6, 12, 0, -12, 15988, 72),
+ (6, 6, 12, 0, -12, 16060, 72),
+ (6, 6, 12, 0, -12, 16132, 72),
+ (6, 6, 12, 0, -12, 16204, 72),
+ (6, 6, 12, 0, -12, 16276, 72),
+ (8, 8, 11, 0, -11, 16348, 88),
+ (8, 8, 9, 0, -9, 16436, 72),
+ (8, 8, 10, 0, -10, 16508, 80),
+ (4, 4, 13, 0, -13, 16588, 52),
+ (4, 4, 13, 0, -13, 16640, 52),
+ (6, 6, 10, 0, -10, 16692, 60),
+ (6, 6, 7, 0, -7, 16752, 42),
+ (6, 6, 7, 0, -7, 16794, 42),
+ (6, 6, 7, 0, -7, 16836, 42),
+ (6, 6, 7, 0, -7, 16878, 42),
+ (6, 6, 7, 0, -7, 16920, 42),
+ (6, 6, 7, 0, -7, 16962, 42),
+ (6, 6, 7, 0, -7, 17004, 42),
+ (6, 6, 7, 0, -7, 17046, 42),
+ (6, 6, 7, 0, -7, 17088, 42),
+ (6, 6, 7, 0, -7, 17130, 42),
+ (8, 8, 6, 0, -6, 17172, 48),
+ (8, 8, 4, 0, -4, 17220, 32),
+ (8, 8, 5, 0, -5, 17252, 40),
+ (4, 4, 9, 0, -8, 17292, 36),
+ (4, 4, 9, 0, -8, 17328, 36),
+ (6, 6, 6, 0, -6, 17364, 36),
+ (7, 7, 6, 0, -6, 17400, 42),
+ (7, 7, 5, 0, -5, 17442, 35),
+ (7, 7, 6, 0, -6, 17477, 42),
+ (6, 6, 7, 0, -7, 17519, 42),
+ (7, 7, 8, 0, -8, 17561, 56),
+ (3, 3, 7, 0, -7, 17617, 21),
+ (10, 10, 5, 0, -5, 17638, 50),
+ (6, 6, 5, 0, -5, 17688, 30),
+ (7, 7, 8, 0, -6, 17718, 56),
+ (6, 6, 6, 0, -6, 17774, 36),
+ (5, 5, 7, 0, -7, 17810, 35),
+ (13, 13, 8, 0, -8, 17845, 104),
+ (13, 13, 8, 0, -8, 17949, 104),
+ (8, 8, 12, 0, -11, 18053, 96),
+ (11, 12, 12, -1, -12, 18149, 144),
+ (14, 14, 13, 0, -12, 18293, 182),
+ (13, 13, 5, 0, -5, 18475, 65),
+ (13, 13, 11, 0, -11, 18540, 143),
+ (10, 11, 13, 0, -13, 18683, 143),
+ (11, 11, 8, 0, -8, 18826, 88),
+ (13, 13, 8, 0, -8, 18914, 104),
+ (8, 8, 17, 0, -13, 19018, 136),
+ (13, 13, 9, 0, -9, 19154, 117),
+ (13, 13, 9, 0, -9, 19271, 117),
+ (13, 13, 9, 0, -9, 19388, 117),
+ (13, 13, 9, 0, -9, 19505, 117),
];
-pub static COVERAGE: [u8; 19382] = [
+pub static COVERAGE: [u8; 19622] = [
0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0,
0, 0, 148, 255, 0, 0, 0, 0, 143, 251, 0, 0, 0, 0, 130, 237, 0, 0, 0, 0, 115, 223, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 148, 255, 0, 0, 0, 0, 148, 255, 0, 0,
@@ -681,7 +683,17 @@ pub static COVERAGE: [u8; 19382] = [
0, 0, 0, 0, 0, 0, 124, 255, 225, 6, 0, 0, 0, 0, 0, 0, 0, 0, 69, 246, 183, 246, 164, 2,
0, 0, 0, 0, 0, 0, 69, 246, 160, 2, 67, 246, 164, 2, 0, 0, 0, 0, 69, 246, 159, 2, 0, 0,
66, 245, 164, 2, 0, 0, 0, 95, 156, 2, 0, 0, 0, 0, 64, 180, 9, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0, 108, 255, 255, 255, 255, 255, 255, 212, 0, 108, 255, 40, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 104, 255, 44, 92, 255, 60, 0, 0, 0, 0, 104, 255, 44, 92, 255,
+ 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 67, 196, 246, 241, 178, 40, 0, 0, 0,
+ 67, 252, 145, 24, 36, 187, 237, 30, 0, 0, 199, 209, 0, 0, 0, 19, 245, 148, 0, 8, 253, 133, 0, 0,
+ 0, 0, 186, 212, 0, 24, 255, 111, 0, 0, 0, 0, 163, 231, 0, 8, 253, 132, 0, 0, 0, 0, 185, 212,
+ 0, 0, 199, 207, 0, 0, 0, 18, 244, 149, 0, 0, 69, 252, 143, 23, 34, 185, 239, 31, 0, 0, 0, 69,
+ 197, 247, 242, 179, 42, 0, 0, 0, 0, 88, 255, 60, 76, 255, 76, 0, 0, 0, 0, 88, 255, 60, 76, 255,
+ 76, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 164, 204, 0, 0, 0, 0, 192, 176, 0, 0,
+ 164, 204, 0, 0, 0, 0, 192, 176, 0, 0, 164, 204, 0, 0, 0, 0, 192, 176, 0, 0, 164, 204, 0, 0,
+ 0, 0, 192, 176, 0, 0, 164, 204, 0, 0, 0, 0, 193, 176, 0, 0, 150, 216, 0, 0, 0, 0, 218, 176,
+ 0, 0, 117, 246, 11, 0, 0, 36, 252, 176, 0, 0, 28, 250, 146, 16, 45, 172, 204, 176, 0, 0, 0, 83,
+ 219, 248, 194, 39, 192, 176, 0, 0, 108, 255, 255, 255, 255, 255, 255, 212, 0, 108, 255, 40, 0, 0, 0, 0,
0, 0, 108, 255, 40, 0, 0, 0, 0, 0, 0, 108, 255, 40, 0, 0, 0, 0, 0, 0, 108, 255, 40, 0,
0, 0, 0, 0, 0, 108, 255, 40, 0, 0, 0, 0, 0, 0, 108, 255, 40, 0, 0, 0, 0, 0, 0, 108,
255, 40, 0, 0, 0, 0, 0, 0, 108, 255, 40, 0, 0, 0, 0, 0, 0, 108, 255, 40, 0, 0, 0, 0,
diff --git a/src/raster.rs b/src/raster.rs
index d426a724..1c2021d8 100644
--- a/src/raster.rs
+++ b/src/raster.rs
@@ -2794,6 +2794,25 @@ mod tests {
assert!(ink > 5, "glyph produced no ink: {ink}");
}
+ #[test]
+ fn matplotlib_text_commands_umlauts_have_native_glyphs() {
+ assert!(glyph_index('ö').is_some());
+ assert!(glyph_index('ü').is_some());
+ let mut cmd = vec![OP_TEXT];
+ cmd.extend(f32le(1.0));
+ cmd.extend(f32le(30.0));
+ cmd.push(0); // anchor start
+ cmd.extend(f32le(20.0)); // size
+ cmd.extend([0, 0, 0, 255]);
+ let s = "öü".as_bytes();
+ cmd.extend(u32le(s.len() as u32));
+ cmd.extend_from_slice(s);
+ let mut out = vec![0u8; 40 * 40 * 4];
+ assert!(rasterize_into(&cmd, 40, 40, &mut out));
+ let ink: u32 = out.chunks(4).map(|p| (p[3] > 32) as u32).sum();
+ assert!(ink > 10, "umlaut glyphs produced no ink: {ink}");
+ }
+
#[test]
fn rotated_cw_text_walks_downward() {
// "II" rotated CW from (20, 5): ink extends down the column below the
diff --git a/tests/pyplot/test_gallery_text_pie_compat.py b/tests/pyplot/test_gallery_text_pie_compat.py
index 47ac9d42..a9d03c68 100644
--- a/tests/pyplot/test_gallery_text_pie_compat.py
+++ b/tests/pyplot/test_gallery_text_pie_compat.py
@@ -213,6 +213,59 @@ def test_text_preserves_mathtext_italic_span_across_all_exporters() -> None:
assert np.any(rgba[..., :3] < 0.5)
+def test_text_fontdict_styles_title_labels_and_named_math_functions() -> None:
+ fig, ax = plt.subplots(figsize=(6.4, 4.8), dpi=100)
+ font = {"family": "serif", "color": "darkred", "weight": "normal", "size": 16}
+
+ ax.set_title("Damped exponential decay", fontdict=font)
+ equation = ax.text(2, 0.65, r"$\cos(2 \pi t) \exp(-t)$", fontdict=font)
+ ax.set_xlabel("time (s)", fontdict=font)
+ ax.set_ylabel("voltage (mV)", fontdict=font)
+
+ assert equation.get_text() == "cos(2πt)exp(\N{MINUS SIGN}t)"
+ assert equation._entry["kwargs"]["style"]["math_italic_ranges"] == "5:7,13:14"
+ spec, _ = ax._build_chart(*fig._panel_px()).figure().build_payload()
+ assert spec["dom"]["styles"]["title"] == {
+ "font-size": "22.2222px",
+ "color": "darkred",
+ "font-weight": "normal",
+ "font-family": "serif",
+ }
+ for axis in ("x_axis", "y_axis"):
+ style = spec[axis]["style"]
+ assert style["label_size"] == pytest.approx(22.2222, rel=1e-4)
+ assert style["label_color"] == "darkred"
+ assert style["label_font_weight"] == "normal"
+ assert style["label_font_family"] == "serif"
+ svg = ax._build_chart(*fig._panel_px()).figure().to_svg()
+ assert 'font-family="serif"' in svg
+ assert 'font-weight="normal"' in svg
+ assert 'fill="darkred"' in svg
+ assert ">cos(" in svg
+ assert "\\cos" not in svg and "\\exp" not in svg
+
+
+def test_text_commands_umlauts_render_in_native_png_and_survive_svg() -> None:
+ phrase = "Unicode: Institut für Festkörperphysik"
+ deleted = "Unicode: Institut fr Festkrperphysik"
+
+ def render(text: str) -> np.ndarray:
+ fig, ax = plt.subplots()
+ ax.set_xlim(0, 10)
+ ax.set_ylim(0, 10)
+ ax.text(3, 2, text)
+ return np.asarray(plt.imread(BytesIO(fig._to_png())))
+
+ with_umlauts = render(phrase)
+ plt.close("all")
+ without_umlauts = render(deleted)
+ assert not np.array_equal(with_umlauts, without_umlauts)
+
+ fig, ax = plt.subplots()
+ ax.text(3, 2, phrase)
+ assert phrase in ax._build_chart(*fig._panel_px()).figure().to_svg()
+
+
def test_pie_container_values_are_defensive_and_fracs_stay_numeric() -> None:
_fig, ax = plt.subplots()
pie = ax.pie(np.array([2, 3, 5], dtype=np.int16))