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))