Skip to content
Advertisement

matplotlib: Can I use a secondary font for missing glyphs?

The font I want to use doesn’t have all the symbols I need. Is it possible to have matplotlib use a different font if a symbol is missing?

Here is a minimal example:

import matplotlib.pyplot as plt

fig = plt.figure()
plt.axis([0, 8, 0, 6])

t = u'abcde♥'

plt.text(4.5, 4, 'DejaVu Sans:', horizontalalignment='right')
plt.text(5, 4, t, {'family':'DejaVu Sans'})

plt.text(4.5, 3, 'Noto Sans:', horizontalalignment='right')
plt.text(5, 3, t, {'family':'Noto Sans'})

plt.text(4.5, 2, 'Noto Sans Symbols2:', horizontalalignment='right')
plt.text(5, 2, t, {'family':'Noto Sans Symbols2'})

plt.show()

And the output:

And the output.

Noto Sans is missing the heart symbol while Noto Sans Symbols2 is missing the letters. I’m trying to get something like the DejaVu Sans example but with letters from Noto Sans and the heart from Noto Sans Symbols2.

Advertisement

Answer

Update in 2022: As of matplotlib 3.6, font fallback is now supported.

plt.text(1, 1, u'abcde♥', family=['Noto Sans', 'Noto Sans Symbols2', 'DejaVu Sans'])

Here’s my thinking:

Create a function taking x – the starting x position, y – the y position, text – the text to draw, and fallbackList – a list of fonts, ordered like font-family in CSS.

  1. When rendering the text, use fontTools.ttLib.TTFont to check if a certain character is contained within the primary font (fallbackList[0]), by parsing the font table, looping through it, and checking if the given character is inside the map (see this question).
  2. If the result of step 1 is False (i.e it’s not contained within the font pack), go through fallbackList, repeating step 1, until you find a font that does contain it. If you find a character with no font containing it, just use the first font. We’ll call this font we just found foundFont.
  3. Draw that character with textpath.TextPath((xPosNow, y) ...stuff... prop=foundFont).getextents() (matplotlib > 1.0.0). This draws that character, but also gets the bounding box of the text, from which you can extract the width (see this question). Do xPosNow += textWidth, where textWidth is extracted from getextents().

This would essentially keep a tally of the total distance from the origin (by adding together the width of each bit of text you add), and then when you need to add another bit of text in a different font, simply set the x value to be this tally + a little bit for kerning, and this way, you can just work out where you want each character to go (but do each character separately).

Here’s a code example:

import matplotlib.pyplot as plt
from matplotlib.textpath import TextPath
from fontTools.ttLib import TTFont

fig = plt.figure()
plt.axis([0, 8, 0, 6])

t = u'abcde♥'

plt.text(4.5, 4, 'DejaVu Sans:', horizontalalignment='right')
plt.text(5, 4, t, {'family':'DejaVu Sans'})

plt.text(4.5, 3, 'Noto Sans:', horizontalalignment='right')
plt.text(5, 3, t, {'family':'Noto Sans'})

plt.text(4.5, 2, 'Noto Sans Symbols2:', horizontalalignment='right')
plt.text(5, 2, t, {'family':'Noto Sans Symbols2'})

def doesContain(fontPath, unicode_char):  # Helper function, the only issue being it takes font paths instead of names
    font = TTFont(fontPath)  # Use helper library to go through all characters
    for cmap in font['cmap'].tables:
        if cmap.isUnicode():
            if ord(unicode_char) in cmap.cmap:  # If the character table contains our character return True
                return True
    # Otherwise, return False.
    return False
    
def renderText(x, y, text, fontSize, fallback_list, spacingSize):
    xPosNow = x
    
    for char in text:  # For each character...
        fontId = 0
        while not doesContain(fallback_list[fontId]['path'], char):  # find a font that works
            if fontId < len(fallback_list) - 1:
                fontId += 1
            else:  # Or just go with the first font, if nothing seems to match
                fontId = 0
                break

        print(fontId)
              
        t = plt.text(xPosNow, y, char, {'family':fallback_list[fontId]['name']})
        r = fig.canvas.get_renderer()

        xPosNow += t.get_window_extent(renderer=r).width/100 + spacingSize

We call it with:

renderText(3, 5, t, 9, [
        {
            'path': 'C:\Users\User\Downloads\NotoSans-hinted\NotoSans-Regular.ttf',  # Font path
            'name': 'Noto Sans'  # Font name
        },
        {
            'path': 'C:\Users\User\Downloads\NotoSansSymbols2-unhinted\NotoSansSymbols2-Regular.ttf',
            'name': 'Noto Sans Symbols2'
        }
    ]
, 0.08)  # The distance between the letters

plt.show()

And the output is:

image

User contributions licensed under: CC BY-SA
7 People found this is helpful
Advertisement