diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 5cfed3192..ad64d46cb 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -53,28 +53,28 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | b64 array decode helpers | 109 | | **Rich-text (mini-TeX) engine**: `_texRuns` / `_texLayout` / `_drawTex` | 161 / 228 / 250 | | **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 313 / 323 / 333 | -| **Layout engine** `applyLayout` | 774 | -| `_buildCanvasStack` | 857 | -| `_createPanelDOM` | 999 | -| `_createInsetDOM` / `_applyAllInsetStates` | 1129 / 1512 | -| `_resizePanelDOM` | 2225 | -| **2D drawing**: `_imgFitRect` | 2384 | -| `draw2d` | 2713 | -| `drawScaleBar2d` / `drawColorbar2d` | 2908 / 3188 | -| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3007 / 3030 / 3043 | -| `_drawAxes2d` (ticks, labels, title) | 3242 | -| `drawOverlay2d` / `drawMarkers2d` | 3395 / 3559 | -| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2533 / 2557 / 2618 | -| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 | -| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 3942 / 4024 | -| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4064 / 4079 / 4100 | -| **3D drawing**: `draw3d` | 5298 | -| Event emission `_emitEvent` | 6135 | -| 3D event handlers `_attachEvents3d` | 6187 | -| **1D drawing**: `draw1d` | 6408 | -| `_drawLine` (1D series + markers) | 6561 | -| `drawOverlay1d` / `drawMarkers1d` | 6854 / 6938 | -| Marker hit-test `_markerHitTest2d` | 7206 | +| **Layout engine** `applyLayout` | 778 | +| `_buildCanvasStack` | 861 | +| `_createPanelDOM` | 1003 | +| `_createInsetDOM` / `_applyAllInsetStates` | 1144 / 1538 | +| `_resizePanelDOM` | 2251 | +| **2D drawing**: `_imgFitRect` | 2415 | +| `draw2d` | 2744 | +| `drawScaleBar2d` / `drawColorbar2d` | 2939 / 3219 | +| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 3038 / 3061 / 3074 | +| `_drawAxes2d` (ticks, labels, title) | 3273 | +| `drawOverlay2d` / `drawMarkers2d` | 3426 / 3590 | +| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2564 / 2588 / 2649 | +| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 734 / 765 | +| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 4434 / 4516 | +| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4556 / 4571 / 4592 | +| **3D drawing**: `draw3d` | 5568 | +| Event emission `_emitEvent` | 6405 | +| 3D event handlers `_attachEvents3d` | 6462 | +| **1D drawing**: `draw1d` | 6686 | +| `_drawLine` (1D series + markers) | 6839 | +| `drawOverlay1d` / `drawMarkers1d` | 7132 / 7216 | +| Marker hit-test `_markerHitTest2d` | 7484 | > **`raster` marker (1D/PlotXY)** — `drawMarkers1d` has a `type==='raster'` > branch that blits a single RGBA image across data-coord `extent` (the fast @@ -83,16 +83,20 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: > redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on > the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block > clips it to a curved sector. -| Panel event dispatch `_attachPanelEvents` | 7463 | -| 2D events `_attachEvents2d` | 7505 | -| 1D events `_attachEvents1d` | 7889 | -| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8161 / 8440 | -| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8353 / 8367 / 8396 / 8431 | -| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8565 / 8638 | -| Shared-axis propagation `_getShareGroups` | 8709 | -| Figure resize `_applyFigResizeDOM` | 8773 | -| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8964 / 9027 / 9403 | -| Generic redraw `_redrawPanel` | 9593 | +| Panel event dispatch `_attachPanelEvents` | 7741 | +| 2D events `_attachEvents2d` | 7783 | +| 1D events `_attachEvents1d` | 8176 | +| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8451 / 8730 | +| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8643 / 8657 / 8686 / 8721 | +| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8855 / 8928 | +| Shared-axis propagation `_getShareGroups` | 8999 | +| Figure resize `_applyFigResizeDOM` | 9063 | +| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 9254 / 9317 / 9693 | +| Generic redraw `_redrawPanel` | 9883 | +| **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10042 / 10238 / 10292 | +| Native-resolution render `_withNativeSize` | 10018 | +| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10326 / 10420 / 10563 | +| Export registry `registerExportAction` | 10451 | > **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one > that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods` @@ -610,63 +614,195 @@ Python → JS (set widget position from Python): --- -## PNG export (`exportPNG`) +## PNG export (`exportPNG` / `exportCanvas`) -`render()` now RETURNS an internal API object `{ panels, exportPNG, -_gpuDisposeImagePanel, _gpuDisposePanel }` (anywidget ignores render()'s return; -`mount()` captures it — this also fixed a latent bug where the old -mount-handle `dispose()` referenced `panels`/`_gpuDispose*` from module scope, -which are inside `render`'s closure, and silently threw). The mount handle -exposes `handle.exportPNG({scale=1, includeWidgets=false}) → -Promise<{dataUrl,width,height}>`. +`render()` RETURNS an internal API object `{ panels, exportPNG, exportCanvas, +registerExportAction, unregisterExportAction, calloutCanvas, _drawCallouts, +figMarkerCanvas, _drawFigureMarkers, _gpuDisposeImagePanel, _gpuDisposePanel }` +(anywidget ignores render()'s return; `mount()` captures it). The mount handle +re-exposes `exportPNG`, `exportCanvas`, `registerExportAction` and +`unregisterExportAction`. -`exportPNG(opts)` (inside `render`, near `redrawAll`) composites the WHOLE -figure onto one offscreen canvas at `devicePixelRatio × scale`: +``` +exportPNG({ scale=1, includeWidgets=false, panelId=null, + source='view'|'full'|'native', theme='current'|'light'|'dark' }) + → Promise<{dataUrl, width, height}> +exportCanvas(same opts) → {canvas, width, height} // synchronous, throws +``` + +| Function | Line | Purpose | +|----------|------|---------| +| `_cssScale` | 9918 | inverse of `_applyScale`'s `transform:scale()` | +| `_panelBox` | 9929 | the element whose rect bounds one panel | +| `_neutralizeView` / `_restoreView` | 9938 / 9963 | transient whole-extent view | +| `_nativeGeom` / `_nativeGuard` | 9978 / 9993 | native size + why-not message | +| `_withNativeSize` | 10018 | resize → redraw → run → restore | +| `_compositeCanvas` | 10042 | the compositor (`_drawEl` / `_drawPanel` …) | +| `exportCanvas` / `exportPNG` | 10238 / 10292 | orchestrator / data-URL wrapper | + +**The whole pipeline is ONE synchronous task** — theme swap, view reset, native +resize, composite, restore — so the browser never paints an intermediate state +and nothing flickers. This is only possible because the draw path is fully +synchronous (no `createImageBitmap`, no rAF in the draw functions). + +### `theme` + +Swap the closure's `theme`, `redrawAll()`, composite, restore, `redrawAll()`. +**No CSS work is needed**: `drawImage` copies a canvas's backing store and never +its CSS `background`, and every draw function fills its own bitmap with theme +colours (`_blit2d` → `theme.bgCanvas`, `_drawAxes2d` → `theme.axisBg`, `draw3d` +/ `draw1d` / `drawBar` → `theme.bgPlot`, `_compositeCanvas` → `theme.bg`). The +inline CSS set at DOM-creation time IS stale during the swap, but is never +painted. No cache is theme-keyed (`blitCache` keys on bytes+LUT, `_lutKey` has +no theme term), so nothing survives the redraw. + +### `source` + +- **`view`** — as displayed. Zoom, pan and contrast exactly as on screen. +- **`full`** — `_neutralizeView` resets 2-D `zoom`/`center_x`/`center_y`, 1-D + `view_x0`/`view_x1`, 3-D `zoom` (NOT azimuth/elevation — orientation is + content the user chose). Bar has no view state. It also **clears the detail + tile**: a tile covers only the pre-reset region, so leaving it in place lets + `_blit2d` stretch that sub-region over the whole fit-rect. + Nothing is written to the model — shared-axis propagation + (`_propagateZoom2d` / `_propagateView1d`) only runs from event handlers, and + `_emitViewChanged` early-returns while `_exporting`. +- **`native`** — ONE 2-D panel at one output pixel per data pixel, WITH its + axes, colorbar, title, markers and widgets. `_withNativeSize` inverts the + gutter math at the top of `_resizePanelDOM` (keep the two in step), sets + `p._dprOv = 1` so the backing store is exact data pixels, forces + `st.gpu_mode='off'` (a native-size WebGPU surface would reconfigure to a + buffer far larger than the display), then `_resizePanelDOM` + `_redrawPanel` + do all the work — decorations come along for free. Composited at + `outScale = 1`. + Refused (with a message naming the way out) for a non-2-D panel, a missing + `panelId`, an empty panel, a size over `EXPORT_MAX_SIDE` (16384) / + `EXPORT_MAX_AREA` (2^28), and — importantly — for a **tiled** panel: + `TILE_THRESHOLD` is 1024 and `tile='auto'` is the default, so any image large + enough to want a native export holds only an overview + one detail tile in the + browser. That case must go through `fig.savefig(path, source='native')`, + which re-encodes the backend at full resolution into the snapshot + (`_export._temporarily_untiled`) and then runs this same native render + headless. + +### `panelId` + +Changes **only the origin and the extent**; every draw loop is untouched and the +smaller output canvas clips the rest. So a panel export is by construction the +matching sub-rectangle of the figure export — overlapping insets and callout +leaders that cross into the panel included. Pinned by +`TestPanelCrop::test_panel_export_matches_the_figure_sub_rectangle`. + +### Compositing details - **WebGPU hazard first**: a WebGPU canvas's drawing buffer is only valid right - after its render pass, so exportPNG force-calls `draw2d(p)` on every - active-GPU 2-D panel and `draw3d(p)` on every active-GPU 3-D panel - (`p._gpu==='active' && p.gpuCanvas` visible, `_gpuImg`/`_gpuObj` present) to - re-submit its pass, THEN composites in the SAME synchronous task — so - `drawImage(gpuCanvas,…)` reads live pixels, not a blank buffer. (draw3d's - active-GPU path uploads + submits in-task, no rAF, so the same-task re-render - suffices for 3-D too — without it a scatter3d/voxels panel exported as an - empty background rectangle; see `TestExportGpu3d` in - `tests/test_embed/test_export_png.py`.) -- **Extent**: `fig_width/height + 2×8 px` gridDiv padding (NOT the measured - `gridDiv` width — a bare `mount()` page has no `.apl-outer` inline-block CSS, - so the grid container can stretch to the viewport). **Origin**: `gridDiv`'s - top-left (grid tracks are fixed-px + left-anchored, so panels sit correctly). + after its render pass, so `_compositeCanvas` force-calls `draw2d(p)` on every + active-GPU 2-D panel and `draw3d(p)` on every active-GPU 3-D panel, THEN + composites in the SAME synchronous task. +- **`_cssScale`**: `_applyScale` shrinks `outerDiv` with `transform:scale(s)` + whenever the figure is wider than its container — the normal Jupyter case for + a wide figure. `getBoundingClientRect()` then reports VISUAL px while the + extent comes from `fig_width`/`fig_height` (native px), so every rect delta is + multiplied by `1/s`, read straight off the computed transform matrix (NOT off + `offsetWidth`/rect — a native export resizes a panel mid-flight, which would + corrupt a layout-derived ratio). Without this the panels composite into the + top-left corner of an otherwise blank canvas; see + `TestCssScale::test_export_fills_the_canvas_when_the_figure_is_css_scaled`. +- **Extent**: figure = `fig_width/height + 2×8 px` gridDiv padding (NOT the + measured `gridDiv` width — a bare `mount()` page has no `.apl-outer` + inline-block CSS, so the container can stretch); panel = the wrapper's own + rect (it is explicitly sized `pw×ph` and cannot stretch). **Origin**: the + corresponding element's top-left. - **Per-panel z-order** (`_drawEl` positions each canvas by its `getBoundingClientRect()` relative to the root): gpuCanvas (z0) → plotCanvas - (z1) → x/yAxisCanvas → cbCanvas → [overlayCanvas z5 only if `includeWidgets`] - → markersCanvas (z6) → scaleBar (z7) → titleCanvas (z8). Grid panels first, - then insets (`p.isInset`) on top — **each titled inset's title bar text is - drawn directly onto the output canvas right after its canvas stack** - (`_drawInsetTitle`; the title bar is plain DOM — a `
`/``, not a - canvas — so `_drawEl` alone never captures it; approximates the on-screen - CSS: 11px sans-serif, `theme.tickText` colour, left-padded to the titleBar's - rect) — then the figure-level `calloutCanvas` (region indications) composited - LAST. Status bars / stats overlays are excluded. + (z1) → x/yAxisCanvas → cbCanvas → [overlayCanvas z5 only if `includeWidgets`, + redrawn onto a scratch canvas with handles suppressed] → markersCanvas (z6) → + scaleBar (z7) → titleCanvas (z8). Grid panels first, then insets — each titled + inset's title bar text drawn directly (`_drawInsetTitle`; the title bar is + plain DOM, so `_drawEl` never captures it) — then the figure-level + `calloutCanvas` and `figMarkerCanvas` last. Status bars / stats overlays are + excluded. - **Coordinate snapping** (`_drawEl`): `dx`/`dy` are `Math.round()`ed from the element's `left`/`top`, and `dw`/`dh` are the ROUNDED `right`/`bottom` edge minus the rounded `dx`/`dy` — never `Math.round(width)` directly. This makes - two elements that share a CSS edge (e.g. adjacent grid panels, or a - panel's axis-gutter canvas against its plotCanvas) round that shared edge to - the *same* output pixel on both sides. Without it, at a fractional effective - scale (`devicePixelRatio × opts.scale` — e.g. a real 150% Windows display, or - `scale: 1.25`), each element's `dx`/`dw` were computed independently as raw - floats, and adjacent elements could round their common boundary to different - output pixels — a 1px background-coloured seam (or overlap) exactly at the - join. See `TestExportMultiPanel::test_fractional_scale_no_seam_between_panels` - in `tests/test_embed/test_export_png.py` (reproduced with - `device_scale_factor=1.5`). -- Ends with `out.toDataURL('image/png')`; rejects the promise with a message on - failure (no 2-D context, `toDataURL` throw). + two elements that share a CSS edge round that edge to the *same* output pixel + on both sides; without it a fractional effective scale produced a 1 px + background-coloured seam at the join. See + `TestExportMultiPanel::test_fractional_scale_no_seam_between_panels`. + +## Export UI: menu, clipboard, download, registry + +| Function | Line | Purpose | +|----------|------|---------| +| `_toast` | 10326 | transient bottom-centre message | +| `_copyCanvas` | 10361 | clipboard write + feature detection | +| `_showPngPreview` | 10385 | framed-document download fallback | +| `_downloadCanvas` | 10420 | `` or the preview | +| `registerExportAction` | 10451 | downstream extension point | +| `_menuRows` / `_openMenu` | 10504 / 10563 | menu model / DOM | +| `_panelAtPoint` | 10673 | hit test (insets first — they sit on top) | + +- **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an + ordinary left click.** Hosts — JupyterLab, PyCharm, VS Code — install their own + `contextmenu` and keyboard handlers and may swallow a right-click or Ctrl/Cmd+C + before the page sees it, so the badge is the route that always works. Its + `mousedown` calls `preventDefault()` so it does not steal focus from the + hovered panel, which is what `_focusedPanelId()` uses to scope the menu. +- **The `contextmenu` and Ctrl+C listeners are on `outerDiv`, not on each + `overlayCanvas`.** This deliberately differs from every other input handler: + `overlayCanvas` is positioned at `imgX/imgY` with size `imgW×imgH`, so it + covers only the *image area* — the axis gutters (`PAD_L` 58, `PAD_B` 42), the + colorbar and the title strip are NOT under it, and a per-overlay listener + would silently do nothing on 30-40 % of a panel with physical axes. +- **Ctrl/Cmd+C** is panel-scoped when a panel's `overlayCanvas` is + `document.activeElement` (mouseenter focuses it), figure-scoped otherwise. + Bound to `outerDiv` rather than `document` so it never hijacks Ctrl+C for the + surrounding notebook. +- **Modifier guard**: the per-panel keydown handlers now `return` on + `ctrl/meta/alt` AFTER the unconditional `key_down` emit. Before this, Ctrl+C + toggled the colorbar and Cmd+S (JupyterLab's save) flipped the colour scale to + symlog, because both matched on the bare letter. +- **Two save entries.** *Save PNG…* is `` — no prompt. *Save as…* + (only listed when `_canPickFile()`) uses `showSaveFilePicker`, which hands the + page a PERSISTENT writable handle and therefore triggers Chrome's + file-editing permission prompt — too much for a plain save, right when the + user asked to choose a folder. A dialog the user closed and one that never + opened *both* reject with `AbortError`, so the name cannot separate them; one + that never rendered returns in well under `PICKER_MIN_MS` (250 ms), which is + the discriminator. +- **Download fallback**: a sandboxed frame without `allow-downloads` makes + `a.click()` a SILENT no-op — no exception, no event, nothing to feature + detect. `window.self !== window.top` is the one checkable condition that + separates the reliable case (JupyterLab / Notebook 7 render inline; a + `save_html` page opened directly is top-level) from the unreliable one (VS + Code webviews, `_repr_html_` iframes, nbconvert output). When framed, the + result is posted to the parent under the existing + `anyplotlib_export_png_result` message AND shown as an in-figure preview whose + caption points at the browser's own "Save image as…", which needs no + permission and is never blocked. +- **Clipboard**: gated on `isSecureContext && navigator.clipboard && + ClipboardItem && clipboard.write`. The Blob is built SYNCHRONOUSLY from the + data URL (not via the async `toBlob` callback) so the write stays inside the + user-gesture task. The `_repr_html_` iframes carry `allow="clipboard-write"`, + without which Chrome blocks the write in an opaque-origin frame. +- **Registry**: `registerExportAction({id, label, group, scope:'panel'| + 'figure'|'both', order, enabled, handler})` returns an unregister function. + `handler(ctx)` receives `{panelId, kind, isInset, state, theme, themeName, + figure, exportPNG, exportCanvas, downloadPNG, copyPNG, toast, model, event}`. + +Test hooks: `__apl_menuItems`, `__apl_toastText`, `__apl_menuTheme`, +`__apl_previewOpen`, `__apl_nativeLimits`, `__apl_nativeGuard`. + +Tests: `tests/test_embed/test_export_png.py` (the pre-existing contract), +`test_export_sources.py` (panelId / source / theme / CSS scale), +`test_export_menu.py` (menu, clipboard, download, registry), +`test_savefig.py` (the Python entry point + view reconciliation). The standalone HTML template (`_repr_utils.build_standalone_html`) captures -render()'s api into `_aplRenderApi` and adds a `message` listener: +render()'s api into `_aplRenderApi`, **also assigns it to `window._aplRenderApi`** +(module scope is not global scope, so `page.evaluate` — and therefore +`Figure.savefig` — cannot reach it otherwise), and adds a `message` listener: `{type:'anyplotlib_export_png', requestId, opts}` → `exportPNG(opts)` → replies `{type:'anyplotlib_export_png_result', requestId, dataUrl, width, height}` (or -`{…, error}`) to `event.source` (targetOrigin `'*'`) — the same channel the -`awi_state` postMessages ride. Tests: `tests/test_embed/test_export_png.py`. +`{…, error}`) to `event.source` (targetOrigin `'*'`). `opts` is forwarded +verbatim, so the new fields work over that channel too. diff --git a/anyplotlib/_export.py b/anyplotlib/_export.py new file mode 100644 index 000000000..05dfa7662 --- /dev/null +++ b/anyplotlib/_export.py @@ -0,0 +1,262 @@ +"""Render a :class:`~anyplotlib.Figure` to a PNG file. + +The renderer is the JavaScript one, so ``savefig`` drives it in a headless +Chromium rather than duplicating the LUT, gutter geometry and label engine in +Python: the figure is serialised to a standalone page and its ``exportPNG`` is +called through ``window._aplRenderApi``. + +``source='native'`` needs the full array in the browser, which a **tiled** plot +never has — above ``Plot2D.TILE_THRESHOLD`` the page holds a downsampled +overview plus one detail tile. For that case the plot is re-encoded at full +resolution into the snapshot only (:func:`_temporarily_untiled`). + +Playwright is optional and imported lazily. +""" +from __future__ import annotations + +import base64 +import contextlib +import pathlib +import tempfile + +import numpy as np + +from anyplotlib._utils import _normalize_image + +__all__ = ["savefig"] + +#: Refuse to materialise a frame larger than this — a 16384² float64 array is +#: already 2 GB, and failing here beats an OOM. +MAX_NATIVE_PIXELS = 1 << 28 # 268 435 456 px + +_PLAYWRIGHT_HINT = ( + "savefig() renders the figure in a headless browser and needs Playwright:\n" + "\n" + ' pip install "anyplotlib[docs]"\n' + " playwright install chromium\n" +) + + +# ── headless page plumbing (shared with the Sphinx thumbnail scraper) ───────── + +def run_in_page(html: str, page_fn, *, timeout_ms: int = 30_000, + color_scheme: str | None = None): + """Load *html* in headless Chromium and return ``page_fn(page)``. + + Waits for ``window._aplReady`` and two animation frames. Playwright's sync + API cannot run inside a live asyncio loop (the Jupyter case), so the session + moves to a worker thread when one is detected. + """ + try: + import playwright # noqa: F401 + except ImportError as exc: # pragma: no cover - env dep + raise RuntimeError(_PLAYWRIGHT_HINT) from exc + + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + + def _run(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch( + headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"]) + page = None + try: + page = browser.new_page() + if color_scheme: + page.emulate_media(color_scheme=color_scheme) + page.goto(tmp.as_uri()) + page.wait_for_function( + "() => window._aplReady === true", timeout=timeout_ms) + page.evaluate("() => new Promise(r => requestAnimationFrame(" + "() => requestAnimationFrame(r)))") + return page_fn(page) + finally: + if page is not None: + page.close() + browser.close() + + try: + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(_run).result() + return _run() + finally: + tmp.unlink(missing_ok=True) + + +def _standalone_html(fig) -> str: + """The figure's standalone page, with the ``_aplReady`` sentinel injected.""" + from anyplotlib._repr_utils import build_standalone_html + + html = build_standalone_html(fig, resizable=False) + return html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + + +# ── full-resolution re-encode for tiled plots ──────────────────────────────── + +def _full_resolution_array(plot) -> np.ndarray: + """Sample a tiled plot's backend at full resolution. + + Backend rows are in source order, so an ``origin='lower'`` backend is + flipped to display order here — as the overview and detail tiles are. + """ + backend = plot._tile_backend + h, w = backend.full_shape + if h * w > MAX_NATIVE_PIXELS: + raise ValueError( + f"source='native' would materialise a {w}x{h} frame " + f"({w * h:,} px), over the {MAX_NATIVE_PIXELS:,} px limit. " + "Export a sub-region (set_xlim/set_ylim then source='view'), or " + "raise anyplotlib._export.MAX_NATIVE_PIXELS if you have the memory.") + arr = backend.sample(0, w, 0, h, w, h, plot._integration_method) + if backend.origin == "lower": + arr = np.flipud(arr) + return np.ascontiguousarray(arr) + + +#: Tiling keys, saved and restored around the full-resolution re-encode. +_TILE_STATE_KEYS = ( + "image_b64", "image_width", "image_height", "base_width", "base_height", + "tile_enabled", "display_min", "display_max", "raw_min", "raw_max", + "raw_is_int", "detail_b64", "detail_region", "detail_width", + "detail_height", "detail_min", "detail_max", "detail_is_int", +) + + +@contextlib.contextmanager +def _temporarily_untiled(plot): + """Re-encode *plot* at full resolution for the duration of the block. + + Only the panel's ``_state`` is touched and every key is restored, so the + live figure is unchanged. The current display window is reused as the + quantisation clim, so the export carries the contrast on screen. + """ + st = plot._state + saved = {k: st.get(k) for k in _TILE_STATE_KEYS} + saved_tile_on = plot._tile_on + try: + raw = _full_resolution_array(plot) + h, w = raw.shape[:2] + clim = (st.get("display_min"), st.get("display_max")) + clim = clim if all(v is not None for v in clim) and clim[1] > clim[0] else None + img_u8, vmin, vmax = _normalize_image(raw, clim=clim) + st.update({ + "image_b64": plot._encode_pixels("image_b64", img_u8), + "image_width": w, + "image_height": h, + # base_* == 0 → the base texture IS the full image + "base_width": 0, + "base_height": 0, + "tile_enabled": False, + "display_min": vmin, + "display_max": vmax, + "raw_min": vmin, + "raw_max": vmax, + # integral source → the renderer can invert codes to exact values + "raw_is_int": bool(np.issubdtype(raw.dtype, np.integer)), + # any detail tile describes the old base + "detail_b64": "", "detail_region": [], "detail_width": 0, + "detail_height": 0, "detail_min": None, "detail_max": None, + "detail_is_int": False, + }) + plot._tile_on = False + yield + finally: + st.update(saved) + plot._tile_on = saved_tile_on + + +# ── the public entry point ─────────────────────────────────────────────────── + +def _resolve_panel(fig, panel): + """Return ``(panel_id, plot)`` for *panel*, or ``(None, None)``.""" + if panel is None: + return None, None + plots = fig._plots_map + if isinstance(panel, str): + if panel not in plots: + raise ValueError( + f"no panel with id {panel!r}; this figure has " + f"{sorted(plots)!r}") + return panel, plots[panel] + for pid, plot in plots.items(): + if plot is panel: + return pid, plot + raise ValueError("panel= is not a plot of this figure") + + +def savefig(fig, path, *, source: str = "view", theme: str = "current", + scale: float = 1, include_widgets: bool = True, + panel=None, timeout_ms: int = 30_000) -> pathlib.Path: + """Write *fig* to *path* as a PNG. See :meth:`anyplotlib.Figure.savefig`.""" + if source not in ("view", "full", "native"): + raise ValueError( + f"source must be 'view', 'full' or 'native', got {source!r}") + if theme not in ("current", "light", "dark"): + raise ValueError( + f"theme must be 'current', 'light' or 'dark', got {theme!r}") + + out = pathlib.Path(path) + panel_id, plot = _resolve_panel(fig, panel) + + if source == "native": + if plot is None: + two_d = [(pid, p) for pid, p in fig._plots_map.items() + if getattr(p, "_state", {}).get("kind") == "2d"] + if len(two_d) != 1: + raise ValueError( + "source='native' exports one image panel, and this figure has " + f"{len(two_d)} — name it with panel= or panel=.") + panel_id, plot = two_d[0] + if plot._state.get("kind") != "2d": + raise ValueError( + "source='native' is only available for 2-D image panels; " + f"panel {panel_id!r} is {plot._state.get('kind')!r}.") + + opts = {"source": source, "theme": theme, "scale": scale, + "includeWidgets": bool(include_widgets)} + if panel_id is not None: + opts["panelId"] = panel_id + + def _export(page): + return page.evaluate( + """(opts) => { + const api = window._aplRenderApi; + if (!api || typeof api.exportPNG !== 'function') + throw new Error('figure not ready (exportPNG unavailable)'); + return api.exportPNG(opts).then(r => r.dataUrl); + }""", opts) + + tiled = (source == "native" and plot is not None + and bool(plot._state.get("tile_enabled"))) + if tiled: + # The browser only ever received an overview; push the real pixels into + # the snapshot so the ordinary native render has something to draw. + with _temporarily_untiled(plot): + data_url = run_in_page(_standalone_html(fig), _export, + timeout_ms=timeout_ms) + else: + data_url = run_in_page(_standalone_html(fig), _export, + timeout_ms=timeout_ms) + + prefix = "data:image/png;base64," + if not data_url.startswith(prefix): + raise RuntimeError(f"unexpected export payload: {data_url[:48]!r}") + out.write_bytes(base64.b64decode(data_url[len(prefix):])) + return out diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index 2ebd51104..a7b4e0991 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -245,6 +245,10 @@ def _widget_px(widget) -> tuple[int, int]: const renderFn = mod.default?.render ?? mod.render; if (typeof renderFn === "function") {{ _aplRenderApi = renderFn({{ model, el }}); + // Module scope is not global scope: without this, page.evaluate() and any + // host page script cannot reach exportPNG (only the postMessage protocol + // below can). anyplotlib.savefig() drives the export through this handle. + window._aplRenderApi = _aplRenderApi; }} else {{ el.textContent = "ESM has no render() export"; }} @@ -307,7 +311,9 @@ def _widget_px(widget) -> tuple[int, int]: // and receives back, on event.source (targetOrigin '*'): // ← {{ type: 'anyplotlib_export_png_result', requestId, dataUrl, width, height }} // ← {{ type: 'anyplotlib_export_png_result', requestId, error }} (on failure) -// `opts` is forwarded verbatim to handle.exportPNG ({{ scale?, includeWidgets? }}). +// `opts` is forwarded verbatim to exportPNG: +// {{ scale?, includeWidgets?, panelId?, source?, theme? }} +// source: 'view' | 'full' | 'native' theme: 'current' | 'light' | 'dark' window.addEventListener('message', (e) => {{ if (!e.data || e.data.type !== 'anyplotlib_export_png') return; const requestId = e.data.requestId; @@ -441,6 +447,7 @@ def repr_html_iframe(widget, *, resizable: bool = False, f'
' f'