Skip to content

Commit 3aaa9a2

Browse files
Matplotlib 3.8.4 update: restore WASM backend and partially restore HTML5 Canvas backend (#64)
* Ignore build-related files * Check existence of toolbar * Temporarily disable event listeners, add notes * Use path-based MathTextParser to render math text * Add note about current state of HTML5 backend * Redirect HTMLCanvas backend to WASM backend --------- Co-authored-by: Gyeongjae Choi <def6488@gmail.com>
1 parent 90638fa commit 3aaa9a2

File tree

3 files changed

+217
-43
lines changed

3 files changed

+217
-43
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Build-related files
2+
/build
3+
/matplotlib_pyodide.egg-info

matplotlib_pyodide/browser_backend.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,15 @@ def ignore(event):
164164
rubberband.setAttribute("tabindex", "0")
165165
# Event handlers are added to the canvas "on top", even though most of
166166
# the activity happens in the canvas below.
167-
add_event_listener(rubberband, "mousemove", self.onmousemove)
168-
add_event_listener(rubberband, "mouseup", self.onmouseup)
169-
add_event_listener(rubberband, "mousedown", self.onmousedown)
170-
add_event_listener(rubberband, "mouseenter", self.onmouseenter)
171-
add_event_listener(rubberband, "mouseleave", self.onmouseleave)
172-
add_event_listener(rubberband, "keyup", self.onkeyup)
173-
add_event_listener(rubberband, "keydown", self.onkeydown)
167+
# TODO: with 0.2.3, we temporarily disable event listeners for the rubberband canvas.
168+
# This shall be revisited in a future release.
169+
# add_event_listener(rubberband, "mousemove", self.onmousemove)
170+
# add_event_listener(rubberband, "mouseup", self.onmouseup)
171+
# add_event_listener(rubberband, "mousedown", self.onmousedown)
172+
# add_event_listener(rubberband, "mouseenter", self.onmouseenter)
173+
# add_event_listener(rubberband, "mouseleave", self.onmouseleave)
174+
# add_event_listener(rubberband, "keyup", self.onkeyup)
175+
# add_event_listener(rubberband, "keydown", self.onkeydown)
174176
context = rubberband.getContext("2d")
175177
context.strokeStyle = "#000000"
176178
context.setLineDash([2, 2])
@@ -180,8 +182,13 @@ def ignore(event):
180182

181183
# The bottom bar, with toolbar and message display
182184
bottom = document.createElement("div")
183-
toolbar = self.toolbar.get_element()
184-
bottom.appendChild(toolbar)
185+
186+
# Check if toolbar exists before trying to get its element
187+
# c.f. https://github.com/pyodide/pyodide/pull/4510
188+
if self.toolbar is not None:
189+
toolbar = self.toolbar.get_element()
190+
bottom.appendChild(toolbar)
191+
185192
message = document.createElement("div")
186193
message.id = self._id + "message"
187194
message.setAttribute("style", "min-height: 1.5em")

matplotlib_pyodide/html5_canvas_backend.py

Lines changed: 198 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
1+
#
2+
# HTMl5 Canvas backend for Matplotlib to use when running Matplotlib in Pyodide, first
3+
# introduced via a Google Summer of Code 2019 project:
4+
# https://summerofcode.withgoogle.com/archive/2019/projects/4683094261497856
5+
#
6+
# Associated blog post:
7+
# https://blog.pyodide.org/posts/canvas-renderer-matplotlib-in-pyodide
8+
#
9+
# TODO: As of release 0.2.3, this backend is not yet fully functional following
10+
# an update from Matplotlib 3.5.2 to 3.8.4 in Pyodide in-tree, please refer to
11+
# https://github.com/pyodide/pyodide/pull/4510.
12+
#
13+
# This backend has been redirected to use the WASM backend in the meantime, which
14+
# is now fully functional. The source code for the HTML5 Canvas backend is still
15+
# available in this file, and shall be updated to work in a future release.
16+
#
17+
# Readers are advised to look at https://github.com/pyodide/matplotlib-pyodide/issues/64
18+
# and at https://github.com/pyodide/matplotlib-pyodide/pull/65 for information
19+
# around the status of this backend and on how to contribute to its restoration
20+
# for future releases. Thank you!
21+
122
import base64
223
import io
324
import math
425
from functools import lru_cache
526

27+
import matplotlib.pyplot as plt
628
import numpy as np
7-
from matplotlib import __version__, interactive
29+
from matplotlib import __version__, figure, interactive
30+
from matplotlib._enums import CapStyle
831
from matplotlib.backend_bases import (
932
FigureManagerBase,
1033
GraphicsContextBase,
1134
RendererBase,
1235
_Backend,
1336
)
37+
from matplotlib.backends import backend_agg
1438
from matplotlib.colors import colorConverter, rgb2hex
1539
from matplotlib.font_manager import findfont
1640
from matplotlib.ft2font import LOAD_NO_HINTING, FT2Font
@@ -20,14 +44,17 @@
2044
from PIL import Image
2145
from PIL.PngImagePlugin import PngInfo
2246

47+
# Redirect to the WASM backend
2348
from matplotlib_pyodide.browser_backend import FigureCanvasWasm, NavigationToolbar2Wasm
49+
from matplotlib_pyodide.wasm_backend import FigureCanvasAggWasm, FigureManagerAggWasm
2450

2551
try:
2652
from js import FontFace, ImageData, document
2753
except ImportError as err:
2854
raise ImportError(
2955
"html5_canvas_backend is only supported in the browser in the main thread"
3056
) from err
57+
3158
from pyodide.ffi import create_proxy
3259

3360
_capstyle_d = {"projecting": "square", "butt": "butt", "round": "round"}
@@ -144,12 +171,31 @@ def restore(self):
144171
self.renderer.ctx.restore()
145172

146173
def set_capstyle(self, cs):
174+
"""
175+
Set the cap style for lines in the graphics context.
176+
177+
Parameters
178+
----------
179+
cs : CapStyle or str
180+
The cap style to use. Can be a CapStyle enum value or a string
181+
that can be converted to a CapStyle.
182+
"""
183+
if isinstance(cs, str):
184+
cs = CapStyle(cs)
185+
186+
# Convert the JoinStyle enum to its name if needed
187+
if hasattr(cs, "name"):
188+
cs = cs.name.lower()
189+
147190
if cs in ["butt", "round", "projecting"]:
148191
self._capstyle = cs
149192
self.renderer.ctx.lineCap = _capstyle_d[cs]
150193
else:
151194
raise ValueError(f"Unrecognized cap style. Found {cs}")
152195

196+
def get_capstyle(self):
197+
return self._capstyle
198+
153199
def set_clip_rectangle(self, rectangle):
154200
self.renderer.ctx.save()
155201
if not rectangle:
@@ -204,7 +250,11 @@ def __init__(self, ctx, width, height, dpi, fig):
204250
self.ctx.width = self.width
205251
self.ctx.height = self.height
206252
self.dpi = dpi
207-
self.mathtext_parser = MathTextParser("bitmap")
253+
254+
# Create path-based math text parser; as the bitmap parser
255+
# was deprecated in 3.4 and removed after 3.5
256+
self.mathtext_parser = MathTextParser("path")
257+
208258
self._get_font_helper = lru_cache(maxsize=50)(self._get_font_helper)
209259

210260
# Keep the state of fontfaces that are loading
@@ -240,14 +290,135 @@ def _matplotlib_color_to_CSS(self, color, alpha, alpha_overrides, is_RGB=True):
240290

241291
return CSS_color
242292

293+
def _math_to_rgba(self, s, prop, rgb):
294+
"""Convert math text to an RGBA array using path parser and figure"""
295+
from io import BytesIO
296+
297+
# Get the text dimensions and generate a figure
298+
# of the right rize.
299+
width, height, depth, _, _ = self.mathtext_parser.parse(s, dpi=72, prop=prop)
300+
301+
fig = figure.Figure(figsize=(width / 72, height / 72))
302+
303+
# Add text to the figure
304+
# Note: depth/height gives us the baseline position
305+
fig.text(0, depth / height, s, fontproperties=prop, color=rgb)
306+
307+
backend_agg.FigureCanvasAgg(fig)
308+
309+
buf = BytesIO() # render to PNG
310+
fig.savefig(buf, dpi=self.dpi, format="png", transparent=True)
311+
buf.seek(0)
312+
313+
rgba = plt.imread(buf)
314+
return rgba, depth
315+
316+
def _draw_math_text_path(self, gc, x, y, s, prop, angle):
317+
"""Draw mathematical text using paths directly on the canvas.
318+
319+
This method renders math text by drawing the actual glyph paths
320+
onto the canvas, rather than creating a temporary image.
321+
322+
Parameters
323+
----------
324+
gc : GraphicsContextHTMLCanvas
325+
The graphics context to use for drawing
326+
x, y : float
327+
The position of the text baseline in pixels
328+
s : str
329+
The text string to render
330+
prop : FontProperties
331+
The font properties to use for rendering
332+
angle : float
333+
The rotation angle in degrees
334+
"""
335+
width, height, depth, glyphs, rects = self.mathtext_parser.parse(
336+
s, dpi=self.dpi, prop=prop
337+
)
338+
339+
self.ctx.save()
340+
341+
self.ctx.translate(x, self.height - y)
342+
if angle != 0:
343+
self.ctx.rotate(-math.radians(angle))
344+
345+
self.ctx.fillStyle = self._matplotlib_color_to_CSS(
346+
gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha()
347+
)
348+
349+
for font, fontsize, _, ox, oy in glyphs:
350+
self.ctx.save()
351+
self.ctx.translate(ox, -oy)
352+
353+
font.set_size(fontsize, self.dpi)
354+
verts, codes = font.get_path()
355+
356+
verts = verts * fontsize / font.units_per_EM
357+
358+
path = Path(verts, codes)
359+
360+
transform = Affine2D().scale(1.0, -1.0)
361+
self._path_helper(self.ctx, path, transform)
362+
self.ctx.fill()
363+
364+
self.ctx.restore()
365+
366+
for x1, y1, x2, y2 in rects:
367+
self.ctx.fillRect(x1, -y2, x2 - x1, y2 - y1)
368+
369+
self.ctx.restore()
370+
371+
def _draw_math_text(self, gc, x, y, s, prop, angle):
372+
"""Draw mathematical text using the most appropriate method.
373+
374+
This method tries direct path rendering first, and falls back to
375+
the image-based approach if needed.
376+
377+
Parameters
378+
----------
379+
gc : GraphicsContextHTMLCanvas
380+
The graphics context to use for drawing
381+
x, y : float
382+
The position of the text baseline in pixels
383+
s : str
384+
The text string to render
385+
prop : FontProperties
386+
The font properties to use for rendering
387+
angle : float
388+
The rotation angle in degrees
389+
"""
390+
try:
391+
self._draw_math_text_path(gc, x, y, s, prop, angle)
392+
except Exception as e:
393+
# If path rendering fails, we fall back to image-based approach
394+
print(f"Path rendering failed, falling back to image: {str(e)}")
395+
396+
rgba, depth = self._math_to_rgba(s, prop, gc.get_rgb())
397+
398+
angle = math.radians(angle)
399+
if angle != 0:
400+
self.ctx.save()
401+
self.ctx.translate(x, y)
402+
self.ctx.rotate(-angle)
403+
self.ctx.translate(-x, -y)
404+
405+
self.draw_image(gc, x, -y - depth, np.flipud(rgba))
406+
407+
if angle != 0:
408+
self.ctx.restore()
409+
243410
def _set_style(self, gc, rgbFace=None):
244411
if rgbFace is not None:
245412
self.ctx.fillStyle = self._matplotlib_color_to_CSS(
246413
rgbFace, gc.get_alpha(), gc.get_forced_alpha()
247414
)
248415

249-
if gc.get_capstyle():
250-
self.ctx.lineCap = _capstyle_d[gc.get_capstyle()]
416+
capstyle = gc.get_capstyle()
417+
if capstyle:
418+
# Get the string name if it's an enum
419+
if hasattr(capstyle, "name"):
420+
capstyle = capstyle.name.lower()
421+
self.ctx.lineCap = _capstyle_d[capstyle]
251422

252423
self.ctx.strokeStyle = self._matplotlib_color_to_CSS(
253424
gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha()
@@ -329,42 +500,21 @@ def _get_font(self, prop):
329500
def get_text_width_height_descent(self, s, prop, ismath):
330501
w: float
331502
h: float
503+
d: float
332504
if ismath:
333-
image, d = self.mathtext_parser.parse(s, self.dpi, prop)
334-
image_arr = np.asarray(image)
335-
h, w = image_arr.shape
505+
# Use the path parser to get exact metrics
506+
width, height, depth, _, _ = self.mathtext_parser.parse(
507+
s, dpi=72, prop=prop
508+
)
509+
return width, height, depth
336510
else:
337511
font, _ = self._get_font(prop)
338512
font.set_text(s, 0.0, flags=LOAD_NO_HINTING)
339513
w, h = font.get_width_height()
340514
w /= 64.0
341515
h /= 64.0
342516
d = font.get_descent() / 64.0
343-
return w, h, d
344-
345-
def _draw_math_text(self, gc, x, y, s, prop, angle):
346-
rgba, descent = self.mathtext_parser.to_rgba(
347-
s, gc.get_rgb(), self.dpi, prop.get_size_in_points()
348-
)
349-
height, width, _ = rgba.shape
350-
angle = math.radians(angle)
351-
if angle != 0:
352-
self.ctx.save()
353-
self.ctx.translate(x, y)
354-
self.ctx.rotate(-angle)
355-
self.ctx.translate(-x, -y)
356-
self.draw_image(gc, x, -y - descent, np.flipud(rgba))
357-
if angle != 0:
358-
self.ctx.restore()
359-
360-
def load_font_into_web(self, loaded_face, font_url):
361-
fontface = loaded_face.result()
362-
document.fonts.add(fontface)
363-
self.fonts_loading.pop(font_url, None)
364-
365-
# Redraw figure after font has loaded
366-
self.fig.draw()
367-
return fontface
517+
return w, h, d
368518

369519
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
370520
if ismath:
@@ -421,6 +571,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
421571
if angle != 0:
422572
self.ctx.restore()
423573

574+
def load_font_into_web(self, loaded_face, font_url):
575+
fontface = loaded_face.result()
576+
document.fonts.add(fontface)
577+
self.fonts_loading.pop(font_url, None)
578+
579+
# Redraw figure after font has loaded
580+
self.fig.draw()
581+
return fontface
582+
424583

425584
class FigureManagerHTMLCanvas(FigureManagerBase):
426585
def __init__(self, canvas, num):
@@ -443,8 +602,13 @@ def set_window_title(self, title):
443602

444603
@_Backend.export
445604
class _BackendHTMLCanvas(_Backend):
446-
FigureCanvas = FigureCanvasHTMLCanvas
447-
FigureManager = FigureManagerHTMLCanvas
605+
# FigureCanvas = FigureCanvasHTMLCanvas
606+
# FigureManager = FigureManagerHTMLCanvas
607+
# Note: with release 0.2.3, we've redirected the HTMLCanvas backend to use the WASM backend
608+
# for now, as the changes to the HTMLCanvas backend are not yet fully functional.
609+
# This will be updated in a future release.
610+
FigureCanvas = FigureCanvasAggWasm
611+
FigureManager = FigureManagerAggWasm
448612

449613
@staticmethod
450614
def show(*args, **kwargs):

0 commit comments

Comments
 (0)