Skip to content

fix(embed): resolve pixel tokens on export, not just off-wire - #52

Merged
CSSFrancis merged 1 commit into
mainfrom
fix/export-resolves-pixel-tokens
Aug 2, 2026
Merged

fix(embed): resolve pixel tokens on export, not just off-wire#52
CSSFrancis merged 1 commit into
mainfrom
fix/export-resolves-pixel-tokens

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

The bug

Under the Electron binary transport (APL_BINARY_TRANSPORT=1) a panel's pixels do not travel in the state at all: Plot2D._encode_pixels writes a "\x00bin:<adler>" change-token and the real bytes ride a PLOTBIN frame emitted by _electron._route_change.

Figure._push materialises those tokens back to inline base64 — but only if not _binary_wire(). That gate reads a process-global env var, which is on in a host app even while the push it is guarding is serialising a snapshot.

_sync_for_export() (added in 0.5.0 so exports capture widget positions where they are) re-pushes every panel from inside _repr_utils._widget_state — the one chokepoint save_html / to_html / figure_state all go through. So on a live wire, every export wrote unresolved tokens into the panel traits. A snapshot has no PLOTBIN behind it, so those are dangling references and the pixels are simply lost. Worse, because _push rewrites panel_<id>_json unconditionally, it also silently undid any materialisation the caller had performed beforehand.

The visible casualty is an add_layer overlay. _layerBytes in figure_esm.js bails on a token:

const b64 = layer.image_b64 || '';
if (!b64 || b64.charCodeAt(0) === 0) return { bytes: null, key: '' };  // token, no bytes yet

so the layer silently does not draw — while the base image still does, because imshow encodes it before the plot is attached to its Figure (no _raw_pixels side-table yet) and it is therefore plain base64, never a token. That asymmetry is what made this odd to track down in the wild: the figure looks normal, and only the overlay is missing.

Bisected to 0.5.0. Same figure, same three steps:

step 0.4.2 0.5.0 / 0.6.0 / 0.7.0
Plot2D.resolve_pixel_tokens base64 / base64 base64 / base64
caller materialises the traits base64 / base64 base64 / base64
export chokepoint _widget_state base64 / base64 base64 / TOKEN

The fix

_push grows a resolve_pixels keyword; _sync_for_export passes it. An export always ships real pixels. The live wire is untouched and keeps its token/PLOTBIN split — resolving there would push megabytes of base64 through the comm on every scrub frame, which is the whole reason the transport exists.

Tests

New anyplotlib/tests/test_embed/test_export_pixel_tokens.py, 12 tests pinning both sides:

  • snapshots resolve the base image, the nested layers[i].image_b64 (the copy _layerBytes actually reads), and the layer_<id>_b64 geom key; no token survives anywhere in the state
  • a caller's own pre-export materialisation is no longer clobbered
  • widget positions are still reconciled — the pixel fix must not cost the reason _sync_for_export exists
  • the live wire still emits tokens on an ordinary push and on set_data, and returns to tokens after an export
  • with no binary transport, behaviour is unchanged

6 of the 12 fail on main and all 12 pass here. Full suite green: 2007 passed, 6 skipped.

Under the Electron binary transport a panel's pixels do not travel in the
state: `Plot2D._encode_pixels` writes a "\x00bin:<adler>" change-token and
the bytes ride a PLOTBIN frame. `Figure._push` materialises those tokens
back to inline base64 only when `_binary_wire()` is false — but that gate
reads a process-global env var, which is on in a host app even while the
push being made is serialising a snapshot.

`_sync_for_export` (added in 0.5.0 so snapshots capture widget positions)
re-pushes every panel from inside `_repr_utils._widget_state`, the chokepoint
every export goes through. So under a live wire every export wrote unresolved
tokens into the panel traits — dangling references, since a snapshot has no
PLOTBIN behind it — and, because `_push` rewrites `panel_<id>_json`
unconditionally, it also undid any materialisation the caller had done first.

The visible casualty was an `add_layer` overlay: `_layerBytes` in
figure_esm.js bails on a token (`b64.charCodeAt(0) === 0`), so the layer
silently did not draw, while the base image — encoded before the plot is
attached to its Figure, so plain base64 and never a token — still did.

`_push` grows a `resolve_pixels` keyword and `_sync_for_export` passes it:
an export always ships real pixels. The live wire is untouched and keeps its
token/PLOTBIN split, which the new tests pin from both sides.
@CSSFrancis
CSSFrancis merged commit 2cb946f into main Aug 2, 2026
11 of 12 checks passed
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