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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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): `<prefix>(,).N[f|%]<suffix>`. 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:
Expand Down
12 changes: 9 additions & 3 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,22 @@ 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`)
substitutes the tokens it knows and copies every other character through
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
Expand Down
76 changes: 76 additions & 0 deletions tests/test_svg_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,3 +731,79 @@ def test_segment_constant_translucent_color_applies_alpha_once() -> None:
entry for entry in re.findall(r"<line[^>]*/>", 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">([^<>]+)</text>", 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)
Loading