Rendering SVG with Matplotlib

Author

Jae-Joon Lee

Published

October 27, 2024

Modified

October 29, 2024

A guide to render SVG in your Matplotlib plot as vector format.

This tutorial demonstrates how one can include svg in Matplotlib, as vector format (i.e.g, Matplotlib’s paths). While you can rasterize the svg into an image and include them, we will read the svg file and produce matplotlib path objects.

We will use (mpl-simple-svg-parser)[https://mpl-simple-svg-parser.readthedocs.io/en/latest/index.html] package. Note that this package is not fully-featured SVG parser. Instead, it uses (cariosvg)[https://cairosvg.org/] and (picosvg)[https://github.com/googlefonts/picosvg] to convert the input svg into a more manageable svg and then read them with the help of (svgpath2mpl)[https://github.com/nvictus/svgpath2mpl]. The package does support gradient in a very ad hoc way. It uses (Skia)[https://skia.org/] to produce gradient image and include them in the matplotlib plot.

While this is not ideal (and not very efficient), this let you render a good fraction of svg wit matlotlib. On the other hand, features like filters are not supported.

Here is an example of annotating your plot with svg.

Code
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

from matplotlib.offsetbox import AnnotationBbox
from mpl_simple_svg_parser import SVGMplPathIterator


# TIOBE score from https://spectrum.ieee.org/the-top-programming-languages-2023

l = [
    ("Python", 1),
    ("Java", 0.588),
    ("C++", 0.538),
    ("C", 0.4641),
    ("JavaScript", 0.4638),
    ("C#", 0.3973)
]

df = pd.DataFrame(l, columns=["Language", "Score"])


sns.set_color_codes("muted")
sns.set(font_scale = 1.5)

fig, ax = plt.subplots(num=1, clear=True, layout="constrained")

sns.barplot(x="Score", y="Language", data=df,
            label="Tiobe Score 2023", color="b",
            legend=False)

ax.yaxis.label.set_visible(False)
ax.set_title("TIOBE Score 2023")
ax.set_xlim(0, 1.13)

ylabels = [l.get_text() for l in ax.get_yticklabels()] # save it to use with svg icons later
bars = ax.containers[0] # list of rectangles for the bars.

for bar, l in zip(bars, ylabels):
    ax.annotate(l, xy=(0, 0.5), xycoords=bar, va="center", ha="left",
               xytext=(10, 0), textcoords="offset points", color="w")

ax.tick_params(axis="y",labelleft=False)
# ax.tick_params(axis="x",direction="in")

ax.set_xlim(0, 1.2) # to make a room for svg annotattion.

import toml
icons = toml.load(open("svg_icons.toml"))

def get_da(b, ax, wmax=64, hmax=64):
    svg_mpl_path_iterator = SVGMplPathIterator(b)
    da = svg_mpl_path_iterator.get_drawing_area(ax, wmax=wmax, hmax=hmax)
    return da

for l, bar in zip(ylabels, bars):
    da = get_da(icons[l].encode("ascii"), ax, wmax=32, hmax=32)
    ab = AnnotationBbox(da, (1., 0.5), xycoords=bar, frameon=False,
                        xybox=(5, 0), boxcoords="offset points",
                        box_alignment=(0.0, 0.5))
    ax.add_artist(ab)

plt.show()

Using SVGMplPathIterator from mpl_simple_svg_parser

Let’s start with a simple example. The base class is SVGMplPathIterator. It reads the svg string, and produces a list of matplotlib’s path object. If you want to render the svg in the axes’ data coordinate, you may simply use the draw method.

import matplotlib.pyplot as plt
from mpl_simple_svg_parser import SVGMplPathIterator

fig, ax = plt.subplots(num=1, clear=True)
ax.set_aspect(1)
fn = "homer-simpson.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
svg_mpl_path_iterator.draw(ax)

You can offset and scale it.

# First let's check the viewbox of the svg.

svg_mpl_path_iterator.viewbox # This is the size defined in the svg file.
[0.0, 0.0, 375.0, 375.0]
fig, ax = plt.subplots(num=2, clear=True)
ax.set_aspect(1)
ax.plot([0, 1000], [0, 1000])

fn = "homer-simpson.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
svg_mpl_path_iterator.draw(ax, xy=(600, 100), scale=0.7)

The package mpl-simple-svg-parser processes the input svg content using cairosvg to produce simplified svg. There are some caveats of drawing the result with matplotlib. For example, linewidth in Matplotlib cannot be specified in the data coordinate. In the example below, the arms and legs of the robot is too thin because of this issue.

fig, ax = plt.subplots(num=3, clear=True)
ax.set_aspect(1)
fn = "android.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
svg_mpl_path_iterator.draw(ax)

Besides the stroke width issue, the package does not handle clipping.

Turning on the option “pico=True” can solve some of the issues. With this option, the svg is further processed by picosvg which converts strokes to fills and clips the paths. Running pico has its own caveats though. You may check this (page)[https://leejjoon.github.io/mpl-simple-svg-parser/gallery/] and check the results.

fig, ax = plt.subplots(num=4, clear=True)
ax.set_aspect(1)
fn = "android.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read(), pico=True)
svg_mpl_path_iterator.draw(ax)

The package does support gradient. While the result is reasonable, the implementation is quite naive and not efficient. It uses Skia (or Cairo) to produce gradient image, and let the matplotlib’s backends to clip it.

fig, ax = plt.subplots(num=5, clear=True)
ax.set_aspect(1)
fn = "python.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
svg_mpl_path_iterator.draw(ax)

Sometimes, the viewbox size in the svg can be incorrect. You can set datalim_mode=‘path’ to ignore the viewbox, and try to let matplotlib guess its extent based on extent of individual patches (this can be incorrect sometime.)

fig, ax = plt.subplots(num=6, clear=True)
ax.set_aspect(1)
fn = "tiger.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read(), pico=True)
svg_mpl_path_iterator.draw(ax, datalim_mode="path")

And we can render Matplotlib logo!

fig, ax = plt.subplots()
ax.set_aspect(1)
fn = "matplotlib-original-wordmark.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read(), pico=True)
svg_mpl_path_iterator.draw(ax, datalim_mode="path")

ax.tick_params(labelleft=False, labelbottom=False)

DrawingArea

For the examples so far, we draw the svg in Matplotlb’s data coordinates. Often, you want your svg rendering results, behaves like text, whose size is set in points, independent of data coordinates.

Instead of drawing it direcly on the axes, it is recommented to draw it on DrawingArea – derived from OffsetBox – and use AnnotationBbox to place it on the axes similar to annotation.

from matplotlib.offsetbox import AnnotationBbox

fig, ax = plt.subplots(num=7, clear=True)
fn = "python.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
da = svg_mpl_path_iterator.get_drawing_area(ax, wmax=64)

ab = AnnotationBbox(da, (0.5, 0.5), xycoords="data")
ax.add_artist(ab)
<matplotlib.offsetbox.AnnotationBbox at 0x7c7d5a74d960>

Annotation Example

Let’s make a barplot and annotate it with svgs.

We start with a boxplot

import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

from matplotlib.offsetbox import AnnotationBbox
from mpl_simple_svg_parser import SVGMplPathIterator

# TIOBE score from https://spectrum.ieee.org/the-top-programming-languages-2023
l = [
    ("Python", 1),
    ("Java", 0.588),
    ("C++", 0.538),
    ("C", 0.4641),
    ("JavaScript", 0.4638),
    ("C#", 0.3973)
]

df = pd.DataFrame(l, columns=["Language", "Score"])

sns.set_color_codes("muted")
sns.set(font_scale = 1.5)

fig, ax = plt.subplots(num=1, clear=True, layout="constrained")

sns.barplot(x="Score", y="Language", data=df,
            label="Tiobe Score 2023", color="b",
            legend=False)

ax.yaxis.label.set_visible(False)
ax.set_title("TIOBE Score 2023")

ylabels = [l.get_text() for l in ax.get_yticklabels()] # save it to use with svg icons later
bars = ax.containers[0] # list of rectangles for the bars.

for bar, l in zip(bars, ylabels):
    ax.annotate(l, xy=(0, 0.5), xycoords=bar, va="center", ha="left",
               xytext=(10, 0), textcoords="offset points", color="w")

ax.tick_params(axis="y",labelleft=False)
# ax.tick_params(axis="x",direction="in")

ax.set_xlim(0, 1.2) # to make a room for svg annotattion.

We annotate the plot with svg in drawing_area.

# We will use svg icons downloaded from (devicons)[https://github.com/devicons/devicon]

import toml
icons = toml.load(open("svg_icons.toml"))
icons["Python"]
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><linearGradient id="python-original-a" gradientUnits="userSpaceOnUse" x1="70.252" y1="1237.476" x2="170.659" y2="1151.089" gradientTransform="matrix(.563 0 0 -.568 -29.215 707.817)"><stop offset="0" stop-color="#5A9FD4"/><stop offset="1" stop-color="#306998"/></linearGradient><linearGradient id="python-original-b" gradientUnits="userSpaceOnUse" x1="209.474" y1="1098.811" x2="173.62" y2="1149.537" gradientTransform="matrix(.563 0 0 -.568 -29.215 707.817)"><stop offset="0" stop-color="#FFD43B"/><stop offset="1" stop-color="#FFE873"/></linearGradient><path fill="url(#python-original-a)" d="M63.391 1.988c-4.222.02-8.252.379-11.8 1.007-10.45 1.846-12.346 5.71-12.346 12.837v9.411h24.693v3.137H29.977c-7.176 0-13.46 4.313-15.426 12.521-2.268 9.405-2.368 15.275 0 25.096 1.755 7.311 5.947 12.519 13.124 12.519h8.491V67.234c0-8.151 7.051-15.34 15.426-15.34h24.665c6.866 0 12.346-5.654 12.346-12.548V15.833c0-6.693-5.646-11.72-12.346-12.837-4.244-.706-8.645-1.027-12.866-1.008zM50.037 9.557c2.55 0 4.634 2.117 4.634 4.721 0 2.593-2.083 4.69-4.634 4.69-2.56 0-4.633-2.097-4.633-4.69-.001-2.604 2.073-4.721 4.633-4.721z" transform="translate(0 10.26)"/><path fill="url(#python-original-b)" d="M91.682 28.38v10.966c0 8.5-7.208 15.655-15.426 15.655H51.591c-6.756 0-12.346 5.783-12.346 12.549v23.515c0 6.691 5.818 10.628 12.346 12.547 7.816 2.297 15.312 2.713 24.665 0 6.216-1.801 12.346-5.423 12.346-12.547v-9.412H63.938v-3.138h37.012c7.176 0 9.852-5.005 12.348-12.519 2.578-7.735 2.467-15.174 0-25.096-1.774-7.145-5.161-12.521-12.348-12.521h-9.268zM77.809 87.927c2.561 0 4.634 2.097 4.634 4.692 0 2.602-2.074 4.719-4.634 4.719-2.55 0-4.633-2.117-4.633-4.719 0-2.595 2.083-4.692 4.633-4.692z" transform="translate(0 10.26)"/><radialGradient id="python-original-c" cx="1825.678" cy="444.45" r="26.743" gradientTransform="matrix(0 -.24 -1.055 0 532.979 557.576)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#B8B8B8" stop-opacity=".498"/><stop offset="1" stop-color="#7F7F7F" stop-opacity="0"/></radialGradient><path opacity=".444" fill="url(#python-original-c)" d="M97.309 119.597c0 3.543-14.816 6.416-33.091 6.416-18.276 0-33.092-2.873-33.092-6.416 0-3.544 14.815-6.417 33.092-6.417 18.275 0 33.091 2.872 33.091 6.417z"/></svg>\n'
def get_da(b, ax, wmax=64, hmax=64):
    svg_mpl_path_iterator = SVGMplPathIterator(b)
    da = svg_mpl_path_iterator.get_drawing_area(ax, wmax=wmax, hmax=hmax)
    return da

for l, bar in zip(ylabels, ax.containers[0]):

    da = get_da(icons[l].encode("ascii"), ax, wmax=32, hmax=32)
    ab = AnnotationBbox(da, (1., 0.5), xycoords=bar, frameon=False,
                        xybox=(5, 0), boxcoords="offset points",
                        box_alignment=(0.0, 0.5))
    ax.add_artist(ab)

Accessing the parsed results

Once can access the parsed results. The base methods would be ‘iter_path_attrib’ and ‘iter_mpl_path_patch_prop’.

fn = "python.svg"
svg_mpl_path_iterator = SVGMplPathIterator(open(fn, "rb").read())
list(svg_mpl_path_iterator.iter_path_attrib())
[('M 20.25 12 C 20.25 6.75 27 2.25 38.25 2.25 C 49.5 2.25 55.5 6.75 55.5 12 L 55.5 28.5 C 55.5 33.75 51.75 37.5 47.25 37.5 L 29.25 37.5 C 23.25 37.5 18.75 42 18.75 48.75 L 18.75 56.25 L 12 56.25 C 6 56.25 2.25 49.5 2.25 38.25 C 2.25 27.75 6 21 12 21 L 38.25 21 L 38.25 18.75 L 20.25 18.75 Z M 66 37.5 L 66 38.25 ',
  {'id': 'surface106',
   'style': ' stroke:none;fill-rule:nonzero;fill:url(#linear0);',
   'd': 'M 20.25 12 C 20.25 6.75 27 2.25 38.25 2.25 C 49.5 2.25 55.5 6.75 55.5 12 L 55.5 28.5 C 55.5 33.75 51.75 37.5 47.25 37.5 L 29.25 37.5 C 23.25 37.5 18.75 42 18.75 48.75 L 18.75 56.25 L 12 56.25 C 6 56.25 2.25 49.5 2.25 38.25 C 2.25 27.75 6 21 12 21 L 38.25 21 L 38.25 18.75 L 20.25 18.75 Z M 66 37.5 L 66 38.25 '}),
 ('M 55.5 65.25 C 55.5 70.5 49.5 75 38.25 75 C 27 75 20.25 70.5 20.25 65.25 L 20.25 48.75 C 20.25 43.5 24.75 39.75 29.25 39.75 L 47.25 39.75 C 53.25 39.75 57.75 34.5 57.75 28.5 L 57.75 21 L 64.5 21 C 69.75 21 74.25 27.75 74.25 38.25 C 74.25 49.5 69.75 56.25 64.5 56.25 L 38.25 56.25 L 38.25 58.5 L 55.5 58.5 Z M 105 37.5 L 105 38.25 ',
  {'id': 'surface106',
   'style': ' stroke:none;fill-rule:nonzero;fill:url(#linear1);',
   'd': 'M 55.5 65.25 C 55.5 70.5 49.5 75 38.25 75 C 27 75 20.25 70.5 20.25 65.25 L 20.25 48.75 C 20.25 43.5 24.75 39.75 29.25 39.75 L 47.25 39.75 C 53.25 39.75 57.75 34.5 57.75 28.5 L 57.75 21 L 64.5 21 C 69.75 21 74.25 27.75 74.25 38.25 C 74.25 49.5 69.75 56.25 64.5 56.25 L 38.25 56.25 L 38.25 58.5 L 55.5 58.5 Z M 105 37.5 L 105 38.25 '}),
 ('M 51 66 C 51 70 45 70 45 66 C 45 62 51 62 51 66 Z M 51 66 ',
  {'id': 'surface106',
   'style': ' stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;',
   'd': 'M 51 66 C 51 70 45 70 45 66 C 45 62 51 62 51 66 Z M 51 66 '}),
 ('M 30.75 11.25 C 30.75 15.25 24.75 15.25 24.75 11.25 C 24.75 7.25 30.75 7.25 30.75 11.25 Z M 30.75 11.25 ',
  {'id': 'surface106',
   'style': ' stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;',
   'd': 'M 30.75 11.25 C 30.75 15.25 24.75 15.25 24.75 11.25 C 24.75 7.25 30.75 7.25 30.75 11.25 Z M 30.75 11.25 '})]
list(svg_mpl_path_iterator.iter_mpl_path_patch_prop())
[(Path(array([[20.25, 63.  ],
         [20.25, 68.25],
         [27.  , 72.75],
         [38.25, 72.75],
         [49.5 , 72.75],
         [55.5 , 68.25],
         [55.5 , 63.  ],
         [55.5 , 46.5 ],
         [55.5 , 41.25],
         [51.75, 37.5 ],
         [47.25, 37.5 ],
         [29.25, 37.5 ],
         [23.25, 37.5 ],
         [18.75, 33.  ],
         [18.75, 26.25],
         [18.75, 18.75],
         [12.  , 18.75],
         [ 6.  , 18.75],
         [ 2.25, 25.5 ],
         [ 2.25, 36.75],
         [ 2.25, 47.25],
         [ 6.  , 54.  ],
         [12.  , 54.  ],
         [38.25, 54.  ],
         [38.25, 56.25],
         [20.25, 56.25],
         [20.25, 63.  ],
         [20.25, 63.  ],
         [66.  , 37.5 ],
         [66.  , 36.75]]), array([ 1,  4,  4,  4,  4,  4,  4,  2,  4,  4,  4,  2,  4,  4,  4,  2,  2,
          4,  4,  4,  4,  4,  4,  2,  2,  2,  2, 79,  1,  2], dtype=uint8)),
  {'fc': 'none',
   'ec': 'none',
   'lw': 1.0,
   'alpha': 1,
   'fc_orig': 'url(#linear0)'}),
 (Path(array([[ 55.5 ,   9.75],
         [ 55.5 ,   4.5 ],
         [ 49.5 ,   0.  ],
         [ 38.25,   0.  ],
         [ 27.  ,   0.  ],
         [ 20.25,   4.5 ],
         [ 20.25,   9.75],
         [ 20.25,  26.25],
         [ 20.25,  31.5 ],
         [ 24.75,  35.25],
         [ 29.25,  35.25],
         [ 47.25,  35.25],
         [ 53.25,  35.25],
         [ 57.75,  40.5 ],
         [ 57.75,  46.5 ],
         [ 57.75,  54.  ],
         [ 64.5 ,  54.  ],
         [ 69.75,  54.  ],
         [ 74.25,  47.25],
         [ 74.25,  36.75],
         [ 74.25,  25.5 ],
         [ 69.75,  18.75],
         [ 64.5 ,  18.75],
         [ 38.25,  18.75],
         [ 38.25,  16.5 ],
         [ 55.5 ,  16.5 ],
         [ 55.5 ,   9.75],
         [ 55.5 ,   9.75],
         [105.  ,  37.5 ],
         [105.  ,  36.75]]), array([ 1,  4,  4,  4,  4,  4,  4,  2,  4,  4,  4,  2,  4,  4,  4,  2,  2,
          4,  4,  4,  4,  4,  4,  2,  2,  2,  2, 79,  1,  2], dtype=uint8)),
  {'fc': 'none',
   'ec': 'none',
   'lw': 1.0,
   'alpha': 1,
   'fc_orig': 'url(#linear1)'}),
 (Path(array([[51.,  9.],
         [51.,  5.],
         [45.,  5.],
         [45.,  9.],
         [45., 13.],
         [51., 13.],
         [51.,  9.],
         [51.,  9.],
         [51.,  9.]]), array([ 1,  4,  4,  4,  4,  4,  4, 79,  1], dtype=uint8)),
  {'fc': array([1., 1., 1.]),
   'ec': 'none',
   'lw': 1.0,
   'alpha': 1.0,
   'fc_orig': None}),
 (Path(array([[30.75, 63.75],
         [30.75, 59.75],
         [24.75, 59.75],
         [24.75, 63.75],
         [24.75, 67.75],
         [30.75, 67.75],
         [30.75, 63.75],
         [30.75, 63.75],
         [30.75, 63.75]]), array([ 1,  4,  4,  4,  4,  4,  4, 79,  1], dtype=uint8)),
  {'fc': array([1., 1., 1.]),
   'ec': 'none',
   'lw': 1.0,
   'alpha': 1.0,
   'fc_orig': None})]