repo_description: matplotlib: plotting with Python
repo_topics: data-science data-visualization gtk matplotlib plotting python qt tk wx
code: """
  A module for parsing and generating `fontconfig patterns`_.
  
  .. _fontconfig patterns:
     https://www.freedesktop.org/software/fontconfig/fontconfig-user.html
  """
  
  # This class logically belongs in `matplotlib.font_manager`, but placing it
  # there would have created cyclical dependency problems, because it also needs
  # to be available from `matplotlib.rcsetup` (for parsing matplotlibrc files).
  
  from functools import cache, lru_cache, partial
  import re
  
  from pyparsing import (
      Group, Optional, ParseException, Regex, StringEnd, Suppress, ZeroOrMore, one_of)
  
  
  _family_punc = r'\\\-:,'
  _family_unescape = partial(re.compile(r'\\(?=[%s])' % _family_punc).sub, '')
  _family_escape = partial(re.compile(r'(?=[%s])' % _family_punc).sub, r'\\')
  _value_punc = r'\\=_:,'
  _value_unescape = partial(re.compile(r'\\(?=[%s])' % _value_punc).sub, '')
  _value_escape = partial(re.compile(r'(?=[%s])' % _value_punc).sub, r'\\')
  
  
  _CONSTANTS = {
      'thin':           ('weight', 'light'),
      'extralight':     ('weight', 'light'),
      'ultralight':     ('weight', 'light'),
      'light':          ('weight', 'light'),
      'book':           ('weight', 'book'),
      'regular':        ('weight', 'regular'),
      'normal':         ('weight', 'normal'),
      'medium':         ('weight', 'medium'),
      'demibold':       ('weight', 'demibold'),
      'semibold':       ('weight', 'semibold'),
      'bold':           ('weight', 'bold'),
      'extrabold':      ('weight', 'extra bold'),
      'black':          ('weight', 'black'),
      'heavy':          ('weight', 'heavy'),
      'roman':          ('slant', 'normal'),
      'italic':         ('slant', 'italic'),
      'oblique':        ('slant', 'oblique'),
      'ultracondensed': ('width', 'ultra-condensed'),
      'extracondensed': ('width', 'extra-condensed'),
      'condensed':      ('width', 'condensed'),
      'semicondensed':  ('width', 'semi-condensed'),
      'expanded':       ('width', 'expanded'),
      'extraexpanded':  ('width', 'extra-expanded'),
      'ultraexpanded':  ('width', 'ultra-expanded'),
  }
  
  
  @cache  # The parser instance is a singleton.
  def _make_fontconfig_parser():
      def comma_separated(elem):
          return elem + ZeroOrMore(Suppress(",") + elem)
  
      family = Regex(fr"([^{_family_punc}]|(\\[{_family_punc}]))*")
      size = Regex(r"([0-9]+\.?[0-9]*|\.[0-9]+)")
      name = Regex(r"[a-z]+")
      value = Regex(fr"([^{_value_punc}]|(\\[{_value_punc}]))*")
      prop = Group((name + Suppress("=") + comma_separated(value)) | one_of(_CONSTANTS))
      return (
          Optional(comma_separated(family)("families"))
          + Optional("-" + comma_separated(size)("sizes"))
          + ZeroOrMore(":" + prop("properties*"))
          + StringEnd()
      )
  
  
  # `parse_fontconfig_pattern` is a bottleneck during the tests because it is
  # repeatedly called when the rcParams are reset (to validate the default
  # fonts).  In practice, the cache size doesn't grow beyond a few dozen entries
  # during the test suite.
  @lru_cache
  def parse_fontconfig_pattern(pattern):
      """
      Parse a fontconfig *pattern* into a dict that can initialize a
      `.font_manager.FontProperties` object.
      """
      parser = _make_fontconfig_parser()
      try:
          parse = parser.parse_string(pattern)
      except ParseException as err:
          # explain becomes a plain method on pyparsing 3 (err.explain(0)).
          raise ValueError("\n" + ParseException.explain(err, 0)) from None
      parser.reset_cache()
      props = {}
      if "families" in parse:
          props["family"] = [*map(_family_unescape, parse["families"])]
      if "sizes" in parse:
          props["size"] = [*parse["sizes"]]
      for prop in parse.get("properties", []):
          if len(prop) == 1:
              prop = _CONSTANTS[prop[0]]
          k, *v = prop
          props.setdefault(k, []).extend(map(_value_unescape, v))
      return props
  
  
  def generate_fontconfig_pattern(d):
      """Convert a `.FontProperties` to a fontconfig pattern string."""
      kvs = [(k, getattr(d, f"get_{k}")())
             for k in ["style", "variant", "weight", "stretch", "file", "size"]]
      # Families is given first without a leading keyword.  Other entries (which
      # are necessarily scalar) are given as key=value, skipping Nones.
      return (",".join(_family_escape(f) for f in d.get_family())
              + "".join(f":{k}={_value_escape(str(v))}"
                        for k, v in kvs if v is not None))
  