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 ``