diff --git a/AGENTS.md b/AGENTS.md index 5f6a240d..9fbfc497 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ grep -nE '^\s*(function|const|let) [A-Za-z_]' anyplotlib/figure_esm.js ``` and reconcile against the two numbered tables (the section map near the top and -the 2-D function table). Both were last verified at 12,425 lines. +the 2-D function table). Both were last verified at 12,438 lines. Changelog entries: add a fragment file to `upcoming_changes/` (e.g. `123.new_feature.rst`) — towncrier assembles `CHANGELOG.rst` at release time. diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index cd729499..6f73de6d 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -97,10 +97,10 @@ Rule 5 – Text never clips. Optional gutters earn real layout space: | Generic redraw `_redrawPanel` | 10139 | | **PNG export**: `_compositeCanvas` / `exportCanvas` / `exportPNG` | 10302 / 10498 / 10557 | | Native-resolution render `_withNativeSize` | 10278 | -| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10591 / 10700 / 10879 | -| Export registry `registerExportAction` | 10756 | -| **Embedding API**: `createLocalModel` / `mount` | 11270 / 11326 | -| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11581 / 11968 | +| **Export UI**: `_toast` / `_downloadCanvas` / `_openMenu` | 10591 / 10713 / 10892 | +| Export registry `registerExportAction` | 10769 | +| **Embedding API**: `createLocalModel` / `mount` | 11283 / 11339 | +| **Navigated embed**: `decodeBlocks` / `mountNavigated` | 11594 / 11981 | > **`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` @@ -767,10 +767,11 @@ leaders that cross into the panel included. Pinned by | `_toast` | 10591 | transient bottom-centre message | | `_copyCanvas` | 10626 | clipboard write + feature detection | | `_showPngPreview` | 10650 | framed-document download fallback | -| `_downloadCanvas` | 10700 | `` or the preview | -| `registerExportAction` | 10756 | downstream extension point | -| `_menuRows` / `_openMenu` | 10810 / 10879 | menu model / DOM | -| `_panelAtPoint` | 10991 | hit test (insets first — they sit on top) | +| `_hostSavesPng` / `_canPickFile` | 10702 / 10708 | the parent saves PNGs itself / a system Save dialog exists | +| `_downloadCanvas` | 10713 | ``, the preview, or the host | +| `registerExportAction` | 10769 | downstream extension point | +| `_menuRows` / `_openMenu` | 10823 / 10892 | menu model / DOM | +| `_panelAtPoint` | 11004 | hit test (insets first — they sit on top) | - **An `exportBtn` badge (⤓, beside the help badge) opens the same menu on an ordinary left click.** It is a `role="button"` with `tabIndex=0` and @@ -812,9 +813,15 @@ leaders that cross into the panel included. Pinned by `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. + `anyplotlib_export_png_result` message (`requestId: null`, plus `filename`) + 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. +- **Host saves** (`_hostSavesPng`): a parent that posts + `{type:'anyplotlib_host', savesPng:true}` sets `globalThis.__aplHostSavesPng` + (the page template's listener, parent frame only). Framed saves then post the + image WITHOUT the preview, and `_canPickFile()` is false, so the *Save as…* + row disappears — the host's own Save dialog chooses the folder, and an app + webview may have no "Save image as…" menu for the preview to point at. - **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 @@ -831,6 +838,7 @@ Test hooks: `__apl_menuItems`, `__apl_toastText`, `__apl_menuTheme`, 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_host_saves_png.py` (the host announcement and the framed save), `test_savefig.py` (the Python entry point + view reconciliation). The standalone HTML template (`_repr_utils.build_standalone_html`) captures @@ -840,11 +848,14 @@ render()'s api into `_aplRenderApi`, **also assigns it to `window._aplRenderApi` `{type:'anyplotlib_export_png', requestId, opts}` → `exportPNG(opts)` → replies `{type:'anyplotlib_export_png_result', requestId, dataUrl, width, height}` (or `{…, error}`) to `event.source` (targetOrigin `'*'`). `opts` is forwarded -verbatim, so the new fields work over that channel too. +verbatim, so the new fields work over that channel too. A second listener takes +`{type:'anyplotlib_host', savesPng}` from `window.parent` only and records it +in `globalThis.__aplHostSavesPng` (read by `_hostSavesPng`). Both live in +`PNG_HARVEST_LISTENER`, which the navigated embed installs too. --- -## Navigated-embed runtime (line 11333 to the end of the file) +## Navigated-embed runtime (line 11346 to the end of the file) Everything below `mount()` is module scope, outside `render()`'s closure: pure functions over decoded data plus one entry point that wires them to a mounted @@ -854,16 +865,16 @@ bindings, let it dispatch", rather than a hand-written program per result kind. | Function | Line | Purpose | |----------|------|---------| -| `decodeBlocks` | 11581 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | -| `dense` | 11607 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | -| `ragged` | 11672 | the same three, over a row-pointer block (`offsets` + one array per column) | -| `maskFromWidget` | 11755 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | -| `rasterDisks` | 11795 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | -| `robustLevels` / `toU8` | 11824 / 11865 | the percentile window and the 8-bit code map, one implementation | -| `panelAxis` | 11943 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | -| `installTouchShim` / `reportEmbedHeight` | 11880 / 11899 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | -| `encodeBase64` / `typedArrayBytes` | 11919 / 11927 | a 3-D cloud's geometry channel is base64, not the binary side table | -| `mountNavigated` | 11968 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` | +| `decodeBlocks` | 11594 | one base64 `fetch` → one ArrayBuffer → a typed-array view per manifest entry | +| `dense` | 11620 | `at` / `gather` / `reduce` over a block whose leading axes are the nav axes | +| `ragged` | 11685 | the same three, over a row-pointer block (`offsets` + one array per column) | +| `maskFromWidget` | 11768 | rectangle / circle / annulus widget dict → `Uint8Array` (carries `width`/`height`) | +| `rasterDisks` | 11808 | splat `{x, y, intensity}` rows as filled disks — the base image of a vectors panel | +| `robustLevels` / `toU8` | 11837 / 11878 | the percentile window and the 8-bit code map, one implementation | +| `panelAxis` | 11956 | a 1-D panel's decoded x axis (`_1dXArr`, else `x_axis_b64`) | +| `installTouchShim` / `reportEmbedHeight` | 11893 / 11912 | page chrome: touch → mouse, `postMessage({aplEmbedHeight})` | +| `encodeBase64` / `typedArrayBytes` | 11932 / 11940 | a 3-D cloud's geometry channel is base64, not the binary side table | +| `mountNavigated` | 11981 | mount + bind + dispatch; resolves to the mount handle plus `dispatch`/`index`/`blocks` | `mountNavigated(el, page, opts)` is **async** — the blob decode is a `fetch` of a `data:` URL — so a host `await`s it. `page` is `{state, blocks, bindings, diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index 0c3d79ab..cae1be3f 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -330,9 +330,10 @@ def _widget_px(widget) -> tuple[int, int]: # A host page (or the SpyDE report harvester) asks an embedded figure for a -# composite PNG over postMessage. Both the standalone page and the navigated -# embed install this listener; each assigns ``globalThis.__aplExportPNG`` once -# its figure is mounted, which is also what makes "not ready yet" answerable. +# composite PNG over postMessage, and can announce that it saves exported PNGs +# itself. Both the standalone page and the navigated embed install these +# listeners; each assigns ``globalThis.__aplExportPNG`` once its figure is +# mounted, which is also what makes "not ready yet" answerable. PNG_HARVEST_LISTENER = '''\ // ── PNG export protocol ────────────────────────────────────────────────────── // A parent page (or the SpyDE report harvester) asks this frame over @@ -370,6 +371,20 @@ def _widget_px(widget) -> tuple[int, int]: reply({ error: String(err && err.message || err) }); } }); + +// A host that saves exported PNGs itself (a desktop app with its own Save +// dialog) announces it once: +// → { type: 'anyplotlib_host', savesPng: true } +// after which the export menu's "Save PNG…" only posts the image to it +// ← { type: 'anyplotlib_export_png_result', requestId: null, +// dataUrl, width, height, filename } +// instead of also showing the in-figure "Save image as…" preview. Only the +// parent frame may say so. +window.addEventListener('message', (e) => { + if (!e.data || e.data.type !== 'anyplotlib_host') return; + if (e.source !== window.parent || window.parent === window) return; + globalThis.__aplHostSavesPng = e.data.savesPng === true; +}); ''' diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index e8e1e870..5708ddbc 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -10689,11 +10689,24 @@ fn fs(in : VsOut) -> @location(0) vec4 { // usePicker → showSaveFilePicker, a real system dialog (Chromium, secure // context, user gesture) — costs a Chrome permission prompt // otherwise → , straight to the downloads folder, no prompt - // framed → an in-figure preview, since a sandboxed frame makes a.click() - // a SILENT no-op with nothing to feature detect + // framed → the image is posted to the parent, plus an in-figure preview, + // since a sandboxed frame makes a.click() a SILENT no-op with + // nothing to feature detect + // framed, host saves → posted to the parent only: the host has its own + // Save dialog (a desktop app), so the preview's "right-click → + // Save image as…" would be wrong — an app webview may have no + // such menu at all + + // Set by the page template when the parent announces + // {type:'anyplotlib_host', savesPng:true} (see PNG_HARVEST_LISTENER). + function _hostSavesPng() { + return window.self !== window.top && globalThis.__aplHostSavesPng === true; + } + // Is a system Save dialog available at all? Chromium only, secure context. + // A host that saves already asks where to put the file. function _canPickFile() { - return !!(window.isSecureContext + return !_hostSavesPng() && !!(window.isSecureContext && typeof window.showSaveFilePicker === 'function'); } @@ -10736,7 +10749,7 @@ fn fs(in : VsOut) -> @location(0) vec4 { type: 'anyplotlib_export_png_result', requestId: null, dataUrl, width: canvas.width, height: canvas.height, filename }, '*'); } catch (_) {} - _showPngPreview(dataUrl, filename); + if (!_hostSavesPng()) _showPngPreview(dataUrl, filename); return; } const url = URL.createObjectURL(blob); diff --git a/anyplotlib/tests/test_embed/test_host_saves_png.py b/anyplotlib/tests/test_embed/test_host_saves_png.py new file mode 100644 index 00000000..bc931618 --- /dev/null +++ b/anyplotlib/tests/test_embed/test_host_saves_png.py @@ -0,0 +1,114 @@ +""" +A framed figure's "Save PNG…" when the host page saves PNGs itself. + +In a frame, "Save PNG…" cannot download (a sandboxed frame makes ``a.click()`` +a silent no-op), so it posts the image to the parent and shows an in-figure +preview whose caption points at the browser's "Save image as…". A host with its +own Save dialog — a desktop app, whose webview may have no such menu — +announces ``{type: 'anyplotlib_host', savesPng: true}``; the figure then only +posts the image, and drops the "Save as… (choose folder)" row, since the +host's dialog already chooses the folder. +""" +from __future__ import annotations + +import pathlib +import tempfile +from html import escape + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib._repr_utils import build_standalone_html + +_PARENT_PAGE = """ + + + + + +""" + +_MENU_ROWS = """() => Array.from(document.querySelectorAll('[data-apl-menu] *')) + .filter((element) => element.children.length === 0) + .map((element) => element.textContent)""" + +_OPEN_MENU = """() => document.querySelector( + '[aria-label="Copy or save this figure"]').click()""" + +_CLICK_SAVE_PNG = """() => Array.from(document.querySelectorAll('[data-apl-menu] *')) + .find((element) => element.textContent === 'Save PNG…').click()""" + +_PREVIEW_SHOWN = "() => document.body.innerText.includes('Save image as')" + + +@pytest.fixture +def framed_figure(_pw_browser): + """Open a parent page holding a figure in an iframe; yield ``(page, frame)``.""" + fig, ax = apl.subplots(1, 1, figsize=(320, 240)) + ax.imshow(np.random.default_rng(3).random((16, 16)).astype(np.float32)) + parent = _PARENT_PAGE.replace( + "__SRCDOC__", escape(build_standalone_html(fig, resizable=False), quote=True)) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False) as handle: + handle.write(parent) + path = pathlib.Path(handle.name) + page = _pw_browser.new_page() + try: + page.goto(path.as_uri()) + frame = next(frame for frame in page.frames if frame.parent_frame is not None) + frame.wait_for_function( + "() => typeof globalThis.__aplExportPNG === 'function'", timeout=15_000) + yield page, frame + finally: + page.close() + path.unlink(missing_ok=True) + + +def _save_png(page, frame): + """Open the export menu, click "Save PNG…"; return the menu's rows.""" + frame.evaluate(_OPEN_MENU) + rows = frame.evaluate(_MENU_ROWS) + frame.evaluate(_CLICK_SAVE_PNG) + page.wait_for_function("() => window._saved.length > 0", timeout=5_000) + frame.wait_for_timeout(100) + return rows + + +class TestHostSavesPng: + def test_without_a_host_the_frame_shows_the_preview(self, framed_figure): + page, frame = framed_figure + _save_png(page, frame) + saved = page.evaluate("() => window._saved") + assert saved[0]["dataUrl"].startswith("data:image/png;base64,") + assert saved[0]["filename"].endswith(".png") + assert frame.evaluate(_PREVIEW_SHOWN) + + def test_a_host_that_saves_gets_the_image_and_no_preview(self, framed_figure): + page, frame = framed_figure + page.evaluate("() => window._announceHost()") + frame.wait_for_function("() => globalThis.__aplHostSavesPng === true") + rows = _save_png(page, frame) + saved = page.evaluate("() => window._saved") + assert len(saved) == 1 and saved[0]["dataUrl"].startswith("data:image/png;base64,") + assert not frame.evaluate(_PREVIEW_SHOWN) + # The host's own dialog chooses the folder. + assert not any("choose folder" in (row or "") for row in rows), rows + + def test_only_the_parent_can_announce_itself(self, framed_figure): + page, frame = framed_figure + frame.evaluate( + "() => window.postMessage({type: 'anyplotlib_host', savesPng: true}, '*')") + frame.wait_for_timeout(100) + assert frame.evaluate("() => globalThis.__aplHostSavesPng") is None + _save_png(page, frame) + assert frame.evaluate(_PREVIEW_SHOWN) diff --git a/docs/exporting.rst b/docs/exporting.rst index b3cc48b2..b6199812 100644 --- a/docs/exporting.rst +++ b/docs/exporting.rst @@ -165,8 +165,26 @@ image is written once and the handle discarded. If a host blocks script-started downloads — VS Code notebooks, pages inside a sandboxed ``