Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 34 additions & 23 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 | `<a download>` 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 | `<a download>`, 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
21 changes: 18 additions & 3 deletions anyplotlib/_repr_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});
'''


Expand Down
21 changes: 17 additions & 4 deletions anyplotlib/figure_esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -10689,11 +10689,24 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
// usePicker → showSaveFilePicker, a real system dialog (Chromium, secure
// context, user gesture) — costs a Chrome permission prompt
// otherwise → <a download>, 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');
}

Expand Down Expand Up @@ -10736,7 +10749,7 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
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);
Expand Down
114 changes: 114 additions & 0 deletions anyplotlib/tests/test_embed/test_host_saves_png.py
Original file line number Diff line number Diff line change
@@ -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 = """<!DOCTYPE html>
<html><head><meta charset="utf-8"/><style>html,body{margin:0;padding:0;}</style></head>
<body>
<iframe id="fig" srcdoc="__SRCDOC__" width="360" height="280" style="border:none;"></iframe>
<script>
window._saved = [];
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'anyplotlib_export_png_result' && e.data.requestId === null) {
window._saved.push({filename: e.data.filename, dataUrl: e.data.dataUrl});
}
});
window._announceHost = () => document.getElementById('fig').contentWindow.postMessage(
{type: 'anyplotlib_host', savesPng: true}, '*');
</script>
</body></html>
"""

_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)
22 changes: 20 additions & 2 deletions docs/exporting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<iframe>`` — which it does silently, with no way to detect in
advance, the image appears in an **in-figure preview** captioned *"Right-click
the image → Save image as…"*. That needs no permission and always works.
Embedding hosts also receive the PNG over ``postMessage``.
the image → Save image as…"*. That needs no permission and works in a browser.
Embedding hosts also receive the PNG over ``postMessage``:

.. code-block:: javascript

// ← from the figure's frame, when the user picks "Save PNG…"
{ type: 'anyplotlib_export_png_result', requestId: null,
dataUrl, width, height, filename }

A host that saves that image itself — a desktop app with its own Save dialog,
whose webview may have no "Save image as…" menu at all — announces it once the
frame has loaded:

.. code-block:: javascript

frame.contentWindow.postMessage({ type: 'anyplotlib_host', savesPng: true }, '*');

The figure then only posts the image: no preview, and no *Save as… (choose
folder)* entry, since the host's dialog already chooses the folder. Only the
frame's parent can make this announcement.

.. _export-registry:

Expand Down
1 change: 1 addition & 0 deletions upcoming_changes/78.new_feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A page embedding a figure in an iframe can post ``{type: 'anyplotlib_host', savesPng: true}`` to say it saves exported PNGs itself; the figure's "Save PNG…" then only sends the image to the host, without the in-figure "Save image as…" preview or the "Save as… (choose folder)" entry.
Loading