Skip to content

Commit

Permalink
Matplotlib 3.8.4 update: restore WASM backend and partially restore H…
Browse files Browse the repository at this point in the history
…TML5 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 <[email protected]>
  • Loading branch information
agriyakhetarpal and ryanking13 authored Dec 16, 2024
1 parent 90638fa commit 3aaa9a2
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 43 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Build-related files
/build
/matplotlib_pyodide.egg-info
25 changes: 16 additions & 9 deletions matplotlib_pyodide/browser_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,15 @@ def ignore(event):
rubberband.setAttribute("tabindex", "0")
# Event handlers are added to the canvas "on top", even though most of
# the activity happens in the canvas below.
add_event_listener(rubberband, "mousemove", self.onmousemove)
add_event_listener(rubberband, "mouseup", self.onmouseup)
add_event_listener(rubberband, "mousedown", self.onmousedown)
add_event_listener(rubberband, "mouseenter", self.onmouseenter)
add_event_listener(rubberband, "mouseleave", self.onmouseleave)
add_event_listener(rubberband, "keyup", self.onkeyup)
add_event_listener(rubberband, "keydown", self.onkeydown)
# TODO: with 0.2.3, we temporarily disable event listeners for the rubberband canvas.
# This shall be revisited in a future release.
# add_event_listener(rubberband, "mousemove", self.onmousemove)
# add_event_listener(rubberband, "mouseup", self.onmouseup)
# add_event_listener(rubberband, "mousedown", self.onmousedown)
# add_event_listener(rubberband, "mouseenter", self.onmouseenter)
# add_event_listener(rubberband, "mouseleave", self.onmouseleave)
# add_event_listener(rubberband, "keyup", self.onkeyup)
# add_event_listener(rubberband, "keydown", self.onkeydown)
context = rubberband.getContext("2d")
context.strokeStyle = "#000000"
context.setLineDash([2, 2])
Expand All @@ -180,8 +182,13 @@ def ignore(event):

# The bottom bar, with toolbar and message display
bottom = document.createElement("div")
toolbar = self.toolbar.get_element()
bottom.appendChild(toolbar)

# Check if toolbar exists before trying to get its element
# c.f. https://github.com/pyodide/pyodide/pull/4510
if self.toolbar is not None:
toolbar = self.toolbar.get_element()
bottom.appendChild(toolbar)

message = document.createElement("div")
message.id = self._id + "message"
message.setAttribute("style", "min-height: 1.5em")
Expand Down
232 changes: 198 additions & 34 deletions matplotlib_pyodide/html5_canvas_backend.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
#
# HTMl5 Canvas backend for Matplotlib to use when running Matplotlib in Pyodide, first
# introduced via a Google Summer of Code 2019 project:
# https://summerofcode.withgoogle.com/archive/2019/projects/4683094261497856
#
# Associated blog post:
# https://blog.pyodide.org/posts/canvas-renderer-matplotlib-in-pyodide
#
# TODO: As of release 0.2.3, this backend is not yet fully functional following
# an update from Matplotlib 3.5.2 to 3.8.4 in Pyodide in-tree, please refer to
# https://github.com/pyodide/pyodide/pull/4510.
#
# This backend has been redirected to use the WASM backend in the meantime, which
# is now fully functional. The source code for the HTML5 Canvas backend is still
# available in this file, and shall be updated to work in a future release.
#
# Readers are advised to look at https://github.com/pyodide/matplotlib-pyodide/issues/64
# and at https://github.com/pyodide/matplotlib-pyodide/pull/65 for information
# around the status of this backend and on how to contribute to its restoration
# for future releases. Thank you!

import base64
import io
import math
from functools import lru_cache

import matplotlib.pyplot as plt
import numpy as np
from matplotlib import __version__, interactive
from matplotlib import __version__, figure, interactive
from matplotlib._enums import CapStyle
from matplotlib.backend_bases import (
FigureManagerBase,
GraphicsContextBase,
RendererBase,
_Backend,
)
from matplotlib.backends import backend_agg
from matplotlib.colors import colorConverter, rgb2hex
from matplotlib.font_manager import findfont
from matplotlib.ft2font import LOAD_NO_HINTING, FT2Font
Expand All @@ -20,14 +44,17 @@
from PIL import Image
from PIL.PngImagePlugin import PngInfo

# Redirect to the WASM backend
from matplotlib_pyodide.browser_backend import FigureCanvasWasm, NavigationToolbar2Wasm
from matplotlib_pyodide.wasm_backend import FigureCanvasAggWasm, FigureManagerAggWasm

try:
from js import FontFace, ImageData, document
except ImportError as err:
raise ImportError(
"html5_canvas_backend is only supported in the browser in the main thread"
) from err

from pyodide.ffi import create_proxy

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

def set_capstyle(self, cs):
"""
Set the cap style for lines in the graphics context.
Parameters
----------
cs : CapStyle or str
The cap style to use. Can be a CapStyle enum value or a string
that can be converted to a CapStyle.
"""
if isinstance(cs, str):
cs = CapStyle(cs)

# Convert the JoinStyle enum to its name if needed
if hasattr(cs, "name"):
cs = cs.name.lower()

if cs in ["butt", "round", "projecting"]:
self._capstyle = cs
self.renderer.ctx.lineCap = _capstyle_d[cs]
else:
raise ValueError(f"Unrecognized cap style. Found {cs}")

def get_capstyle(self):
return self._capstyle

def set_clip_rectangle(self, rectangle):
self.renderer.ctx.save()
if not rectangle:
Expand Down Expand Up @@ -204,7 +250,11 @@ def __init__(self, ctx, width, height, dpi, fig):
self.ctx.width = self.width
self.ctx.height = self.height
self.dpi = dpi
self.mathtext_parser = MathTextParser("bitmap")

# Create path-based math text parser; as the bitmap parser
# was deprecated in 3.4 and removed after 3.5
self.mathtext_parser = MathTextParser("path")

self._get_font_helper = lru_cache(maxsize=50)(self._get_font_helper)

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

return CSS_color

def _math_to_rgba(self, s, prop, rgb):
"""Convert math text to an RGBA array using path parser and figure"""
from io import BytesIO

# Get the text dimensions and generate a figure
# of the right rize.
width, height, depth, _, _ = self.mathtext_parser.parse(s, dpi=72, prop=prop)

fig = figure.Figure(figsize=(width / 72, height / 72))

# Add text to the figure
# Note: depth/height gives us the baseline position
fig.text(0, depth / height, s, fontproperties=prop, color=rgb)

backend_agg.FigureCanvasAgg(fig)

buf = BytesIO() # render to PNG
fig.savefig(buf, dpi=self.dpi, format="png", transparent=True)
buf.seek(0)

rgba = plt.imread(buf)
return rgba, depth

def _draw_math_text_path(self, gc, x, y, s, prop, angle):
"""Draw mathematical text using paths directly on the canvas.
This method renders math text by drawing the actual glyph paths
onto the canvas, rather than creating a temporary image.
Parameters
----------
gc : GraphicsContextHTMLCanvas
The graphics context to use for drawing
x, y : float
The position of the text baseline in pixels
s : str
The text string to render
prop : FontProperties
The font properties to use for rendering
angle : float
The rotation angle in degrees
"""
width, height, depth, glyphs, rects = self.mathtext_parser.parse(
s, dpi=self.dpi, prop=prop
)

self.ctx.save()

self.ctx.translate(x, self.height - y)
if angle != 0:
self.ctx.rotate(-math.radians(angle))

self.ctx.fillStyle = self._matplotlib_color_to_CSS(
gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha()
)

for font, fontsize, _, ox, oy in glyphs:
self.ctx.save()
self.ctx.translate(ox, -oy)

font.set_size(fontsize, self.dpi)
verts, codes = font.get_path()

verts = verts * fontsize / font.units_per_EM

path = Path(verts, codes)

transform = Affine2D().scale(1.0, -1.0)
self._path_helper(self.ctx, path, transform)
self.ctx.fill()

self.ctx.restore()

for x1, y1, x2, y2 in rects:
self.ctx.fillRect(x1, -y2, x2 - x1, y2 - y1)

self.ctx.restore()

def _draw_math_text(self, gc, x, y, s, prop, angle):
"""Draw mathematical text using the most appropriate method.
This method tries direct path rendering first, and falls back to
the image-based approach if needed.
Parameters
----------
gc : GraphicsContextHTMLCanvas
The graphics context to use for drawing
x, y : float
The position of the text baseline in pixels
s : str
The text string to render
prop : FontProperties
The font properties to use for rendering
angle : float
The rotation angle in degrees
"""
try:
self._draw_math_text_path(gc, x, y, s, prop, angle)
except Exception as e:
# If path rendering fails, we fall back to image-based approach
print(f"Path rendering failed, falling back to image: {str(e)}")

rgba, depth = self._math_to_rgba(s, prop, gc.get_rgb())

angle = math.radians(angle)
if angle != 0:
self.ctx.save()
self.ctx.translate(x, y)
self.ctx.rotate(-angle)
self.ctx.translate(-x, -y)

self.draw_image(gc, x, -y - depth, np.flipud(rgba))

if angle != 0:
self.ctx.restore()

def _set_style(self, gc, rgbFace=None):
if rgbFace is not None:
self.ctx.fillStyle = self._matplotlib_color_to_CSS(
rgbFace, gc.get_alpha(), gc.get_forced_alpha()
)

if gc.get_capstyle():
self.ctx.lineCap = _capstyle_d[gc.get_capstyle()]
capstyle = gc.get_capstyle()
if capstyle:
# Get the string name if it's an enum
if hasattr(capstyle, "name"):
capstyle = capstyle.name.lower()
self.ctx.lineCap = _capstyle_d[capstyle]

self.ctx.strokeStyle = self._matplotlib_color_to_CSS(
gc.get_rgb(), gc.get_alpha(), gc.get_forced_alpha()
Expand Down Expand Up @@ -329,42 +500,21 @@ def _get_font(self, prop):
def get_text_width_height_descent(self, s, prop, ismath):
w: float
h: float
d: float
if ismath:
image, d = self.mathtext_parser.parse(s, self.dpi, prop)
image_arr = np.asarray(image)
h, w = image_arr.shape
# Use the path parser to get exact metrics
width, height, depth, _, _ = self.mathtext_parser.parse(
s, dpi=72, prop=prop
)
return width, height, depth
else:
font, _ = self._get_font(prop)
font.set_text(s, 0.0, flags=LOAD_NO_HINTING)
w, h = font.get_width_height()
w /= 64.0
h /= 64.0
d = font.get_descent() / 64.0
return w, h, d

def _draw_math_text(self, gc, x, y, s, prop, angle):
rgba, descent = self.mathtext_parser.to_rgba(
s, gc.get_rgb(), self.dpi, prop.get_size_in_points()
)
height, width, _ = rgba.shape
angle = math.radians(angle)
if angle != 0:
self.ctx.save()
self.ctx.translate(x, y)
self.ctx.rotate(-angle)
self.ctx.translate(-x, -y)
self.draw_image(gc, x, -y - descent, np.flipud(rgba))
if angle != 0:
self.ctx.restore()

def load_font_into_web(self, loaded_face, font_url):
fontface = loaded_face.result()
document.fonts.add(fontface)
self.fonts_loading.pop(font_url, None)

# Redraw figure after font has loaded
self.fig.draw()
return fontface
return w, h, d

def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
if ismath:
Expand Down Expand Up @@ -421,6 +571,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
if angle != 0:
self.ctx.restore()

def load_font_into_web(self, loaded_face, font_url):
fontface = loaded_face.result()
document.fonts.add(fontface)
self.fonts_loading.pop(font_url, None)

# Redraw figure after font has loaded
self.fig.draw()
return fontface


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

@_Backend.export
class _BackendHTMLCanvas(_Backend):
FigureCanvas = FigureCanvasHTMLCanvas
FigureManager = FigureManagerHTMLCanvas
# FigureCanvas = FigureCanvasHTMLCanvas
# FigureManager = FigureManagerHTMLCanvas
# Note: with release 0.2.3, we've redirected the HTMLCanvas backend to use the WASM backend
# for now, as the changes to the HTMLCanvas backend are not yet fully functional.
# This will be updated in a future release.
FigureCanvas = FigureCanvasAggWasm
FigureManager = FigureManagerAggWasm

@staticmethod
def show(*args, **kwargs):
Expand Down

0 comments on commit 3aaa9a2

Please sign in to comment.