From c682ca5cf89644d510df810b2d4dd79f680fd68a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 4 Sep 2026 07:34:11 -0500 Subject: [PATCH] feat(export): PNG export, clipboard copy and a right-click save menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer already composited a complete figure PNG — `exportPNG` — but it was reachable only from `mount()`'s handle, the standalone-HTML postMessage protocol, or Playwright. Under plain anywidget nothing captures `render()`'s return value, so a Jupyter kernel could not reach it at all, and there was no Python entry point and no UI. Right-clicking a plot now offers Copy image, Save PNG…, Save full view… and Save at native resolution… for the clicked panel, the same for the whole figure, and a sticky light/dark choice that applies to all of them. Ctrl/Cmd+C copies the plot under the cursor, or the figure when none is hovered. `Figure.savefig(path, source=, theme=, scale=, panel=)` does the same from Python, rendering through the real JavaScript renderer in a headless browser so the file is what the figure actually looks like — including the zoom and contrast set interactively. Embedding hosts add their own formats through `handle.registerExportAction({id, label, scope, handler})`. `exportPNG` gained `panelId`, `source` and `theme`. `panelId` changes only the origin and the extent, so a panel export is by construction the matching sub-rectangle of the figure export. `source='full'` transiently resets the view — and clears the detail tile, without which `_blit2d` stretches a sub-region over the whole fit-rect. `source='native'` resizes the panel so its image area is the data resolution and redraws the decorated stack, so the axes, colourbar, title, markers and widgets come along with it; `p._dprOv = 1` keeps the backing store in exact data pixels. The whole pipeline runs in one synchronous task, so the browser never paints an intermediate state and nothing flickers. Native export cannot work from the browser for a tiled plot: `tile='auto'` is the default and `TILE_THRESHOLD` is 1024, so any image large enough to want it holds only a downsampled overview plus one detail tile. The menu shows that entry disabled with the reason; `savefig` re-encodes the backend at full resolution into the snapshot and runs the same render headless, leaving the live figure untouched. Three pre-existing bugs are fixed here, because the menu makes each reachable: - Panel key handlers matched bare letters without checking modifiers, so Ctrl+C toggled the colourbar and Cmd+S flipped the colour scale to symlog. - `exportPNG` mixed CSS-scaled element rects with an unscaled extent, so a figure shrunk to fit a narrow cell composited into the top-left corner. - Nothing read the browser's view state back into Python, so `save_html`, `to_html` and `figure_state` silently reset the reader's zoom and pan. Does not add an `Examples/` gallery entry — a right-click menu and a keystroke do not appear in a static thumbnail. At native resolution the decorations keep their normal point sizes, so an 11 px tick label is small against a 4096 px image; raising `title_size` / `tick_size` / `*_label_size` is the workaround. 47 new Playwright tests and no new golden baselines: assertions use exact sizes, known-LUT pixel probes and the literal `_makeTheme` constants. Assisted-by: Claude Opus 5 (1M context) --- anyplotlib/FIGURE_ESM.md | 294 ++++-- anyplotlib/_export.py | 262 ++++++ anyplotlib/_repr_utils.py | 10 +- anyplotlib/figure/_figure.py | 97 ++ anyplotlib/figure_esm.js | 884 ++++++++++++++++-- anyplotlib/sphinx_anywidget/_scraper.py | 65 +- anyplotlib/tests/test_embed/_export_utils.py | 68 ++ anyplotlib/tests/test_embed/conftest.py | 80 ++ .../tests/test_embed/test_export_menu.py | 494 ++++++++++ .../tests/test_embed/test_export_png.py | 94 +- .../tests/test_embed/test_export_sources.py | 375 ++++++++ anyplotlib/tests/test_embed/test_savefig.py | 241 +++++ docs/embedding.rst | 41 +- docs/exporting.rst | 218 +++++ docs/index.rst | 12 + upcoming_changes/+export-css-scale.bugfix.rst | 6 + .../+export-key-modifiers.bugfix.rst | 7 + upcoming_changes/+export-view-sync.bugfix.rst | 7 + upcoming_changes/+png-export.new_feature.rst | 45 + 19 files changed, 3017 insertions(+), 283 deletions(-) create mode 100644 anyplotlib/_export.py create mode 100644 anyplotlib/tests/test_embed/_export_utils.py create mode 100644 anyplotlib/tests/test_embed/conftest.py create mode 100644 anyplotlib/tests/test_embed/test_export_menu.py create mode 100644 anyplotlib/tests/test_embed/test_export_sources.py create mode 100644 anyplotlib/tests/test_embed/test_savefig.py create mode 100644 docs/exporting.rst create mode 100644 upcoming_changes/+export-css-scale.bugfix.rst create mode 100644 upcoming_changes/+export-key-modifiers.bugfix.rst create mode 100644 upcoming_changes/+export-view-sync.bugfix.rst create mode 100644 upcoming_changes/+png-export.new_feature.rst 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'