Skip to content

Stop the PNG exporter from deleting text it cannot draw - #316

Merged
Alek99 merged 2 commits into
alek/cust-audit-2from
alek/raster-text-fidelity
Jul 26, 2026
Merged

Stop the PNG exporter from deleting text it cannot draw#316
Alek99 merged 2 commits into
alek/cust-audit-2from
alek/raster-text-fidelity

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

Stacked on #315 (base: alek/cust-audit-2).

The native rasterizer bakes its glyphs into src/font.rs because the Rust core
deliberately avoids FreeType. A character outside that atlas got no glyph and
no advance
— so it did not render as a box, it vanished.

before and after

Left is to_png() before: RésuméRsum, ZürichZrich,
KölnKln, the CJK tick is blank, and the is gone from every y tick.
The same figure's to_svg() renders all of it correctly.

The currency case is not incidental — y_axis(format="€,.0f") shipped in #315,
and its symbol was being deleted from every tick of the exported PNG.

Changes

  • Extend the atlas to Latin-1 Supplement + Latin Extended-A (every language
    written in the Latin script) plus non-ASCII currency. scripts/gen_font.py
    now takes codepoint ranges rather than a hand-typed string, so the coverage
    is auditable. 205 → 424 glyphs.
  • Substitute U+FFFD for anything still unbaked — CJK, Cyrillic, emoji —
    instead of dropping it. §28 asks that a decision the engine makes on the
    user's behalf be visible, and a replacement box is the visible form of "this
    renderer cannot draw that". Zero-width and control characters stay dropped;
    they have nothing to show.

Cost

~48 KB of baked coverage: the core dylib goes 1,170,160 → 1,219,744 bytes
(+4.2%). Measured, not estimated.

CJK and emoji still need engine=xy.Engine.chromium for real glyphs — the
atlas approach cannot cover them, and this PR does not pretend otherwise. It
makes the limit visible instead of silent.

Verification

  • Pixel-level tests, not display-list tests: the dropping happens in Rust, so
    the Python tests compare to_png() bytes for "X<char>" against "X".
    (My first probe instrumented _Cmd.text and wrongly showed everything
    passing — the display list carries the full string either way.)
  • New Rust test pins the atlas rows and the U+FFFD fallback.
  • 2462 Python tests and 119 Rust tests pass; ruff, ruff format, pre-commit
    clean; ty at the 13-diagnostic baseline; abi_smoke passes.
  • spec/api/styling.md documented the silent-skip behavior precisely, so it is
    updated rather than left contradicting the code; spec/design/rust-engine.md
    glyph counts refreshed.

Related, not in this PR

tick_label_angle at any angle other than ±90° is also discarded by this
exporter — labels are drawn horizontally and overflow the canvas, because the
display-list text command carries two rotation flag bits rather than an
angle. That is a wire-format change, so it gets its own PR.

The native rasterizer bakes its glyphs into src/font.rs because the Rust core
avoids FreeType. A character outside that atlas got no glyph AND no advance —
so it did not render as a box, it vanished. `Zürich` exported as `Zrich`, a
`format="€,.0f"` axis lost its symbol on every tick, and a CJK label exported
as blank space, all while the same figure's SVG rendered them correctly.

Two changes:

- Extend the atlas to the Latin-1 Supplement and Latin Extended-A letters —
  every language written in the Latin script — plus non-ASCII currency. The
  generator takes ranges rather than a hand-typed string so the coverage is
  auditable. 205 -> 424 glyphs, ~48 KB of baked coverage, +4% on the core dylib.
- Substitute U+FFFD for anything still unbaked (CJK, Cyrillic, emoji) instead
  of dropping it. §28 asks that a decision the engine makes on the user's
  behalf be visible, and a replacement box is the visible form of "this
  renderer cannot draw that". Zero-width and control characters stay dropped —
  they have nothing to show.

Currency is not incidental: `y_axis(format="€,.0f")` shipped two commits ago,
and its symbol was being deleted from every tick of the exported PNG.

spec/api/styling.md documented the silent-skip behavior precisely, so it is
updated rather than left contradicting the code.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf3d7be1-7916-4e08-98dd-00c7e7714dd9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/raster-text-fidelity

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 103 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing alek/raster-text-fidelity (e339201) with main (d35731d)2

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on alek/cust-audit-2 (fdfac0d) during the generation of this report, so main (d35731d) was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Alek99

Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Fixed in fdfac0d (on alek/cust-audit-2, merged forward into this branch and #318).

The regression was real and it was mine, from the base branch rather than this one: the probe that decides whether color= is a list of CSS colors called arr.tolist() unconditionally, ahead of _factorize_categories — which goes to some trouble to identify equal records in Rust precisely so it never creates N Python objects. Every categorical scatter paid for a full materialization it did not need.

A category column fails the colour test on its very first value, so the probe now gates on entry zero: ["setosa", ...] costs O(1), and only an array that already looks like paint pays for the full scan. That is exactly as strict as the all(...) that follows, which already required every entry to be a colour string, so nothing changes classification.

Local timings at the benchmark's 100k rows, median of 12 runs:

median min
origin/main 2.00 ms 1.61 ms
this branch, after the fix 2.03 ms 1.64 ms

The probe itself went from 7.9 ms to 30 µs on a 500k-row category column.

Pinned with a test rather than a timing assertion — an ndarray subclass whose tolist raises, so the gate cannot silently regress.

🤖 Addressed by Claude Code

@Alek99
Alek99 merged commit 86fed07 into alek/cust-audit-2 Jul 26, 2026
23 checks passed
Alek99 added a commit that referenced this pull request Jul 26, 2026
* Make customization survive the trip to a file

An audit of xy's customizability across eleven dimensions found one theme
behind most of it: options the WebGL client honors, the static exporters
silently drop. A user styles a chart in a notebook, exports it for a report,
and gets something different — with no warning, and no way to notice until
someone reads the report.

Fixed, each verified against the live client:

- The categorical palette cycled per *trace*, not per series. A box is four
  traces and a stem is two, so four box series under a four-color
  xy.theme(palette=...) all wore palette[0], and eight box series drew two
  colors out of the built-in eight. Adding an outlier to one series repainted
  a different one. Marks now take one slot per logical series, and an explicit
  color= takes none, matching matplotlib's property cycle.
- x_axis/y_axis format= reached the browser and nothing else: "$,.0f" read
  $1,000,000 on screen and 1.0e6 in the PNG. The exporters now share the
  client's grammar, and the left gutter grows for labels that need the room.
- A categorical color= channel produced no legend in any export — one row
  bearing the trace name and one color, beside marks painted in three.
- text= on hline/vline/bands/threshold/arrow was dropped by the exporters, and
  xy.marker() drew nothing at all. Placement is now one helper shared by both,
  ported from the client's own label pass.
- Log-axis decades below 1.0 all rendered "0": precision came from the tick
  step, which means nothing on a multiplicative axis.
- color=["#ff0000", "#00ff00"] was factorized into categories, sorted
  alphabetically, and repainted from the palette. Written-out colors now paint
  themselves; named colors stay categorical, since a column of "red"/"green"
  is ordinary data.

Added: xy.theme(palette={category: color}) pins colors to labels rather than
positions, so a category keeps its color across marks and across facet panels
where a positional cycle silently recolors whatever is left.

* Add the before/after evidence sheet for the export-parity fixes

* Gate the literal-color probe before it materializes the column

CodSpeed caught a 68.8% regression on
`test_first_payload_scatter_categorical_color`. The cause is in this branch:
the probe that decides whether `color=` is a list of CSS colors ran
`arr.tolist()` unconditionally, ahead of `_factorize_categories` — which goes
to real trouble to identify equal records in Rust precisely so it never creates
N Python objects. So every categorical scatter paid for a full materialization
it did not need. Measured at ~39% of the payload build for a 500k-row column.

A category column fails the test on its very first value, so gate on entry
zero: `["setosa", ...]` now costs O(1) and only an array that already looks
like paint pays for the full scan. Requiring entry zero to match is exactly as
strict as the `all(...)` that follows, which already demands every entry be a
color string, so no input changes classification.

Local timings at the benchmark's 100k rows, median of 12:
  origin/main  2.00 ms
  before       (probe alone was 7.9 ms at 500k)
  after        2.03 ms

Pinned with a test rather than a timing assertion: an ndarray subclass whose
`tolist` raises.

* Stop the PNG exporter from deleting text it cannot draw (#316)

The native rasterizer bakes its glyphs into src/font.rs because the Rust core
avoids FreeType. A character outside that atlas got no glyph AND no advance —
so it did not render as a box, it vanished. `Zürich` exported as `Zrich`, a
`format="€,.0f"` axis lost its symbol on every tick, and a CJK label exported
as blank space, all while the same figure's SVG rendered them correctly.

Two changes:

- Extend the atlas to the Latin-1 Supplement and Latin Extended-A letters —
  every language written in the Latin script — plus non-ASCII currency. The
  generator takes ranges rather than a hand-typed string so the coverage is
  auditable. 205 -> 424 glyphs, ~48 KB of baked coverage, +4% on the core dylib.
- Substitute U+FFFD for anything still unbaked (CJK, Cyrillic, emoji) instead
  of dropping it. §28 asks that a decision the engine makes on the user's
  behalf be visible, and a replacement box is the visible form of "this
  renderer cannot draw that". Zero-width and control characters stay dropped —
  they have nothing to show.

Currency is not incidental: `y_axis(format="€,.0f")` shipped two commits ago,
and its symbol was being deleted from every tick of the exported PNG.

spec/api/styling.md documented the silent-skip behavior precisely, so it is
updated rather than left contradicting the code.

* Test the numeric core of a formatted label, not the whole label

Review catch, and a genuine crash. The sub-unit log fallback asked "did this
spec collapse the value to zero?" by comparing the FORMATTED label against
zero — but the grammar allows prefixes and suffixes, so a `format="$,.0f"`
axis renders a sub-unit decade as `"$0"`.

  float("$0")   raises ValueError and takes the whole render down
  Number("$0")  is NaN, so `=== 0` is false and the collapsed label ships

Two different wrong answers out of the one layer whose job is to keep the
browser and the exporters identical. `_collapsed_to_zero` / `collapsedToZero`
now strip everything that is not part of the number — affixes and group
separators alike — before the comparison, in both runtimes.

Also from review: a `{category: color}` palette that pins all eight built-in
colors leaves nothing distinct for an unmapped category, so it necessarily
reuses one that is already spoken for. That is unavoidable with eight
defaults, but it should not be silent — the unmapped-category warning now says
when the fallback repeats a pinned color.

* Draw unicode spaces as spaces, and stop over-claiming Latin coverage

Second review round, three real catches:

- Substituting U+FFFD for every unbaked codepoint boxed the non-ASCII SPACES
  too. Locale-aware number formatting emits NBSP (U+00A0) and narrow NBSP
  (U+202F) as group separators, so those arrive through ordinary tick labels —
  and whitespace is drawn by its advance, not its shape, so a box is a worse
  answer than the space it stands for. Whitespace now maps to the ASCII space
  glyph; control and zero-width characters stay dropped; everything else still
  boxes.
- "Every language written in the Latin script" was an over-claim. Latin-1
  Supplement plus Latin Extended-A carries Western, Central and Northern
  European orthographies, but Vietnamese and Romanian's ș/ț live in later
  blocks and still box. Both spec statements now say which blocks, and which
  languages that leaves out.
- rust-engine.md §2.1 still documented 205 glyphs and silent deletion — its own
  text called a substitution glyph with a real advance "the minimum honest
  fix", which is what shipped, so the section is rewritten to describe it. The
  remaining gap it names (a warning at the Python export boundary) is recorded
  as still open rather than quietly dropped.

Also spelled the invisible test characters as escapes: a literal U+200B or
U+FFFD in the source can be stripped by an editor or a copy-paste, silently
reducing the assertion to `_title_png("X") == _title_png("X")`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant