|
| 1 | +""" |
| 2 | +A module for parsing and generating `fontconfig patterns`_. |
| 3 | +
|
| 4 | +.. _fontconfig patterns: |
| 5 | + https://www.freedesktop.org/software/fontconfig/fontconfig-user.html |
| 6 | +""" |
| 7 | + |
| 8 | +# This class logically belongs in `matplotlib.font_manager`, but placing it |
| 9 | +# there would have created cyclical dependency problems, because it also needs |
| 10 | +# to be available from `matplotlib.rcsetup` (for parsing matplotlibrc files). |
| 11 | + |
| 12 | +from functools import lru_cache |
| 13 | +import re |
| 14 | +import numpy as np |
| 15 | +from pyparsing import (Literal, ZeroOrMore, Optional, Regex, StringEnd, |
| 16 | + ParseException, Suppress) |
| 17 | + |
| 18 | +family_punc = r'\\\-:,' |
| 19 | +family_unescape = re.compile(r'\\([%s])' % family_punc).sub |
| 20 | +family_escape = re.compile(r'([%s])' % family_punc).sub |
| 21 | + |
| 22 | +value_punc = r'\\=_:,' |
| 23 | +value_unescape = re.compile(r'\\([%s])' % value_punc).sub |
| 24 | +value_escape = re.compile(r'([%s])' % value_punc).sub |
| 25 | + |
| 26 | + |
| 27 | +class FontconfigPatternParser: |
| 28 | + """ |
| 29 | + A simple pyparsing-based parser for `fontconfig patterns`_. |
| 30 | +
|
| 31 | + .. _fontconfig patterns: |
| 32 | + https://www.freedesktop.org/software/fontconfig/fontconfig-user.html |
| 33 | + """ |
| 34 | + |
| 35 | + _constants = { |
| 36 | + 'thin': ('weight', 'light'), |
| 37 | + 'extralight': ('weight', 'light'), |
| 38 | + 'ultralight': ('weight', 'light'), |
| 39 | + 'light': ('weight', 'light'), |
| 40 | + 'book': ('weight', 'book'), |
| 41 | + 'regular': ('weight', 'regular'), |
| 42 | + 'normal': ('weight', 'normal'), |
| 43 | + 'medium': ('weight', 'medium'), |
| 44 | + 'demibold': ('weight', 'demibold'), |
| 45 | + 'semibold': ('weight', 'semibold'), |
| 46 | + 'bold': ('weight', 'bold'), |
| 47 | + 'extrabold': ('weight', 'extra bold'), |
| 48 | + 'black': ('weight', 'black'), |
| 49 | + 'heavy': ('weight', 'heavy'), |
| 50 | + 'roman': ('slant', 'normal'), |
| 51 | + 'italic': ('slant', 'italic'), |
| 52 | + 'oblique': ('slant', 'oblique'), |
| 53 | + 'ultracondensed': ('width', 'ultra-condensed'), |
| 54 | + 'extracondensed': ('width', 'extra-condensed'), |
| 55 | + 'condensed': ('width', 'condensed'), |
| 56 | + 'semicondensed': ('width', 'semi-condensed'), |
| 57 | + 'expanded': ('width', 'expanded'), |
| 58 | + 'extraexpanded': ('width', 'extra-expanded'), |
| 59 | + 'ultraexpanded': ('width', 'ultra-expanded') |
| 60 | + } |
| 61 | + |
| 62 | + def __init__(self): |
| 63 | + |
| 64 | + family = Regex( |
| 65 | + r'([^%s]|(\\[%s]))*' % (family_punc, family_punc) |
| 66 | + ).setParseAction(self._family) |
| 67 | + |
| 68 | + size = Regex( |
| 69 | + r"([0-9]+\.?[0-9]*|\.[0-9]+)" |
| 70 | + ).setParseAction(self._size) |
| 71 | + |
| 72 | + name = Regex( |
| 73 | + r'[a-z]+' |
| 74 | + ).setParseAction(self._name) |
| 75 | + |
| 76 | + value = Regex( |
| 77 | + r'([^%s]|(\\[%s]))*' % (value_punc, value_punc) |
| 78 | + ).setParseAction(self._value) |
| 79 | + |
| 80 | + families = ( |
| 81 | + family |
| 82 | + + ZeroOrMore( |
| 83 | + Literal(',') |
| 84 | + + family) |
| 85 | + ).setParseAction(self._families) |
| 86 | + |
| 87 | + point_sizes = ( |
| 88 | + size |
| 89 | + + ZeroOrMore( |
| 90 | + Literal(',') |
| 91 | + + size) |
| 92 | + ).setParseAction(self._point_sizes) |
| 93 | + |
| 94 | + property = ( |
| 95 | + (name |
| 96 | + + Suppress(Literal('=')) |
| 97 | + + value |
| 98 | + + ZeroOrMore( |
| 99 | + Suppress(Literal(',')) |
| 100 | + + value)) |
| 101 | + | name |
| 102 | + ).setParseAction(self._property) |
| 103 | + |
| 104 | + pattern = ( |
| 105 | + Optional( |
| 106 | + families) |
| 107 | + + Optional( |
| 108 | + Literal('-') |
| 109 | + + point_sizes) |
| 110 | + + ZeroOrMore( |
| 111 | + Literal(':') |
| 112 | + + property) |
| 113 | + + StringEnd() |
| 114 | + ) |
| 115 | + |
| 116 | + self._parser = pattern |
| 117 | + self.ParseException = ParseException |
| 118 | + |
| 119 | + def parse(self, pattern): |
| 120 | + """ |
| 121 | + Parse the given fontconfig *pattern* and return a dictionary |
| 122 | + of key/value pairs useful for initializing a |
| 123 | + `.font_manager.FontProperties` object. |
| 124 | + """ |
| 125 | + props = self._properties = {} |
| 126 | + try: |
| 127 | + self._parser.parseString(pattern) |
| 128 | + except self.ParseException as e: |
| 129 | + raise ValueError( |
| 130 | + "Could not parse font string: '%s'\n%s" % (pattern, e)) from e |
| 131 | + |
| 132 | + self._properties = None |
| 133 | + |
| 134 | + self._parser.resetCache() |
| 135 | + |
| 136 | + return props |
| 137 | + |
| 138 | + def _family(self, s, loc, tokens): |
| 139 | + return [family_unescape(r'\1', str(tokens[0]))] |
| 140 | + |
| 141 | + def _size(self, s, loc, tokens): |
| 142 | + return [float(tokens[0])] |
| 143 | + |
| 144 | + def _name(self, s, loc, tokens): |
| 145 | + return [str(tokens[0])] |
| 146 | + |
| 147 | + def _value(self, s, loc, tokens): |
| 148 | + return [value_unescape(r'\1', str(tokens[0]))] |
| 149 | + |
| 150 | + def _families(self, s, loc, tokens): |
| 151 | + self._properties['family'] = [str(x) for x in tokens] |
| 152 | + return [] |
| 153 | + |
| 154 | + def _point_sizes(self, s, loc, tokens): |
| 155 | + self._properties['size'] = [str(x) for x in tokens] |
| 156 | + return [] |
| 157 | + |
| 158 | + def _property(self, s, loc, tokens): |
| 159 | + if len(tokens) == 1: |
| 160 | + if tokens[0] in self._constants: |
| 161 | + key, val = self._constants[tokens[0]] |
| 162 | + self._properties.setdefault(key, []).append(val) |
| 163 | + else: |
| 164 | + key = tokens[0] |
| 165 | + val = tokens[1:] |
| 166 | + self._properties.setdefault(key, []).extend(val) |
| 167 | + return [] |
| 168 | + |
| 169 | + |
| 170 | +# `parse_fontconfig_pattern` is a bottleneck during the tests because it is |
| 171 | +# repeatedly called when the rcParams are reset (to validate the default |
| 172 | +# fonts). In practice, the cache size doesn't grow beyond a few dozen entries |
| 173 | +# during the test suite. |
| 174 | +parse_fontconfig_pattern = lru_cache()(FontconfigPatternParser().parse) |
| 175 | + |
| 176 | + |
| 177 | +def _escape_val(val, escape_func): |
| 178 | + """ |
| 179 | + Given a string value or a list of string values, run each value through |
| 180 | + the input escape function to make the values into legal font config |
| 181 | + strings. The result is returned as a string. |
| 182 | + """ |
| 183 | + if not np.iterable(val) or isinstance(val, str): |
| 184 | + val = [val] |
| 185 | + |
| 186 | + return ','.join(escape_func(r'\\\1', str(x)) for x in val |
| 187 | + if x is not None) |
| 188 | + |
| 189 | + |
| 190 | +def generate_fontconfig_pattern(d): |
| 191 | + """ |
| 192 | + Given a dictionary of key/value pairs, generates a fontconfig |
| 193 | + pattern string. |
| 194 | + """ |
| 195 | + props = [] |
| 196 | + |
| 197 | + # Family is added first w/o a keyword |
| 198 | + family = d.get_family() |
| 199 | + if family is not None and family != []: |
| 200 | + props.append(_escape_val(family, family_escape)) |
| 201 | + |
| 202 | + # The other keys are added as key=value |
| 203 | + for key in ['style', 'variant', 'weight', 'stretch', 'file', 'size']: |
| 204 | + val = getattr(d, 'get_' + key)() |
| 205 | + # Don't use 'if not val' because 0 is a valid input. |
| 206 | + if val is not None and val != []: |
| 207 | + props.append(":%s=%s" % (key, _escape_val(val, value_escape))) |
| 208 | + |
| 209 | + return ''.join(props) |
0 commit comments