diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 435868b6..a9f72dfe 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -474,15 +474,85 @@ def _fmt_linear(v: float, step: float) -> str: return f"{v:.{min(dec, 8)}f}" +# The numeric format grammar, mirroring `fmtNumberSpec` in js/src/30_ticks.ts +# (spec/api/styling.md): `(,).N[f|%]`. Affixes may not contain +# `,`, `.` or `%`, and the bare `.N` core (no `f`/`%`) accepts none. +_NUMBER_SPEC = re.compile(r"^([^,.%]*)(,)?\.([0-9]+)(f?)(%?)([^,.%]*)$") + +# The strftime subset the client implements, likewise from 30_ticks.ts. +_TIME_SPEC_TOKEN = re.compile(r"%[YmdHMSbB]") +_MONTHS_LONG = ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +) + + +def _fmt_number_spec(v: float, spec: Any) -> Optional[str]: + """Apply the client's numeric format grammar, or None to fall back. + + Group separators are the `,`/`.` pair rather than the viewer's locale: the + browser reads `toLocaleString(undefined, ...)`, but a static export has no + viewer and must be reproducible, so the same figure exports byte-identical + on every machine.""" + if not isinstance(spec, str) or not math.isfinite(v): + return None + match = _NUMBER_SPEC.match(spec) + if match is None: + return None + prefix, group, digits_text, f, pct, suffix = match.groups() + if not f and not pct and (prefix or suffix): + return None + digits = int(digits_text) + percent = pct == "%" + value = v * 100 if percent else v + text = f"{value:,.{digits}f}" if group else f"{value:.{digits}f}" + return f"{prefix}{text}{'%' if percent else ''}{suffix}" + + +def _fmt_time_spec(ms: float, spec: Any) -> Optional[str]: + """Apply the client's strftime subset (`%Y %m %d %H %M %S %b %B`), UTC and + English months. Like the client, an unknown `%` token is copied through + verbatim rather than falling back.""" + if not isinstance(spec, str) or not math.isfinite(ms): + return None + d = datetime.fromtimestamp(ms / 1e3, tz=UTC) + replacements = { + "%Y": str(d.year), + "%m": f"{d.month:02d}", + "%d": f"{d.day:02d}", + "%H": f"{d.hour:02d}", + "%M": f"{d.minute:02d}", + "%S": f"{d.second:02d}", + "%b": _MONTHS[d.month - 1], + "%B": _MONTHS_LONG[d.month - 1], + } + return _TIME_SPEC_TOKEN.sub(lambda m: replacements[m.group(0)], spec) + + def _fmt_axis(axis: dict[str, Any], v: float, step: float) -> str: kind = axis.get("kind") if kind == "category": cats = axis.get("categories") or [] i = round(v) return str(cats[i]) if 0 <= i < len(cats) else "" + spec = axis.get("format") if kind == "time": - return _fmt_time(v, step) - return _fmt_linear(v, step) + return _fmt_time_spec(v, spec) or _fmt_time(v, step) + formatted = _fmt_number_spec(v, spec) + # A log decade under 1 must not collapse to "0" under a coarse spec. + if axis.get("scale") == "log" and 0 < v < 1 and formatted == "0": + return _fmt_linear(v, step) + return formatted if formatted is not None else _fmt_linear(v, step) def _tick_text(axis: dict[str, Any], value: float, step: float) -> str: diff --git a/spec/api/styling.md b/spec/api/styling.md index dc40727a..6cd92d2e 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -180,8 +180,13 @@ but they fail differently, and only the numeric grammar falls back. branch (`js/src/30_ticks.ts`), so the axis silently reverts to the automatic formatter. On a log axis, a value in `(0, 1)` that the spec would render as `"0"` falls back the same way. The same grammar formats tooltip fields - (`xy.tooltip(format=...)`, `js/src/52_tooltip.ts`). Static SVG/PNG exports - do not yet consult `format` — automatic labels only (known gap). + (`xy.tooltip(format=...)`, `js/src/52_tooltip.ts`). The native exporters + apply the identical grammar through `_fmt_number_spec` in `python/xy/_svg.py` + (shared by SVG, PNG/JPEG/WebP and PDF), so a formatted axis reads the same in + every target; `tests/test_svg_export.py` pins the two grammars together. + One deliberate difference: group separators are the `,`/`.` pair rather than + the viewer's locale, because a static export has no viewer and must stay + reproducible. - **Time axes** accept a strftime subset of exactly `%Y %m %d %H %M %S %b %B`. All fields are **UTC**; `%b`/`%B` are English month names. A time spec **never** falls back: `fmtTimeSpec` (`js/src/30_ticks.ts:180-200`) @@ -189,7 +194,8 @@ but they fail differently, and only the numeric grammar falls back. verbatim, so it always returns a string and the `|| fmtTime(...)` branch at `:204` is unreachable. An unrecognized `%` token such as `%y` therefore renders literally as `%y`. The automatic time formatter is reached only when - `format` is absent or not a string. + `format` is absent or not a string. `_fmt_time_spec` in `python/xy/_svg.py` + mirrors this for the native exporters, including the verbatim copy-through. - **Category axes** ignore `format=` and render the category label. ## Slot reference diff --git a/tests/test_svg_export.py b/tests/test_svg_export.py index ec25bba3..0c64fbfa 100644 --- a/tests/test_svg_export.py +++ b/tests/test_svg_export.py @@ -731,3 +731,79 @@ def test_segment_constant_translucent_color_applies_alpha_once() -> None: entry for entry in re.findall(r"]*/>", opaque) if 'stroke="red"' in entry ] assert opaque_lines, "opaque constant color should pass through verbatim" + + +def test_static_axis_format_matches_the_client_grammar() -> None: + """`format=` must render the same in a native export as in the browser. + + The two implementations are separate — `fmtNumberSpec`/`fmtTimeSpec` in + js/src/30_ticks.ts for the live chart, `_fmt_number_spec`/`_fmt_time_spec` + in python/xy/_svg.py for SVG/PNG/JPEG/WebP/PDF — so pin the grammar itself, + not just a few outputs. A change to one side without the other fails here. + """ + from pathlib import Path + + from xy import _svg + + client = (Path(__file__).resolve().parents[1] / "js" / "src" / "30_ticks.ts").read_text( + encoding="utf-8" + ) + assert _svg._NUMBER_SPEC.pattern in client, ( + "the numeric grammar diverged from fmtNumberSpec in js/src/30_ticks.ts" + ) + assert _svg._TIME_SPEC_TOKEN.pattern in client, ( + "the strftime subset diverged from fmtTimeSpec in js/src/30_ticks.ts" + ) + + # Behavior, mirroring the worked examples in spec/api/styling.md. + assert _svg._fmt_number_spec(0.4, ".0%") == "40%" + assert _svg._fmt_number_spec(0.4, ".1%") == "40.0%" + assert _svg._fmt_number_spec(14741.0, "$,.0f") == "$14,741" + assert _svg._fmt_number_spec(14741.0, "$,.0fK") == "$14,741K" + assert _svg._fmt_number_spec(-14741.0, "$,.0f") == "$-14,741" + assert _svg._fmt_number_spec(1.5, ".2f") == "1.50" + # Affixes need an explicit `f`/`%` core, so the historical `.N` form is + # unchanged; anything outside the grammar falls back to automatic labels. + assert _svg._fmt_number_spec(1.5, "$.2") is None + assert _svg._fmt_number_spec(1.5, ".2e") is None + assert _svg._fmt_number_spec(float("nan"), ".2f") is None + # A Python-style spec is not rejected: `{:` and `}` are legal affixes under + # the shared grammar, so it renders as one. Asserted to keep both sides + # agreeing on the quirk rather than only on the happy path. + assert _svg._fmt_number_spec(1.5, "{:,.2f}") == "{:1.50}" + + ms = 1704067200000.0 # 2024-01-01T00:00:00Z + assert _svg._fmt_time_spec(ms, "%Y/%m/%d") == "2024/01/01" + assert _svg._fmt_time_spec(ms, "%b %Y") == "Jan 2024" + assert _svg._fmt_time_spec(ms, "%B %d, %Y") == "January 01, 2024" + assert _svg._fmt_time_spec(ms, "%H:%M:%S") == "00:00:00" + # An unknown token is copied through verbatim, as the client does. + assert _svg._fmt_time_spec(ms, "%y") == "%y" + + +def test_static_export_renders_axis_format() -> None: + """End to end: a formatted axis reaches the SVG text nodes, and an + unsupported spec degrades to the automatic labels rather than raising.""" + import re + + import xy + + def tick_text(chart: object) -> set[str]: + return set(re.findall(r">([^<>]+)", chart.to_svg())) # type: ignore[attr-defined] + + pct = xy.line_chart( + xy.line([1, 2, 3], [0.1, 0.2, 0.3]), xy.y_axis(format=".0%"), width=600, height=400 + ) + assert {"10%", "20%", "30%"} <= tick_text(pct) + + money = xy.line_chart( + xy.line([1, 2, 3], [1000, 2000, 3000]), xy.y_axis(format="$,.0f"), width=600, height=400 + ) + assert {"$1,000", "$2,000", "$3,000"} <= tick_text(money) + + plain = xy.line_chart(xy.line([1, 2, 3], [0.1, 0.2, 0.3]), width=600, height=400) + assert tick_text( + xy.line_chart( + xy.line([1, 2, 3], [0.1, 0.2, 0.3]), xy.y_axis(format=".2e"), width=600, height=400 + ) + ) == tick_text(plain)