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+
122import base64
223import io
324import math
425from functools import lru_cache
526
27+ import matplotlib .pyplot as plt
628import numpy as np
7- from matplotlib import __version__ , interactive
29+ from matplotlib import __version__ , figure , interactive
30+ from matplotlib ._enums import CapStyle
831from matplotlib .backend_bases import (
932 FigureManagerBase ,
1033 GraphicsContextBase ,
1134 RendererBase ,
1235 _Backend ,
1336)
37+ from matplotlib .backends import backend_agg
1438from matplotlib .colors import colorConverter , rgb2hex
1539from matplotlib .font_manager import findfont
1640from matplotlib .ft2font import LOAD_NO_HINTING , FT2Font
2044from PIL import Image
2145from PIL .PngImagePlugin import PngInfo
2246
47+ # Redirect to the WASM backend
2348from matplotlib_pyodide .browser_backend import FigureCanvasWasm , NavigationToolbar2Wasm
49+ from matplotlib_pyodide .wasm_backend import FigureCanvasAggWasm , FigureManagerAggWasm
2450
2551try :
2652 from js import FontFace , ImageData , document
2753except ImportError as err :
2854 raise ImportError (
2955 "html5_canvas_backend is only supported in the browser in the main thread"
3056 ) from err
57+
3158from 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
425584class FigureManagerHTMLCanvas (FigureManagerBase ):
426585 def __init__ (self , canvas , num ):
@@ -443,8 +602,13 @@ def set_window_title(self, title):
443602
444603@_Backend .export
445604class _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