Skip to content
Open
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
6 changes: 6 additions & 0 deletions doc/release/next_whats_new/pyodide_backend.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Pyodide backend
---------------

The ``pyodide`` backend, which has been patched into pyodide builds of Matplotlib since
early 2025, is now included in the main Matplotlib repository.
It is an interactive backend based on ``webagg`` and is the default backend used on pyodide.
2 changes: 2 additions & 0 deletions galleries/users_explain/figure/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ GTK4Cairo Cairo rendering to a GTK_ 4.x canvas (requires PyGObject_ and
pycairo_).
wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4).
This backend can be activated in IPython with ``%matplotlib wx``.
pyodide Variant of WebAgg backend that is the default on Pyodide_.
========= ================================================================

.. note::
Expand All @@ -223,6 +224,7 @@ wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4).
.. _Tk: https://www.tcl.tk/
.. _wxWidgets: https://www.wxwidgets.org/
.. _ipympl: https://www.matplotlib.org/ipympl
.. _Pyodide: https://pyodide.org/

.. _ipympl_install:

Expand Down
166 changes: 166 additions & 0 deletions lib/matplotlib/backends/backend_pyodide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Interactive backend for pyodide running in main browser thread, based on webagg."""

import base64
from io import BytesIO
import json
import mimetypes
from pathlib import Path

from pyodide.code import run_js
from pyodide.ffi import create_proxy

from matplotlib.backend_bases import _Backend
from matplotlib._pylab_helpers import Gcf
from . import backend_webagg_core as core


class FigureManagerPyodide(core.FigureManagerWebAgg):
_toolbar2_class = core.NavigationToolbar2WebAgg

@classmethod
def pyplot_show(cls, *, block=None):
PyodideApplication.initialize()
managers = Gcf.get_all_fig_managers()
for manager in managers:
manager.show()

def show(self):
fignum = str(self.num)

js_code = \
"""
var websocket_type = mpl.get_websocket_type();
var websocket = new websocket_type(fig_id);
const parent_element = document.pyodideMplTarget ?? document.body;
const fig = new mpl.figure(fig_id, websocket, null, parent_element);
fig;
"""
js_code = f"var fig_id = '{fignum}';" + js_code

self.js_fig = run_js(js_code)
web_socket = PyodideApplication.MockPythonWebSocket(self, self.js_fig.ws)
web_socket.open(fignum)


class FigureCanvasPyodide(core.FigureCanvasWebAggCore):
manager_class = FigureManagerPyodide

def get_diff_image(self):
ret = super().get_diff_image()
self._force_full = True
return ret

def handle_save(self, event):
figure_id = event['figure_id']
format = event['format']

try:
from js import alert, document
except ImportError:
raise RuntimeError(
"Save not supported as cannot import js.alert and js.document")

mimetype = mimetypes.types_map.get(f".{format}")
if mimetype is None:
alert(f"Cannot download plot, unable to determine mimetype for '{format}'")
return

element = document.createElement('a')
data = BytesIO()
self.figure.savefig(data, format=format)

element.setAttribute(
"href",
"data:{};base64,{}".format(
mimetype, base64.b64encode(data.getvalue()).decode("ascii")
),
)
element.setAttribute("download", f"plot{figure_id}.{format}")
element.style.display = "none"
document.body.appendChild(element)
element.click()
document.body.removeChild(element)


class PyodideApplication():
initialized = False

class MockPythonWebSocket:
supports_binary = True

def __init__(self, manager, js_web_socket):
self.manager = manager
self.js_web_socket = js_web_socket
self.on_message_proxy = None

def open(self, fignum):
self.fignum = int(fignum)
self.on_message_proxy = create_proxy(self.on_message)
self.js_web_socket.open(self.on_message_proxy)
self.manager.add_web_socket(self)

def on_close(self):
self.manager.remove_web_socket(self)
self.on_message_proxy.destroy()
self.on_message_proxy = None

def on_message(self, message):
message = json.loads(message)

# The 'supports_binary' message is on a client-by-client
# basis. The others affect the (shared) canvas as a
# whole.
if message['type'] == 'supports_binary':
self.supports_binary = message['value']
else:
# It is possible for a figure to be closed,
# but a stale figure UI is still sending messages
# from the browser.
if self.manager is not None:
self.manager.handle_json(message)

def send_json(self, content):
self.js_web_socket.receive_json(json.dumps(content))

def send_binary(self, blob):
if self.supports_binary:
self.js_web_socket.receive_binary(blob, binary=True)
else:
data_uri = "data:image/png;base64,{}".format(
blob.encode('base64').replace('\n', ''))
self.js_web_socket.receive_binary(data_uri)

@classmethod
def initialize(cls, url_prefix='', port=None, address=None):
if cls.initialized:
return

try:
from js import document

css = (Path(__file__).parent / "web_backend/css/mpl.css").read_text(
encoding="utf-8")
style = document.createElement('style')
style.textContent = css
document.head.append(style)
except ImportError:
# js.document not available, continue without CSS.
pass

js_content = core.FigureManagerWebAgg.get_javascript(pyodide=True)
set_toolbar_image_callback = run_js(js_content)
set_toolbar_image_callback(create_proxy(PyodideApplication.get_toolbar_image))

cls.initialized = True

@classmethod
def get_toolbar_image(cls, image):
filename = Path(__file__).parent.parent / f"mpl-data/images/{image}.png"
png_bytes = filename.read_bytes()
return png_bytes


@_Backend.export
class _BackendPyodide(_Backend):
FigureCanvas = FigureCanvasPyodide
FigureManager = FigureManagerPyodide
10 changes: 9 additions & 1 deletion lib/matplotlib/backends/backend_webagg_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,14 +509,17 @@ def refresh_all(self):
s.send_binary(diff)

@classmethod
def get_javascript(cls, stream=None):
def get_javascript(cls, stream=None, *, pyodide=False):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra kwarg here isn't elegant, but it keeps backward compatibility with minimal code changes.

Alternatives would be to reimplement this entirely in backend_pyodide to keep it unchanged here, but that would be quite a lot of code duplication. Or this function could call a number of other shorter functions and backend_pyodide could just override the 2 that it needs to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, I'm 👍🏻 on this approach.

if stream is None:
output = StringIO()
else:
output = stream

output.write((Path(__file__).parent / "web_backend/js/mpl.js")
.read_text(encoding="utf-8"))
if pyodide:
output.write((Path(__file__).parent / "web_backend/js/mpl_pyodide.js")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we have already loaded the default mpl.js JavaScript code into the browser page, and then loading mpl_pyodide.js afterwards adds some new code and replaces some of the previous functions.

.read_text(encoding="utf-8"))

toolitems = []
for name, tooltip, image, method in cls.ToolbarCls.toolitems:
Expand All @@ -536,6 +539,11 @@ def get_javascript(cls, stream=None):
output.write("mpl.default_extension = {};".format(
json.dumps(FigureCanvasWebAggCore.get_default_filetype())))

if pyodide:
output.write("mpl.toolbar_image_callback = null;\n")
output.write("mpl.set_toolbar_image_callback = function(c) {\n")
output.write(" mpl.toolbar_image_callback=c;}\n")

if stream is None:
return output.getvalue()

Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ python_sources = [
'backend_pdf.py',
'backend_pgf.py',
'backend_ps.py',
'backend_pyodide.py',
'backend_qt.py',
'backend_qtagg.py',
'backend_qtcairo.py',
Expand Down
4 changes: 4 additions & 0 deletions lib/matplotlib/backends/registry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from enum import Enum
import importlib
import sys


class BackendFilter(Enum):
Expand Down Expand Up @@ -92,6 +93,9 @@ def __init__(self):
"notebook": "nbagg",
}

if sys.platform == 'emscripten':
self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK["pyodide"] = "pyodide"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I am only advertising the existence of the pyodide backend if we are running on emscripten. The alternative would be to always have it present in the list of available backends even when it cannot be used.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know about how other backends (e.g. wegagg) are advertised, but it makes sense to me as people who try to use pyodide-backend in non-emscripten environment would get runtime error.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes sense to do.

In the case of GUI backends it makes sense to advertise backends the user can not use due to missing dependencies so they learn they can install those dependencies to get the backend, but given that this only works in emscripten and will never work on a desktop, advertising it will just be annoying.


def _backend_module_name(self, backend):
if backend.startswith("module://"):
return backend[9:]
Expand Down
32 changes: 0 additions & 32 deletions lib/matplotlib/backends/web_backend/.eslintrc.js

This file was deleted.

3 changes: 2 additions & 1 deletion lib/matplotlib/backends/web_backend/css/mpl.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@

.mpl-widget.active img {
/* Convert black to tab:blue, approximately */
filter: invert(34%) sepia(97%) saturate(468%) hue-rotate(162deg) brightness(96%) contrast(91%);
filter: invert(34%) sepia(97%) saturate(468%) hue-rotate(162deg)
brightness(96%) contrast(91%);
}

button.mpl-widget:focus,
Expand Down
63 changes: 63 additions & 0 deletions lib/matplotlib/backends/web_backend/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import js from "@eslint/js";
import globals from "globals";
import prettier from "eslint-config-prettier/flat";

export default [
{
ignores: ["jquery-ui-*/", "node_modules/**"],
},
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
...globals.jquery,
IPython: "readonly",
MozWebSocket: "readonly",
mpl: "readonly",
mpl_ondownload: "readonly",
},
},
rules: {
indent: ["error", 2, { SwitchCase: 1 }],
"max-len": ["error", { code: 100 }],
"no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
caughtErrors: "none",
}
],
quotes: ["error", "double", { avoidEscape: true }],
},
},
{
files: ["js/**/*.js"],
rules: {
indent: ["error", 4, { SwitchCase: 1 }],
quotes: ["error", "single", { avoidEscape: true }],
},
},
{
files: ["js/mpl_pyodide.js"],
rules: {
"no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
caughtErrors: "none",
args: "none",
}
],
},
},
prettier,
{
rules: {
"comma-dangle": [
"error",
{ functions: "never", objects: "always-multiline" }
],
},
}
];
Loading
Loading