diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts index 7691bb64..a85c1f5b 100644 --- a/js/src/20_theme.ts +++ b/js/src/20_theme.ts @@ -82,15 +82,26 @@ export function cssColor([r, g, b, a]: any) { // convention every host we target uses (Reflex/next-themes, Radix Themes, // Tailwind). These remain zero-specificity :where() rules, so public // --chart-badge-* / --chart-modebar-* tokens or utility classes override them. +// +// Every chrome text slot below either declares `font-weight:400` or declares +// no weight at all and inherits 400. Matplotlib's `axes.titleweight`, +// `axes.labelweight` and `font.weight` all default to "normal", and its legend +// title and colorbar label are normal too, so 400 is the parity default for +// title, axis titles, annotations, legend titles and colorbar titles alike. +// The SVG (`python/xy/_svg.py`) and native raster (`python/xy/_raster.py`) +// exporters carry the same 400 default — the three renderers must not +// disagree; tests/test_text_weight_defaults.py guards all three. A heavier +// weight is opt-in, via `styles[slot]`, a mark/axis text style, or the pyplot +// `axes.titleweight`/`axes.labelweight` rcParams. export const XY_CHROME_CSS = ` @layer base{ -:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)} +:where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:400;color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="tooltip"]){max-width:calc(100% - 8px);max-height:calc(100% - 8px);box-sizing:border-box;white-space:normal;overflow-wrap:anywhere;overflow:auto;background:var(--chart-tooltip-bg,rgba(20,24,33,.92));color:var(--chart-tooltip-text,#fff);padding:5px 8px;border-radius:4px;font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)} :where(.xy [data-xy-slot="legend"]){left:var(--xy-legend-left,auto);right:var(--xy-legend-right,auto);top:var(--xy-legend-top,auto);bottom:var(--xy-legend-bottom,auto);transform:var(--xy-legend-transform,none);max-width:var(--xy-legend-max-width);max-height:var(--xy-legend-max-height);gap:2px;font-size:11px;background:var(--chart-legend-bg,rgba(128,128,128,.08));border-radius:4px;padding:4px 8px;color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="legend_swatch"]){width:12px;height:10px;border-radius:2px;margin-right:5px} :where(.xy [data-xy-slot="colorbar"]){color:var(--chart-text,inherit);font-size:10px} :where(.xy [data-xy-slot="colorbar_bar"]){background:var(--xy-colorbar-gradient);border:1px solid currentColor;box-sizing:border-box} -:where(.xy [data-xy-slot="colorbar_title"]){font-weight:500} +:where(.xy [data-xy-slot="colorbar_title"]){font-weight:400} :where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} :where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)} :where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:#fff;--xy-modebar-menu-bg:#fff;--xy-modebar-hover:#edf1f6;--xy-modebar-text:#5c6573;--xy-modebar-text-strong:#1b212a;--xy-modebar-text-soft:#798495;--xy-modebar-text-subtle:#9aa4b2;--xy-modebar-border:rgba(27,33,42,.12);--xy-modebar-separator:rgba(27,33,42,.08);--xy-modebar-active:#edf1f6;--xy-modebar-shadow:0 8px 24px rgba(28,32,36,.1),0 2px 6px rgba(28,32,36,.06);--xy-modebar-menu-shadow:0 8px 24px rgba(28,32,36,.12);--xy-modebar-button-shadow:0 1px 2px rgba(28,32,36,.06)} @@ -141,7 +152,7 @@ export const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} :where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)} :where(.xy [data-xy-slot="axis_title"]){color:var(--chart-text,inherit);font-size:12px} -:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:500;color:var(--chart-annotation-text,var(--chart-text,inherit))} +:where(.xy [data-xy-slot="annotation_label"]){font-size:11px;line-height:1.2;font-weight:400;color:var(--chart-annotation-text,var(--chart-text,inherit))} :where(.xy [data-xy-slot="canvas"]){cursor:var(--chart-cursor,crosshair)} :where(.xy [data-xy-slot="canvas"][data-xy-dragmode="pan"]){cursor:var(--chart-cursor-pan,grab)} :where(.xy [data-xy-slot="canvas"][data-xy-dragmode="none"]){cursor:default} diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index a5472402..846c4850 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1769,7 +1769,10 @@ export class ChartView { if (options.title) { const title = document.createElement("div"); title.textContent = String(options.title); - title.style.fontWeight = "600"; + // Matplotlib renders a legend title at normal weight, and the native + // raster exporter never emphasized it, so 400 is what both the SVG + // exporter and this path emit. + title.style.fontWeight = "400"; title.style.gridColumn = `1 / span ${horizontal ? ncols : 1}`; lg.appendChild(title); } @@ -4173,7 +4176,7 @@ export class ChartView { const hasAngle = axis && Number.isFinite(Number(axis.label_angle)); if (!hasPosition && !hasOffset && !hasAngle) return { css: fallbackCss, style: null }; if (rawPosition && typeof rawPosition === "object" && !Array.isArray(rawPosition)) { - return { css: "font-weight:500;white-space:nowrap;", style: rawPosition }; + return { css: "font-weight:400;white-space:nowrap;", style: rawPosition }; } const p = this.plot; @@ -4196,7 +4199,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translateX(${translateX}%) rotate(${angle}deg);` + - "transform-origin:center;font-weight:500;white-space:nowrap;", + "transform-origin:center;font-weight:400;white-space:nowrap;", style: null, }; } @@ -4211,7 +4214,7 @@ export class ChartView { css: `left:${x}px;top:${y}px;` + `transform:translate(-50%,-50%) rotate(${angle}deg);` + - "transform-origin:center;font-weight:500;white-space:nowrap;", + "transform-origin:center;font-weight:400;white-space:nowrap;", style: null, }; } @@ -4536,7 +4539,7 @@ export class ChartView { if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const top = axis.side === "top" ? p.y - 34 : p.y + p.h + 24; const fallbackCss = - `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; + `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`; const placement = this._axisLabelCss(axis, "x", fallbackCss); label(axis.label, placement.css, axis, "label", placement.style); } @@ -4586,22 +4589,22 @@ export class ChartView { } if (axis.label && this._axisTickLabelStrategy(axis) !== "none") { const fallbackCss = axis.side === "left" - ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;` - : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;`; + ? `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;` + : `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;`; const placement = this._axisLabelCss(axis, "y", fallbackCss); label(axis.label, placement.css, axis, "label", placement.style); } } if (s.x_axis.label && !hideX) { const top = xAxis.side === "top" ? p.y - 34 : p.y + p.h + 24; - const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:500;`; + const fallbackCss = `left:${p.x + p.w / 2}px;top:${top}px;transform:translateX(-50%);font-weight:400;`; const placement = this._axisLabelCss(xAxis, "x", fallbackCss); label(s.x_axis.label, placement.css, xAxis, "label", placement.style); } if (s.y_axis.label && !hideY) { const fallbackCss = yAxis.side === "right" - ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:500;` - : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:500;`; + ? `left:${p.x + p.w + 40}px;top:${p.y + p.h / 2}px;transform:rotate(90deg) translateX(-50%);transform-origin:left;font-weight:400;` + : `left:10px;top:${p.y + p.h / 2}px;transform:rotate(-90deg) translateX(50%);transform-origin:left;font-weight:400;`; const placement = this._axisLabelCss(yAxis, "y", fallbackCss); label(s.y_axis.label, placement.css, yAxis, "label", placement.style); } diff --git a/pr-assets/text-weight/core-chart-text-weight.png b/pr-assets/text-weight/core-chart-text-weight.png new file mode 100644 index 00000000..aaf0e381 Binary files /dev/null and b/pr-assets/text-weight/core-chart-text-weight.png differ diff --git a/pr-assets/text-weight/pdsh-cell23-text-weight.png b/pr-assets/text-weight/pdsh-cell23-text-weight.png new file mode 100644 index 00000000..89840ea6 Binary files /dev/null and b/pr-assets/text-weight/pdsh-cell23-text-weight.png differ diff --git a/python/xy/_raster.py b/python/xy/_raster.py index b026e446..0048f605 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1001,7 +1001,10 @@ def emit_tick_labels( title_italic, title_bold = _native_font_emphasis( { "font_style": title_style.get("font-style"), - "font_weight": title_style.get("font-weight", 600), + # 400 = Matplotlib's `axes.titleweight: normal`; the baked + # atlas only has a bold face, so anything >= 600 rounds up to + # it. Mirrors the SVG/browser title default. + "font_weight": title_style.get("font-weight", 400), } ) cmd.text( @@ -1024,7 +1027,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: italic, bold = _native_font_emphasis( { "font_style": axis_style.get("label_font_style"), - "font_weight": axis_style.get("label_font_weight", 500), + "font_weight": axis_style.get("label_font_weight", 400), } ) args = ( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index fe9d6ae5..0c107c1a 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1625,7 +1625,11 @@ def line_attrs(style: dict[str, Any], color: str) -> 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) + # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to + # "normal", so chrome text stays at 400 unless a style or rcParam asks + # for more. Keep this in step with the `title`/`axis_title` slot rules + # in js/src/20_theme.ts and the raster defaults in _raster.py. + title_weight = title_style.get("font-weight", 400) title_family = title_style.get("font-family") title_font_style = title_style.get("font-style") title_font_attrs = ( @@ -1658,7 +1662,7 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: chrome.append( f'' f"{escape(str(axis['label']))}" ) @@ -2906,7 +2910,7 @@ def _legend( if title: rows.append( f'{escape(str(title))}' + f'font-weight="400" fill="{escape(text_color)}">{escape(str(title))}' ) for i, t in enumerate(named[: legend["visible_count"]]): style = t.get("style") or {} diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 549fe429..a81cc941 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -102,12 +102,14 @@ def _rc_chrome_snapshot(dpi: float) -> dict[str, Any]: f"{_font_size(rcParams['axes.titlesize'], rcParams['font.size'], dpi):g}px" ), "color": theme_tokens["text_color"], + **_rc_font_weight(rcParams["axes.titleweight"]), }, "axis_title": { "font-size": ( f"{_font_size(rcParams['axes.labelsize'], rcParams['font.size'], dpi):g}px" ), "color": resolve_color(rcParams["axes.labelcolor"]), + **_rc_font_weight(rcParams["axes.labelweight"]), }, "tick_label": { "font-size": ( @@ -5081,6 +5083,19 @@ def _font_size(value: Any, base: Any, dpi: float = 96.0) -> float: return _font_size_points(value, base) * float(dpi) / 72.0 +def _rc_font_weight(value: Any, key: str = "font-weight") -> dict[str, Any]: + """Carry a weight rcParam only when it asks for something other than normal. + + Matplotlib's ``axes.titleweight``/``axes.labelweight`` default to + ``"normal"``, which is also every xy renderer's own default, so the + default case needs nothing on the wire — the same rule the neighbouring + ``axes.labelcolor`` check uses. An explicit weight (``"bold"``, ``"light"``, + a numeric string) travels through to the browser, SVG, and raster paths. + """ + weight = str(value) + return {} if weight == "normal" else {key: weight} + + def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: prefix = "xtick" if axis == "x" else "ytick" point_scale = float(dpi) / 72.0 @@ -5105,6 +5120,10 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: if rcParams["axes.labelcolor"] != "black": result["label_color"] = resolve_color(rcParams["axes.labelcolor"]) result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi) + # The browser reads the weight off `chrome_styles["axis_title"]`; the SVG + # and native raster exporters read it off the axis style, so `axes.labelweight` + # has to be published in both places to reach all three renderers. + result.update(_rc_font_weight(rcParams["axes.labelweight"], "label_font_weight")) return result diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 19537e39..1d676d3e 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -44,8 +44,10 @@ def by_key(self) -> dict[str, list[str]]: "axes.edgecolor": "black", "axes.labelcolor": "black", "axes.labelsize": "medium", + "axes.labelweight": "normal", "axes.titlesize": "large", "axes.titlecolor": "auto", + "axes.titleweight": "normal", "axes.linewidth": 0.8, "axes.xmargin": 0.05, "axes.ymargin": 0.05, diff --git a/spec/api/styling.md b/spec/api/styling.md index 3504641f..66e48a73 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -127,6 +127,7 @@ or a CSS `px` value such as `"3px"`. | `grid_opacity` | Number from `0` to `1` | | `tick_length` | Non-negative pixel length | | `tick_size` / `tick_label_size`, `label_size` | Positive pixel font size | +| `label_font_weight`, `label_font_family`, `label_font_style` | Axis-label font overrides, passed through to the browser, SVG, and native PNG paths. `label_font_weight` defaults to `400` — see [Chrome text weight](#chrome-text-weight). | | `tick_direction` | `"in"`, `"out"`, or `"inout"` | | `tick_label_anchor` | `"start"`, `"center"`, or `"end"` (mpl `ha` aliases `"left"`/`"right"`/`"middle"` normalize) — which label edge pins to the tick; rotated labels pivot about the pinned edge. Also a first-class `x_axis`/`y_axis` option. X defaults to `"center"`; y defaults to the tick-side edge (`"end"` left of the plot, `"start"` right of it). Honored by static SVG/PNG exports. | @@ -237,6 +238,51 @@ raises before it reaches the client.
``` +### Chrome text weight + +**Every chrome text element defaults to `font-weight: 400`** — chart title, axis +titles, tick labels, legend entries, legend titles, colorbar titles, and text/ +label/callout annotations alike. This is Matplotlib's default and it is +deliberate: `axes.titleweight`, `axes.labelweight`, and `font.weight` are all +`normal` in Matplotlib 3.11, and its legend titles and colorbar labels are +normal too, so a chart exported from `xy.pyplot` carries the same text weight as +the same script run under Matplotlib. + +The default is a **cross-renderer contract**, not a per-renderer choice. All +three renderers must agree: + +| Renderer | Where the default lives | +| --- | --- | +| Browser render client | `font-weight:400` on the text slot rules in `js/src/20_theme.ts`, plus the inline axis-label/legend-title weights in `js/src/50_chartview.ts` (inline styles beat the slot stylesheet, so both have to say 400) | +| SVG export | `python/xy/_svg.py` — the `font-weight` attribute on the title, axis-title, and legend-title `` elements | +| Native PNG export | `python/xy/_raster.py` — `_native_font_emphasis` maps a weight `>= 600` onto the baked atlas's bold face, so 400 emits a plain, unemphasized text record | + +A renderer that drifts heavier is a bug; `tests/test_text_weight_defaults.py` +asserts the emitted weight per element in the SVG output and in the native +raster command stream, and holds a source-level guard on the TypeScript +defaults (the client bundles are a generated, git-ignored artifact, so a +bundle-reading test could not run from a fresh checkout). + +Heavier text is always opt-in, never a default: + +```python +xy.chart(..., styles={"title": {"font_weight": 600}}) # per-slot +xy.x_axis(label="time", style={"label_font_weight": "bold"}) # per-axis +``` + +Under the pyplot shim, Matplotlib's own knobs work too — +`rcParams["axes.titleweight"]`, `rcParams["axes.labelweight"]`, and the explicit +`ax.set_title(..., fontweight=)` / `ax.set_xlabel(..., fontweight=)` arguments. +Because `normal` is already every renderer's default, the shim only puts a +weight on the wire when it differs from `normal`. + +The native PNG exporter's font atlas is bounded and carries one regular and one +bold face, so it approximates: any weight `>= 600` (or a name in +`bold`/`semibold`/`demibold`/`heavy`/`black`) renders with the bold face, and +everything lighter renders regular. Intermediate weights are therefore not +distinguishable in native PNG output, while the browser and SVG paths pass the +requested weight through verbatim. + ## Why your styles always win The client injects one stylesheet of *visual* defaults (background, color, diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 75bb35d8..210018a6 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -86,7 +86,8 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.cm.*` / `plt.colormaps[...]` / `cmap=` names | viridis, plasma, inferno, magma, cividis, gray, turbo, coolwarm, Blues, Purples, PuBu, RdBu, RdYlGn, RdGy, PiYG, PRGn, jet, rainbow, Spectral, binary, aliases, and true `*_r` reversal (RdGy/jet render from 11-stop anchor tables sampled from Matplotlib 3.11, linearly interpolated) | | `LinearSegmentedColormap.from_list` / `ListedColormap` | Python-side callables (`cmap(np.arange(cmap.N))` → RGBA) for scripts that colormap values themselves; they cannot be passed as `cmap=` to plotting calls (no engine table), which fails loudly | | `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | -| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore them. Unknown keys warn once | +| `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore most of them. `axes.titleweight` and `axes.labelweight` are supported and verified to reach all three renderers (browser, single-chart SVG, single-chart native PNG); both default to `normal`, matching Matplotlib. Unknown keys warn once | +| Text weight | Title, axis-label, tick-label, legend, legend-title, colorbar-title, and annotation text all default to normal (400) weight in every renderer, matching Matplotlib's `axes.titleweight`/`axes.labelweight`/`font.weight` defaults. Heavier text needs an explicit `fontweight=`, `label_font_weight`, `styles[slot]`, or rcParam. Native PNG approximates: the bounded font atlas holds one regular and one bold face, so weights `>= 600` render bold and everything lighter renders regular — intermediate weights are not distinguishable in native PNG, while browser and SVG output pass the requested weight through verbatim. See [styling § Chrome text weight](../api/styling.md#chrome-text-weight) | | `plt.style.use(...)` / `plt.style.context(...)` | `"default"`, `"xy"`, bounded rcParam dictionaries, ordered lists, and the stock sheets fivethirtyeight, ggplot, bmh, dark_background, grayscale, seaborn-v0_8-white(grid), seaborn-v0_8-darkgrid, and seaborn-v0_8-deep — reduced to the supported rcParams subset (colors, grid, cycle, line width, font size; per-sheet keys outside that subset are not carried). The darkgrid sheet mirrors seaborn's `axes_style` (including `patch.edgecolor: white` + `patch.force_edgecolor`, which give hist/bar patches their white separators); `-deep` installs seaborn's classic color cycle. `context()` snapshots and restores. Unknown sheet names fail precisely | | `plt.GridSpec(r, c, wspace=, hspace=, width_ratios=)` + slice specs | Spans (`grid[0, 1:]`, `grid[:-1, 0]`) and custom spacing resolve to explicit figure rectangles using Matplotlib's SubplotParams frame; default-geometry single cells keep the uniform grid. Spanning layouts position exactly in HTML, PNG, and SVG: free-form panels (including `add_axes` rects and insets) render absolutely at their figure rectangles in every exporter, with later axes stacked above earlier ones | | `add_subplot(spec, sharex=, sharey=, xticklabels=[], ...)` | per-axes sharing aliases the axis-property store (static domains, as `twiny` does), not Matplotlib's live Grouper; `get_shared_x_axes()` reflects it | diff --git a/spec/matplotlib/shim-todo.md b/spec/matplotlib/shim-todo.md index dcdf2c03..ee0eadcd 100644 --- a/spec/matplotlib/shim-todo.md +++ b/spec/matplotlib/shim-todo.md @@ -661,7 +661,10 @@ streamplot ### Known-inconsistent, still open - Exporter chrome beyond backgrounds (fonts, tick/legend styling) stays fixed - in single-chart PNG/SVG regardless of rcParams. + in single-chart PNG/SVG regardless of rcParams — except `axes.titleweight` + and `axes.labelweight`, which reach the browser, single-chart SVG, and + single-chart native PNG paths and are covered by + `tests/pyplot/test_rc_chrome_contracts.py`. - `ax.set_facecolor()` mutates the per-Axes plot background after creation, but `set_facecolor(None)` is a silent no-op rather than a reset to the rc default; restoring it requires passing `rcParams["axes.facecolor"]` back diff --git a/tests/pyplot/test_rc_chrome_contracts.py b/tests/pyplot/test_rc_chrome_contracts.py index b8dbcf78..d4bf965b 100644 --- a/tests/pyplot/test_rc_chrome_contracts.py +++ b/tests/pyplot/test_rc_chrome_contracts.py @@ -96,3 +96,50 @@ def test_spine_controls_and_invalid_cycle_boundaries() -> None: ax.spines[["top", "right"]].set_visible(False) spec, _ = ax._build_chart(640, 480).figure().build_payload() assert spec["frame_sides"] == ["bottom"] + + +def test_title_and_label_weight_rc_defaults_match_matplotlib() -> None: + # Matplotlib 3.11: axes.titleweight and axes.labelweight are both "normal". + assert plt.rcParams["axes.titleweight"] == "normal" + assert plt.rcParams["axes.labelweight"] == "normal" + + _fig, ax = plt.subplots() + ax.set_title("title") + ax.set_xlabel("x") + built = ax._build_chart(640, 480).figure() + + # "normal" is every renderer's own default, so it needs nothing on the + # wire — the browser, SVG, and raster paths all land on 400 unaided. + assert "font-weight" not in built.chrome_styles["title"] + assert "font-weight" not in built.chrome_styles["axis_title"] + assert "label_font_weight" not in built.axis_options["x"]["style"] + + +def test_title_and_label_weight_rc_overrides_reach_every_renderer() -> None: + with plt.rc_context({"axes.titleweight": "bold", "axes.labelweight": 700}): + _fig, ax = plt.subplots() + ax.set_title("title") + ax.set_xlabel("x") + ax.set_ylabel("y") + built = ax._build_chart(640, 480).figure() + svg = built.to_svg() + + # browser: the chrome slot styles the render client applies + assert built.chrome_styles["title"]["font-weight"] == "bold" + assert built.chrome_styles["axis_title"]["font-weight"] == "700" + # SVG + native raster read the weight off the axis style, not the chrome + # slots, so axes.labelweight has to be published in both places. + assert built.axis_options["x"]["style"]["label_font_weight"] == "700" + assert built.axis_options["y"]["style"]["label_font_weight"] == "700" + assert 'font-weight="bold"' in svg + assert 'font-weight="700"' in svg + + +def test_explicit_set_title_weight_still_beats_the_rc_default() -> None: + _fig, ax = plt.subplots() + ax.set_title("title", fontweight="bold") + ax.set_xlabel("x", fontweight="light") + built = ax._build_chart(640, 480).figure() + + assert built.chrome_styles["title"]["font-weight"] == "bold" + assert built.axis_options["x"]["style"]["label_font_weight"] == "light" diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 156d1e35..097a34fa 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -187,7 +187,9 @@ def test_axis_style_reaches_svg_and_native_renderers() -> None: assert 'stroke="#0000ff" stroke-width="2"' in svg assert 'stroke="#00aa00" stroke-width="2"' in svg assert 'fill="#cc5500" font-size="13" text-anchor="middle"' in svg - assert 'font-size="15" font-weight="500" fill="#aa00aa"' in svg + # 400 is the matplotlib-parity axis-label default; this style sets no + # `label_font_weight`, so the default is what lands in the SVG. + assert 'font-size="15" font-weight="400" fill="#aa00aa"' in svg assert _raster.render_raster(*fig.build_payload(), scale=1).shape[-1] == 4 diff --git a/tests/test_text_weight_defaults.py b/tests/test_text_weight_defaults.py new file mode 100644 index 00000000..615a0779 --- /dev/null +++ b/tests/test_text_weight_defaults.py @@ -0,0 +1,201 @@ +"""Chrome text weight defaults must match Matplotlib, in every renderer. + +Matplotlib 3.11's `axes.titleweight`, `axes.labelweight` and `font.weight` all +default to `normal`, and its legend titles and colorbar labels are normal too. +So every xy chrome text default is 400, and the three renderers — the browser +render client (`js/src/`), the SVG exporter (`python/xy/_svg.py`) and the native +raster exporter (`python/xy/_raster.py`) — must agree on it. A renderer that +quietly drifts heavier is the bug these tests exist to catch. + +The TypeScript source guard is a source-level assertion on purpose: the render +client's bundles are a generated, git-ignored artifact, so a test that can only +read the bundle cannot run from a fresh checkout. +""" + +from __future__ import annotations + +import re +import struct +from pathlib import Path + +import pytest + +import xy +from xy import _raster + +_ROOT = Path(__file__).resolve().parents[1] +_JS = _ROOT / "js" / "src" + +# Native text-record header sizes, from `_Cmd.text` in python/xy/_raster.py. +# Both records end with `u32 byte_length` + UTF-8 payload, so a unique payload +# is an unambiguous anchor to walk back from. +# _TEXT_OP : op(1) x(4) y(4) anchor(1) size(4) rgba(4) len(4) = 22 +# _STYLED_TEXT : op(1) x(4) y(4) anchor(1) size(4) angle(4) flags(1) +# range_count(4) [range(8) * n] rgba(4) len(4) = 31 + 8n +_TEXT_OP_HEADER = 22 +_STYLED_HEADER = 31 + + +def _title_axis_legend_figure(**axis_style): + """A chart exercising title, both axis labels, legend title and annotation.""" + fig = xy.chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.5], name="series-a"), + xy.x_axis(label="XLABEL", style=axis_style or None), + xy.y_axis(label="YLABEL", style=axis_style or None), + title="TITLE", + ).figure() + fig.legend_options = {"title": "LEGENDTITLE"} + fig.text(1.0, 2.0, "ANNOTATION") + return fig + + +def _svg_text_weights(svg: str) -> dict[str, str]: + """Map each `` body to the `font-weight` the SVG exporter emitted.""" + weights: dict[str, str] = {} + for attrs, body in re.findall(r"]*)>(?:]*>)?([^<]*)", svg): + match = re.search(r'font-weight="([^"]+)"', attrs) + weights[body.strip()] = match.group(1) if match else "unset" + return weights + + +def _raster_stream(fig) -> bytes: + """The native command stream `to_png` hands to the Rust rasterizer.""" + captured: dict[str, object] = {} + original = _raster._Cmd.__init__ + + def spy(self, *args, **kwargs): + original(self, *args, **kwargs) + captured.setdefault("cmd", self) + + _raster._Cmd.__init__ = spy + try: + fig.to_png(scale=1) + finally: + _raster._Cmd.__init__ = original + return bytes(captured["cmd"].buf) # type: ignore[union-attr] + + +def _raster_text_bold(stream: bytes, text: str) -> bool: + """Whether the native record carrying `text` sets the bold emphasis flag.""" + payload = text.encode("utf-8") + at = stream.find(struct.pack("= 0, f"no native text record carries {text!r}" + body = at + 4 + styled = body - _STYLED_HEADER + if styled >= 0 and stream[styled] == _raster._STYLED_TEXT: + (ranges,) = struct.unpack("= 0 and stream[plain] == _raster._TEXT_OP, ( + f"record for {text!r} is neither a plain nor a styled text record" + ) + return False # the plain opcode carries no emphasis byte at all + + +# --- SVG exporter ---------------------------------------------------------- + + +def test_svg_chrome_text_defaults_to_normal_weight() -> None: + weights = _svg_text_weights(_title_axis_legend_figure().to_svg()) + + assert weights["TITLE"] == "400" + assert weights["XLABEL"] == "400" + assert weights["YLABEL"] == "400" + assert weights["LEGENDTITLE"] == "400" + # The annotation and legend-entry paths emit no weight at all, so they + # inherit the document's 400. Asserted so a later default cannot sneak in. + assert weights["ANNOTATION"] == "unset" + assert weights["series-a"] == "unset" + + +def test_svg_honors_an_explicit_heavier_axis_label_weight() -> None: + fig = _title_axis_legend_figure(label_font_weight="bold") + assert _svg_text_weights(fig.to_svg())["XLABEL"] == "bold" + + +def test_svg_honors_an_explicit_heavier_title_weight() -> None: + fig = _title_axis_legend_figure() + fig.chrome_styles["title"] = {"font-weight": "700"} + assert _svg_text_weights(fig.to_svg())["TITLE"] == "700" + + +# --- native raster exporter ------------------------------------------------ + + +def test_native_raster_chrome_text_carries_no_bold_by_default() -> None: + stream = _raster_stream(_title_axis_legend_figure()) + + for label in ("TITLE", "XLABEL", "YLABEL", "LEGENDTITLE", "ANNOTATION"): + assert not _raster_text_bold(stream, label), f"{label} is bold in the raster stream" + + +@pytest.mark.parametrize("weight", ["bold", "700", 800]) +def test_native_raster_emits_bold_only_when_asked(weight) -> None: + fig = _title_axis_legend_figure() + fig.chrome_styles["title"] = {"font-weight": weight} + stream = _raster_stream(fig) + + assert _raster_text_bold(stream, "TITLE") + # Opting the title in must not drag the axis labels along with it. + assert not _raster_text_bold(stream, "XLABEL") + + +def test_native_raster_and_svg_agree_on_the_axis_label_default() -> None: + fig = _title_axis_legend_figure() + svg_weight = _svg_text_weights(fig.to_svg())["XLABEL"] + raster_bold = _raster_text_bold(_raster_stream(fig), "XLABEL") + + assert svg_weight == "400" + assert raster_bold is False + + +# --- browser render client (source-level guard) ----------------------------- + +# Slot rules in js/src/20_theme.ts that carry chrome *text*. Each must declare +# 400, or declare nothing and inherit it. +_TEXT_SLOTS = ( + "title", + "axis_title", + "tick_label", + "legend", + "colorbar", + "colorbar_title", + "annotation_label", +) + + +def test_theme_css_chrome_text_slots_declare_normal_weight() -> None: + css = (_JS / "20_theme.ts").read_text(encoding="utf-8") + rules = dict(re.findall(r'data-xy-slot="(\w+)"\]\)\{([^}]*)\}', css)) + + for slot in _TEXT_SLOTS: + assert slot in rules, f"no `{slot}` slot rule in 20_theme.ts" + weight = re.search(r"font-weight:\s*([^;}]+)", rules[slot]) + assert weight is None or weight.group(1).strip() in {"400", "normal"}, ( + f'slot "{slot}" declares font-weight:{weight.group(1).strip()}; ' + "Matplotlib renders every chrome text element at normal weight" + ) + + +def test_chartview_inline_text_weights_are_all_normal() -> None: + """The inline axis-label/legend-title weights beat the slot stylesheet. + + They are written out at ten sites in `_axisLabelCss`/`_drawChrome`/ + `_legendBox`, so a guard on the stylesheet alone would not catch a drift + here. + """ + source = (_JS / "50_chartview.ts").read_text(encoding="utf-8") + + inline = set(re.findall(r"font-weight:\s*(\d+|normal|bold)", source)) + assert inline, "expected inline font-weight declarations in 50_chartview.ts" + assert inline <= {"400", "normal"}, ( + f"50_chartview.ts declares inline chrome weights {sorted(inline)}; " + "only normal/400 matches Matplotlib" + ) + + assigned = set(re.findall(r"\.style\.fontWeight\s*=\s*\"(\d+|normal|bold)\"", source)) + assert assigned <= {"400", "normal"}, ( + f"50_chartview.ts assigns chrome fontWeight {sorted(assigned)}; " + "only normal/400 matches Matplotlib" + )