Skip to content

Commit 42ccad0

Browse files
committed
fix(embed): resolve pixel tokens on export, not just off-wire
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.
1 parent 78b6819 commit 42ccad0

3 files changed

Lines changed: 206 additions & 3 deletions

File tree

anyplotlib/figure/_figure.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def _register_panel(self, ax: Axes, plot) -> None:
300300
self._push(pid)
301301
self._push_layout()
302302

303-
def _push(self, panel_id: str) -> None:
303+
def _push(self, panel_id: str, *, resolve_pixels: bool = False) -> None:
304304
"""Serialise one panel and write to its trait.
305305
306306
Inside a :meth:`batch` block, pushes are coalesced: each panel is
@@ -309,6 +309,14 @@ def _push(self, panel_id: str) -> None:
309309
many per-frame pushes of a linked-view update (set_data + set_title +
310310
widget moves on the same panel) into one serialise/transfer per panel
311311
— the dominant cost over a Pyodide comm boundary.
312+
313+
Parameters
314+
----------
315+
resolve_pixels : bool, optional
316+
Force pixel change-tokens to be materialised to inline base64 even
317+
when the binary transport is live. Set by :meth:`_sync_for_export`
318+
because a snapshot has no PLOTBIN channel to carry the bytes; see
319+
the token discussion below.
312320
"""
313321
plot = self._plots_map.get(panel_id)
314322
if plot is None:
@@ -329,7 +337,15 @@ def _push(self, panel_id: str) -> None:
329337
# save_html / Jupyter figure with no binary channel — materialise the
330338
# real base64 inline so the pixels actually travel. ``_binary_wire``
331339
# matches the producer's gate (``Plot2D._encode_pixels``).
332-
if geom_keys and not _binary_wire() and hasattr(plot, "resolve_pixel_tokens"):
340+
#
341+
# ``resolve_pixels`` overrides that gate for an EXPORT. A snapshot is by
342+
# definition off-wire: whatever PLOTBIN would have delivered never
343+
# arrives, so a token left in the state is a dangling reference and the
344+
# pixels are simply lost. The gate reads a process-global env var, which
345+
# is on in a host app even while THIS push is serialising a snapshot —
346+
# so the export has to say so explicitly.
347+
if (geom_keys and (resolve_pixels or not _binary_wire())
348+
and hasattr(plot, "resolve_pixel_tokens")):
333349
plot.resolve_pixel_tokens(state)
334350
if geom_keys and self.has_trait(gname):
335351
# Split heavy geometry into its own channel. Detect change by
@@ -814,9 +830,17 @@ def _sync_for_export(self) -> None:
814830
Called from ``_repr_utils._widget_state``, the one chokepoint every
815831
export path goes through. The live Jupyter path deliberately keeps
816832
using targeted pushes and does not pay this cost.
833+
834+
The re-push passes ``resolve_pixels=True``: this rewrites the panel
835+
traits, and under a live binary transport an ordinary push leaves pixel
836+
change-tokens in place for PLOTBIN to fill in. A snapshot has no
837+
PLOTBIN, so those tokens would be dangling — and because the re-push
838+
rewrites ``panel_<id>_json`` unconditionally it would also undo any
839+
materialisation the caller did beforehand. An export always ships real
840+
pixels.
817841
"""
818842
for panel_id in list(self._plots_map):
819-
self._push(panel_id)
843+
self._push(panel_id, resolve_pixels=True)
820844

821845
def _push_panel_fields(self, panel_id: str, fields: dict) -> None:
822846
"""Apply a small set of changed *fields* to a panel, then push once.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
Snapshots must carry real pixels, never a dangling binary change-token.
3+
4+
Under the Electron binary transport (``APL_BINARY_TRANSPORT=1``) a panel's
5+
image bytes do not travel in the state at all: ``Plot2D._encode_pixels`` writes
6+
a ``"\\x00bin:<adler>"`` change-token and the real bytes ride a PLOTBIN frame
7+
emitted by ``_electron._route_change``. That split is right for a live wire and
8+
wrong for a snapshot — ``save_html`` / ``to_html`` / ``figure_state`` produce a
9+
document with no PLOTBIN behind it, so a token left in the state resolves to
10+
nothing and the pixels are simply lost.
11+
12+
``Figure._sync_for_export`` therefore re-pushes with ``resolve_pixels=True``.
13+
It has to say so explicitly because the transport gate is a process-global env
14+
var, which is on in a host app even while that push is serialising a snapshot.
15+
16+
Regression: the re-push (added for widget-position capture) rewrote
17+
``panel_<id>_json`` unconditionally with unresolved tokens, which both lost the
18+
pixels and undid any materialisation a caller had done beforehand. An overlay
19+
LAYER was the visible casualty — ``_layerBytes`` in figure_esm.js bails on a
20+
token (``b64.charCodeAt(0) === 0``), so the layer silently did not draw while
21+
the base image, which is plain base64, still did.
22+
"""
23+
from __future__ import annotations
24+
25+
import json
26+
27+
import numpy as np
28+
import pytest
29+
30+
import anyplotlib as apl
31+
from anyplotlib.embed import figure_state
32+
33+
34+
TOKEN = "\x00bin:"
35+
36+
37+
@pytest.fixture
38+
def binary_wire(monkeypatch):
39+
"""Turn on the same env gate the Electron host sets."""
40+
monkeypatch.setenv("APL_BINARY_TRANSPORT", "1")
41+
42+
43+
def _fig_with_layer():
44+
"""A base image + one overlay layer, both on the token path.
45+
46+
``imshow`` encodes the base BEFORE the plot is attached to its Figure, so
47+
``_encode_pixels`` finds no ``_raw_pixels`` side-table and falls back to
48+
base64 for it; ``add_layer`` runs after the attach and does produce a token.
49+
One ``set_data`` puts the base on the token path too — the state a live host
50+
is actually in once anything has redrawn. (That asymmetry is why the
51+
regression looked so odd in the wild: the base image still rendered and only
52+
the overlay vanished.)
53+
"""
54+
img = np.linspace(0, 1, 32 * 32, dtype=np.float32).reshape(32, 32)
55+
fig, ax = apl.subplots(1, 1, figsize=(300, 300))
56+
plot = ax.imshow(img, cmap="gray")
57+
plot.add_layer(img, cmap="magma", alpha=0.5)
58+
plot.set_data(img)
59+
return fig, plot
60+
61+
62+
def _panel(state, plot):
63+
return json.loads(state[f"panel_{plot._id}_json"])
64+
65+
66+
def _geom(state, plot):
67+
return json.loads(state[f"panel_{plot._id}_geom"])
68+
69+
70+
def _is_b64(value):
71+
return isinstance(value, str) and value != "" and not value.startswith(TOKEN)
72+
73+
74+
class TestSnapshotResolvesPixelTokens:
75+
def test_producer_really_emits_tokens(self, binary_wire):
76+
"""Guard the premise: without this, the tests below prove nothing."""
77+
fig, plot = _fig_with_layer()
78+
state = plot.to_state_dict()
79+
assert state["image_b64"].startswith(TOKEN)
80+
assert state["layers"][0]["image_b64"].startswith(TOKEN)
81+
82+
def test_base_image_is_inline_base64(self, binary_wire):
83+
fig, plot = _fig_with_layer()
84+
assert _is_b64(_geom(figure_state(fig), plot)["image_b64"])
85+
86+
def test_layer_pixels_are_inline_base64(self, binary_wire):
87+
"""The nested copy is the one figure_esm.js `_layerBytes` reads."""
88+
fig, plot = _fig_with_layer()
89+
layer = _panel(figure_state(fig), plot)["layers"][0]
90+
assert _is_b64(layer["image_b64"])
91+
92+
def test_layer_geom_key_is_inline_base64(self, binary_wire):
93+
fig, plot = _fig_with_layer()
94+
geom = _geom(figure_state(fig), plot)
95+
assert _is_b64(geom[f"layer_{plot._state['layers'][0]['id']}_b64"])
96+
97+
def test_no_token_survives_anywhere_in_the_snapshot(self, binary_wire):
98+
fig, plot = _fig_with_layer()
99+
blob = json.dumps(figure_state(fig))
100+
# json.dumps escapes NUL as \\u0000.
101+
assert "\\u0000bin:" not in blob
102+
103+
def test_caller_materialisation_is_not_undone(self, binary_wire):
104+
"""A host that resolves the traits itself before exporting keeps them.
105+
106+
``_sync_for_export`` rewrites ``panel_<id>_json`` unconditionally, so an
107+
unresolved re-push would clobber the caller's work — the exact shape of
108+
the reported regression.
109+
"""
110+
fig, plot = _fig_with_layer()
111+
state = plot.to_state_dict()
112+
plot.resolve_pixel_tokens(state)
113+
resolved = state["layers"][0]["image_b64"]
114+
setattr(fig, f"panel_{plot._id}_json", json.dumps(state))
115+
116+
after = _panel(figure_state(fig), plot)["layers"][0]["image_b64"]
117+
assert after == resolved
118+
119+
def test_multi_layer_all_resolved(self, binary_wire):
120+
img = np.linspace(0, 1, 16 * 16, dtype=np.float32).reshape(16, 16)
121+
fig, ax = apl.subplots(1, 1, figsize=(300, 300))
122+
plot = ax.imshow(img, cmap="gray")
123+
for cmap in ("magma", "cividis", "plasma"):
124+
plot.add_layer(img, cmap=cmap, alpha=0.4)
125+
layers = _panel(figure_state(fig), plot)["layers"]
126+
assert len(layers) == 3
127+
assert all(_is_b64(ly["image_b64"]) for ly in layers)
128+
129+
def test_widget_positions_still_reconciled(self, binary_wire):
130+
"""The pixel fix must not cost the reason _sync_for_export exists."""
131+
fig, plot = _fig_with_layer()
132+
widget = plot.add_widget("rectangle", x=2, y=2, w=4, h=4)
133+
widget.set(x=21, y=23)
134+
got = _panel(figure_state(fig), plot)["overlay_widgets"][0]
135+
assert got["x"] == 21 and got["y"] == 23
136+
137+
138+
class TestLiveWireStillUsesTokens:
139+
"""The live path must keep its token/PLOTBIN split — that is the whole
140+
point of the binary transport, and resolving there would push megabytes of
141+
base64 through the comm on every scrub frame."""
142+
143+
def test_ordinary_push_keeps_the_token(self, binary_wire):
144+
fig, plot = _fig_with_layer()
145+
fig._push(plot._id)
146+
geom = json.loads(getattr(fig, f"panel_{plot._id}_geom"))
147+
assert geom["image_b64"].startswith(TOKEN)
148+
layer_key = f"layer_{plot._state['layers'][0]['id']}_b64"
149+
assert geom[layer_key].startswith(TOKEN)
150+
151+
def test_set_data_keeps_the_token(self, binary_wire):
152+
fig, plot = _fig_with_layer()
153+
plot.set_data(np.zeros((32, 32), dtype=np.float32))
154+
geom = json.loads(getattr(fig, f"panel_{plot._id}_geom"))
155+
assert geom["image_b64"].startswith(TOKEN)
156+
157+
def test_push_after_export_returns_to_tokens(self, binary_wire):
158+
"""An export must not leave the live wire on the base64 path."""
159+
fig, plot = _fig_with_layer()
160+
figure_state(fig)
161+
plot.set_data(np.ones((32, 32), dtype=np.float32))
162+
geom = json.loads(getattr(fig, f"panel_{plot._id}_geom"))
163+
assert geom["image_b64"].startswith(TOKEN)
164+
165+
166+
class TestNoBinaryTransport:
167+
"""With no binary wire the state was always inline base64; keep it so."""
168+
169+
def test_snapshot_is_inline_base64(self, monkeypatch):
170+
monkeypatch.delenv("APL_BINARY_TRANSPORT", raising=False)
171+
fig, plot = _fig_with_layer()
172+
layer = _panel(figure_state(fig), plot)["layers"][0]
173+
assert _is_b64(layer["image_b64"])
174+
assert _is_b64(_geom(figure_state(fig), plot)["image_b64"])

upcoming_changes/52.bugfix.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed ``save_html`` / ``to_html`` / ``figure_state`` dropping image pixels
2+
under the Electron binary transport: the snapshot kept the ``"\x00bin:"``
3+
change-tokens whose bytes only ever ride the live PLOTBIN channel, so an
4+
overlay added with :meth:`~anyplotlib.Plot2D.add_layer` did not render in the
5+
exported document.

0 commit comments

Comments
 (0)