_fontconfig_pattern.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """
  2. A module for parsing and generating `fontconfig patterns`_.
  3. .. _fontconfig patterns:
  4. https://www.freedesktop.org/software/fontconfig/fontconfig-user.html
  5. """
  6. # This class logically belongs in `matplotlib.font_manager`, but placing it
  7. # there would have created cyclical dependency problems, because it also needs
  8. # to be available from `matplotlib.rcsetup` (for parsing matplotlibrc files).
  9. from functools import lru_cache, partial
  10. import re
  11. from pyparsing import (
  12. Group, Optional, ParseException, Regex, StringEnd, Suppress, ZeroOrMore, one_of)
  13. _family_punc = r'\\\-:,'
  14. _family_unescape = partial(re.compile(r'\\(?=[%s])' % _family_punc).sub, '')
  15. _family_escape = partial(re.compile(r'(?=[%s])' % _family_punc).sub, r'\\')
  16. _value_punc = r'\\=_:,'
  17. _value_unescape = partial(re.compile(r'\\(?=[%s])' % _value_punc).sub, '')
  18. _value_escape = partial(re.compile(r'(?=[%s])' % _value_punc).sub, r'\\')
  19. _CONSTANTS = {
  20. 'thin': ('weight', 'light'),
  21. 'extralight': ('weight', 'light'),
  22. 'ultralight': ('weight', 'light'),
  23. 'light': ('weight', 'light'),
  24. 'book': ('weight', 'book'),
  25. 'regular': ('weight', 'regular'),
  26. 'normal': ('weight', 'normal'),
  27. 'medium': ('weight', 'medium'),
  28. 'demibold': ('weight', 'demibold'),
  29. 'semibold': ('weight', 'semibold'),
  30. 'bold': ('weight', 'bold'),
  31. 'extrabold': ('weight', 'extra bold'),
  32. 'black': ('weight', 'black'),
  33. 'heavy': ('weight', 'heavy'),
  34. 'roman': ('slant', 'normal'),
  35. 'italic': ('slant', 'italic'),
  36. 'oblique': ('slant', 'oblique'),
  37. 'ultracondensed': ('width', 'ultra-condensed'),
  38. 'extracondensed': ('width', 'extra-condensed'),
  39. 'condensed': ('width', 'condensed'),
  40. 'semicondensed': ('width', 'semi-condensed'),
  41. 'expanded': ('width', 'expanded'),
  42. 'extraexpanded': ('width', 'extra-expanded'),
  43. 'ultraexpanded': ('width', 'ultra-expanded'),
  44. }
  45. @lru_cache # The parser instance is a singleton.
  46. def _make_fontconfig_parser():
  47. def comma_separated(elem):
  48. return elem + ZeroOrMore(Suppress(",") + elem)
  49. family = Regex(fr"([^{_family_punc}]|(\\[{_family_punc}]))*")
  50. size = Regex(r"([0-9]+\.?[0-9]*|\.[0-9]+)")
  51. name = Regex(r"[a-z]+")
  52. value = Regex(fr"([^{_value_punc}]|(\\[{_value_punc}]))*")
  53. prop = Group((name + Suppress("=") + comma_separated(value)) | one_of(_CONSTANTS))
  54. return (
  55. Optional(comma_separated(family)("families"))
  56. + Optional("-" + comma_separated(size)("sizes"))
  57. + ZeroOrMore(":" + prop("properties*"))
  58. + StringEnd()
  59. )
  60. # `parse_fontconfig_pattern` is a bottleneck during the tests because it is
  61. # repeatedly called when the rcParams are reset (to validate the default
  62. # fonts). In practice, the cache size doesn't grow beyond a few dozen entries
  63. # during the test suite.
  64. @lru_cache
  65. def parse_fontconfig_pattern(pattern):
  66. """
  67. Parse a fontconfig *pattern* into a dict that can initialize a
  68. `.font_manager.FontProperties` object.
  69. """
  70. parser = _make_fontconfig_parser()
  71. try:
  72. parse = parser.parse_string(pattern)
  73. except ParseException as err:
  74. # explain becomes a plain method on pyparsing 3 (err.explain(0)).
  75. raise ValueError("\n" + ParseException.explain(err, 0)) from None
  76. parser.reset_cache()
  77. props = {}
  78. if "families" in parse:
  79. props["family"] = [*map(_family_unescape, parse["families"])]
  80. if "sizes" in parse:
  81. props["size"] = [*parse["sizes"]]
  82. for prop in parse.get("properties", []):
  83. if len(prop) == 1:
  84. prop = _CONSTANTS[prop[0]]
  85. k, *v = prop
  86. props.setdefault(k, []).extend(map(_value_unescape, v))
  87. return props
  88. def generate_fontconfig_pattern(d):
  89. """Convert a `.FontProperties` to a fontconfig pattern string."""
  90. kvs = [(k, getattr(d, f"get_{k}")())
  91. for k in ["style", "variant", "weight", "stretch", "file", "size"]]
  92. # Families is given first without a leading keyword. Other entries (which
  93. # are necessarily scalar) are given as key=value, skipping Nones.
  94. return (",".join(_family_escape(f) for f in d.get_family())
  95. + "".join(f":{k}={_value_escape(str(v))}"
  96. for k, v in kvs if v is not None))