_fontconfig_pattern.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  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)
  13. from matplotlib import _api
  14. _family_punc = r'\\\-:,'
  15. _family_unescape = partial(re.compile(r'\\(?=[%s])' % _family_punc).sub, '')
  16. _family_escape = partial(re.compile(r'(?=[%s])' % _family_punc).sub, r'\\')
  17. _value_punc = r'\\=_:,'
  18. _value_unescape = partial(re.compile(r'\\(?=[%s])' % _value_punc).sub, '')
  19. _value_escape = partial(re.compile(r'(?=[%s])' % _value_punc).sub, r'\\')
  20. _CONSTANTS = {
  21. 'thin': ('weight', 'light'),
  22. 'extralight': ('weight', 'light'),
  23. 'ultralight': ('weight', 'light'),
  24. 'light': ('weight', 'light'),
  25. 'book': ('weight', 'book'),
  26. 'regular': ('weight', 'regular'),
  27. 'normal': ('weight', 'normal'),
  28. 'medium': ('weight', 'medium'),
  29. 'demibold': ('weight', 'demibold'),
  30. 'semibold': ('weight', 'semibold'),
  31. 'bold': ('weight', 'bold'),
  32. 'extrabold': ('weight', 'extra bold'),
  33. 'black': ('weight', 'black'),
  34. 'heavy': ('weight', 'heavy'),
  35. 'roman': ('slant', 'normal'),
  36. 'italic': ('slant', 'italic'),
  37. 'oblique': ('slant', 'oblique'),
  38. 'ultracondensed': ('width', 'ultra-condensed'),
  39. 'extracondensed': ('width', 'extra-condensed'),
  40. 'condensed': ('width', 'condensed'),
  41. 'semicondensed': ('width', 'semi-condensed'),
  42. 'expanded': ('width', 'expanded'),
  43. 'extraexpanded': ('width', 'extra-expanded'),
  44. 'ultraexpanded': ('width', 'ultra-expanded'),
  45. }
  46. @lru_cache # The parser instance is a singleton.
  47. def _make_fontconfig_parser():
  48. def comma_separated(elem):
  49. return elem + ZeroOrMore(Suppress(",") + elem)
  50. family = Regex(fr"([^{_family_punc}]|(\\[{_family_punc}]))*")
  51. size = Regex(r"([0-9]+\.?[0-9]*|\.[0-9]+)")
  52. name = Regex(r"[a-z]+")
  53. value = Regex(fr"([^{_value_punc}]|(\\[{_value_punc}]))*")
  54. # replace trailing `| name` by oneOf(_CONSTANTS) in mpl 3.9.
  55. prop = Group((name + Suppress("=") + comma_separated(value)) | name)
  56. return (
  57. Optional(comma_separated(family)("families"))
  58. + Optional("-" + comma_separated(size)("sizes"))
  59. + ZeroOrMore(":" + prop("properties*"))
  60. + StringEnd()
  61. )
  62. # `parse_fontconfig_pattern` is a bottleneck during the tests because it is
  63. # repeatedly called when the rcParams are reset (to validate the default
  64. # fonts). In practice, the cache size doesn't grow beyond a few dozen entries
  65. # during the test suite.
  66. @lru_cache
  67. def parse_fontconfig_pattern(pattern):
  68. """
  69. Parse a fontconfig *pattern* into a dict that can initialize a
  70. `.font_manager.FontProperties` object.
  71. """
  72. parser = _make_fontconfig_parser()
  73. try:
  74. parse = parser.parseString(pattern)
  75. except ParseException as err:
  76. # explain becomes a plain method on pyparsing 3 (err.explain(0)).
  77. raise ValueError("\n" + ParseException.explain(err, 0)) from None
  78. parser.resetCache()
  79. props = {}
  80. if "families" in parse:
  81. props["family"] = [*map(_family_unescape, parse["families"])]
  82. if "sizes" in parse:
  83. props["size"] = [*parse["sizes"]]
  84. for prop in parse.get("properties", []):
  85. if len(prop) == 1:
  86. if prop[0] not in _CONSTANTS:
  87. _api.warn_deprecated(
  88. "3.7", message=f"Support for unknown constants "
  89. f"({prop[0]!r}) is deprecated since %(since)s and "
  90. f"will be removed %(removal)s.")
  91. continue
  92. prop = _CONSTANTS[prop[0]]
  93. k, *v = prop
  94. props.setdefault(k, []).extend(map(_value_unescape, v))
  95. return props
  96. def generate_fontconfig_pattern(d):
  97. """Convert a `.FontProperties` to a fontconfig pattern string."""
  98. kvs = [(k, getattr(d, f"get_{k}")())
  99. for k in ["style", "variant", "weight", "stretch", "file", "size"]]
  100. # Families is given first without a leading keyword. Other entries (which
  101. # are necessarily scalar) are given as key=value, skipping Nones.
  102. return (",".join(_family_escape(f) for f in d.get_family())
  103. + "".join(f":{k}={_value_escape(str(v))}"
  104. for k, v in kvs if v is not None))