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
+
1
22
import base64
2
23
import io
3
24
import math
4
25
from functools import lru_cache
5
26
27
+ import matplotlib .pyplot as plt
6
28
import numpy as np
7
- from matplotlib import __version__ , interactive
29
+ from matplotlib import __version__ , figure , interactive
30
+ from matplotlib ._enums import CapStyle
8
31
from matplotlib .backend_bases import (
9
32
FigureManagerBase ,
10
33
GraphicsContextBase ,
11
34
RendererBase ,
12
35
_Backend ,
13
36
)
37
+ from matplotlib .backends import backend_agg
14
38
from matplotlib .colors import colorConverter , rgb2hex
15
39
from matplotlib .font_manager import findfont
16
40
from matplotlib .ft2font import LOAD_NO_HINTING , FT2Font
20
44
from PIL import Image
21
45
from PIL .PngImagePlugin import PngInfo
22
46
47
+ # Redirect to the WASM backend
23
48
from matplotlib_pyodide .browser_backend import FigureCanvasWasm , NavigationToolbar2Wasm
49
+ from matplotlib_pyodide .wasm_backend import FigureCanvasAggWasm , FigureManagerAggWasm
24
50
25
51
try :
26
52
from js import FontFace , ImageData , document
27
53
except ImportError as err :
28
54
raise ImportError (
29
55
"html5_canvas_backend is only supported in the browser in the main thread"
30
56
) from err
57
+
31
58
from pyodide .ffi import create_proxy
32
59
33
60
_capstyle_d = {"projecting" : "square" , "butt" : "butt" , "round" : "round" }
@@ -144,12 +171,31 @@ def restore(self):
144
171
self .renderer .ctx .restore ()
145
172
146
173
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
+
147
190
if cs in ["butt" , "round" , "projecting" ]:
148
191
self ._capstyle = cs
149
192
self .renderer .ctx .lineCap = _capstyle_d [cs ]
150
193
else :
151
194
raise ValueError (f"Unrecognized cap style. Found { cs } " )
152
195
196
+ def get_capstyle (self ):
197
+ return self ._capstyle
198
+
153
199
def set_clip_rectangle (self , rectangle ):
154
200
self .renderer .ctx .save ()
155
201
if not rectangle :
@@ -204,7 +250,11 @@ def __init__(self, ctx, width, height, dpi, fig):
204
250
self .ctx .width = self .width
205
251
self .ctx .height = self .height
206
252
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
+
208
258
self ._get_font_helper = lru_cache (maxsize = 50 )(self ._get_font_helper )
209
259
210
260
# 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):
240
290
241
291
return CSS_color
242
292
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
+
243
410
def _set_style (self , gc , rgbFace = None ):
244
411
if rgbFace is not None :
245
412
self .ctx .fillStyle = self ._matplotlib_color_to_CSS (
246
413
rgbFace , gc .get_alpha (), gc .get_forced_alpha ()
247
414
)
248
415
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 ]
251
422
252
423
self .ctx .strokeStyle = self ._matplotlib_color_to_CSS (
253
424
gc .get_rgb (), gc .get_alpha (), gc .get_forced_alpha ()
@@ -329,42 +500,21 @@ def _get_font(self, prop):
329
500
def get_text_width_height_descent (self , s , prop , ismath ):
330
501
w : float
331
502
h : float
503
+ d : float
332
504
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
336
510
else :
337
511
font , _ = self ._get_font (prop )
338
512
font .set_text (s , 0.0 , flags = LOAD_NO_HINTING )
339
513
w , h = font .get_width_height ()
340
514
w /= 64.0
341
515
h /= 64.0
342
516
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
368
518
369
519
def draw_text (self , gc , x , y , s , prop , angle , ismath = False , mtext = None ):
370
520
if ismath :
@@ -421,6 +571,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
421
571
if angle != 0 :
422
572
self .ctx .restore ()
423
573
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
+
424
583
425
584
class FigureManagerHTMLCanvas (FigureManagerBase ):
426
585
def __init__ (self , canvas , num ):
@@ -443,8 +602,13 @@ def set_window_title(self, title):
443
602
444
603
@_Backend .export
445
604
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
448
612
449
613
@staticmethod
450
614
def show (* args , ** kwargs ):
0 commit comments