diff --git a/doc/api/next_api_changes/behavior/20715-JKS.rst b/doc/api/next_api_changes/behavior/20715-JKS.rst index f0ca1d707d3d..a91aa40ed68c 100644 --- a/doc/api/next_api_changes/behavior/20715-JKS.rst +++ b/doc/api/next_api_changes/behavior/20715-JKS.rst @@ -1,8 +1,8 @@ ``Type1Font`` objects include more properties --------------------------------------------- -The `.type1font.Type1Font.prop` dictionary now includes more keys, such -as ``CharStrings`` and ``Subrs``. The value of the ``Encoding`` key is +The ``matplotlib._type1font.Type1Font.prop`` dictionary now includes more keys, +such as ``CharStrings`` and ``Subrs``. The value of the ``Encoding`` key is now a dictionary mapping codes to glyph names. The -`.type1font.Type1Font.transform` method now correctly removes +``matplotlib._type1font.Type1Font.transform`` method now correctly removes ``UniqueID`` properties from the font. diff --git a/doc/api/next_api_changes/deprecations/22133-OG.rst b/doc/api/next_api_changes/deprecations/22133-OG.rst new file mode 100644 index 000000000000..71d86906a9d8 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/22133-OG.rst @@ -0,0 +1,6 @@ +``AFM``, ``configfont_pattern`` and ``Type1Font`` deprecated +------------------------------------------------------------ + +The modules ``matplotlib.AFM``, ``matplotlib.configfont_pattern``, and +``matplotlib.Type1Font`` are considered internal and public access is +deprecated. \ No newline at end of file diff --git a/doc/api/prev_api_changes/api_changes_0.91.0.rst b/doc/api/prev_api_changes/api_changes_0.91.0.rst index b33fcb50f0ad..7ad3776be0ce 100644 --- a/doc/api/prev_api_changes/api_changes_0.91.0.rst +++ b/doc/api/prev_api_changes/api_changes_0.91.0.rst @@ -36,7 +36,7 @@ Changes for 0.91.0 pfa/pfb file, to get metrics and kerning information for a Type 1 font. -* The :class:`.AFM` class now supports querying CapHeight and stem +* The ``AFM`` class now supports querying CapHeight and stem widths. The get_name_char method now has an isord kwarg like get_width_char. diff --git a/doc/api/prev_api_changes/api_changes_0.98.x.rst b/doc/api/prev_api_changes/api_changes_0.98.x.rst index 41ee63502254..b6e52fdd0b9a 100644 --- a/doc/api/prev_api_changes/api_changes_0.98.x.rst +++ b/doc/api/prev_api_changes/api_changes_0.98.x.rst @@ -63,8 +63,8 @@ Changes for 0.98.x :meth:`matplotlib.axes.Axes.set_ylim` now return a copy of the ``viewlim`` array to avoid modify-in-place surprises. -* :meth:`matplotlib.afm.AFM.get_fullname` and - :meth:`matplotlib.afm.AFM.get_familyname` no longer raise an +* ``matplotlib.afm.AFM.get_fullname`` and + ``matplotlib.afm.AFM.get_familyname`` no longer raise an exception if the AFM file does not specify these optional attributes, but returns a guess based on the required FontName attribute. diff --git a/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst b/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst index fefc398c2e66..9168bd3a3a6a 100644 --- a/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst +++ b/doc/api/prev_api_changes/api_changes_3.5.0/behaviour.rst @@ -229,8 +229,8 @@ defaults to *False*. Type 1 fonts have a large part of their code encrypted as an obsolete copy-protection measure. This part is now available decrypted as the -``decrypted`` attribute of `~.type1font.Type1Font`. This decrypted data is not -yet parsed, but this is a prerequisite for implementing subsetting. +``decrypted`` attribute of ``matplotlib.type1font.Type1Font``. This decrypted +data is not yet parsed, but this is a prerequisite for implementing subsetting. 3D contourf polygons placed between levels ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py new file mode 100644 index 000000000000..3d02d7f9c1d6 --- /dev/null +++ b/lib/matplotlib/_afm.py @@ -0,0 +1,532 @@ +""" +A python interface to Adobe Font Metrics Files. + +Although a number of other Python implementations exist, and may be more +complete than this, it was decided not to go with them because they were +either: + +1) copyrighted or used a non-BSD compatible license +2) had too many dependencies and a free standing lib was needed +3) did more than needed and it was easier to write afresh rather than + figure out how to get just what was needed. + +It is pretty easy to use, and has no external dependencies: + +>>> import matplotlib as mpl +>>> from pathlib import Path +>>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') +>>> +>>> from matplotlib.afm import AFM +>>> with afm_path.open('rb') as fh: +... afm = AFM(fh) +>>> afm.string_width_height('What the heck?') +(6220.0, 694) +>>> afm.get_fontname() +'Times-Roman' +>>> afm.get_kern_dist('A', 'f') +0 +>>> afm.get_kern_dist('A', 'y') +-92.0 +>>> afm.get_bbox_char('!') +[130, -9, 238, 676] + +As in the Adobe Font Metrics File Format Specification, all dimensions +are given in units of 1/1000 of the scale factor (point size) of the font +being used. +""" + +from collections import namedtuple +import logging +import re + +from ._mathtext_data import uni2type1 + + +_log = logging.getLogger(__name__) + + +def _to_int(x): + # Some AFM files have floats where we are expecting ints -- there is + # probably a better way to handle this (support floats, round rather than + # truncate). But I don't know what the best approach is now and this + # change to _to_int should at least prevent Matplotlib from crashing on + # these. JDH (2009-11-06) + return int(float(x)) + + +def _to_float(x): + # Some AFM files use "," instead of "." as decimal separator -- this + # shouldn't be ambiguous (unless someone is wicked enough to use "," as + # thousands separator...). + if isinstance(x, bytes): + # Encoding doesn't really matter -- if we have codepoints >127 the call + # to float() will error anyways. + x = x.decode('latin-1') + return float(x.replace(',', '.')) + + +def _to_str(x): + return x.decode('utf8') + + +def _to_list_of_ints(s): + s = s.replace(b',', b' ') + return [_to_int(val) for val in s.split()] + + +def _to_list_of_floats(s): + return [_to_float(val) for val in s.split()] + + +def _to_bool(s): + if s.lower().strip() in (b'false', b'0', b'no'): + return False + else: + return True + + +def _parse_header(fh): + """ + Read the font metrics header (up to the char metrics) and returns + a dictionary mapping *key* to *val*. *val* will be converted to the + appropriate python type as necessary; e.g.: + + * 'False'->False + * '0'->0 + * '-168 -218 1000 898'-> [-168, -218, 1000, 898] + + Dictionary keys are + + StartFontMetrics, FontName, FullName, FamilyName, Weight, + ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, + UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, + XHeight, Ascender, Descender, StartCharMetrics + """ + header_converters = { + b'StartFontMetrics': _to_float, + b'FontName': _to_str, + b'FullName': _to_str, + b'FamilyName': _to_str, + b'Weight': _to_str, + b'ItalicAngle': _to_float, + b'IsFixedPitch': _to_bool, + b'FontBBox': _to_list_of_ints, + b'UnderlinePosition': _to_float, + b'UnderlineThickness': _to_float, + b'Version': _to_str, + # Some AFM files have non-ASCII characters (which are not allowed by + # the spec). Given that there is actually no public API to even access + # this field, just return it as straight bytes. + b'Notice': lambda x: x, + b'EncodingScheme': _to_str, + b'CapHeight': _to_float, # Is the second version a mistake, or + b'Capheight': _to_float, # do some AFM files contain 'Capheight'? -JKS + b'XHeight': _to_float, + b'Ascender': _to_float, + b'Descender': _to_float, + b'StdHW': _to_float, + b'StdVW': _to_float, + b'StartCharMetrics': _to_int, + b'CharacterSet': _to_str, + b'Characters': _to_int, + } + d = {} + first_line = True + for line in fh: + line = line.rstrip() + if line.startswith(b'Comment'): + continue + lst = line.split(b' ', 1) + key = lst[0] + if first_line: + # AFM spec, Section 4: The StartFontMetrics keyword + # [followed by a version number] must be the first line in + # the file, and the EndFontMetrics keyword must be the + # last non-empty line in the file. We just check the + # first header entry. + if key != b'StartFontMetrics': + raise RuntimeError('Not an AFM file') + first_line = False + if len(lst) == 2: + val = lst[1] + else: + val = b'' + try: + converter = header_converters[key] + except KeyError: + _log.error('Found an unknown keyword in AFM header (was %r)' % key) + continue + try: + d[key] = converter(val) + except ValueError: + _log.error('Value error parsing header in AFM: %s, %s', key, val) + continue + if key == b'StartCharMetrics': + break + else: + raise RuntimeError('Bad parse') + return d + + +CharMetrics = namedtuple('CharMetrics', 'width, name, bbox') +CharMetrics.__doc__ = """ + Represents the character metrics of a single character. + + Notes + ----- + The fields do currently only describe a subset of character metrics + information defined in the AFM standard. + """ +CharMetrics.width.__doc__ = """The character width (WX).""" +CharMetrics.name.__doc__ = """The character name (N).""" +CharMetrics.bbox.__doc__ = """ + The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" + + +def _parse_char_metrics(fh): + """ + Parse the given filehandle for character metrics information and return + the information as dicts. + + It is assumed that the file cursor is on the line behind + 'StartCharMetrics'. + + Returns + ------- + ascii_d : dict + A mapping "ASCII num of the character" to `.CharMetrics`. + name_d : dict + A mapping "character name" to `.CharMetrics`. + + Notes + ----- + This function is incomplete per the standard, but thus far parses + all the sample afm files tried. + """ + required_keys = {'C', 'WX', 'N', 'B'} + + ascii_d = {} + name_d = {} + for line in fh: + # We are defensively letting values be utf8. The spec requires + # ascii, but there are non-compliant fonts in circulation + line = _to_str(line.rstrip()) # Convert from byte-literal + if line.startswith('EndCharMetrics'): + return ascii_d, name_d + # Split the metric line into a dictionary, keyed by metric identifiers + vals = dict(s.strip().split(' ', 1) for s in line.split(';') if s) + # There may be other metrics present, but only these are needed + if not required_keys.issubset(vals): + raise RuntimeError('Bad char metrics line: %s' % line) + num = _to_int(vals['C']) + wx = _to_float(vals['WX']) + name = vals['N'] + bbox = _to_list_of_floats(vals['B']) + bbox = list(map(int, bbox)) + metrics = CharMetrics(wx, name, bbox) + # Workaround: If the character name is 'Euro', give it the + # corresponding character code, according to WinAnsiEncoding (see PDF + # Reference). + if name == 'Euro': + num = 128 + elif name == 'minus': + num = ord("\N{MINUS SIGN}") # 0x2212 + if num != -1: + ascii_d[num] = metrics + name_d[name] = metrics + raise RuntimeError('Bad parse') + + +def _parse_kern_pairs(fh): + """ + Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and + values are the kern pair value. For example, a kern pairs line like + ``KPX A y -50`` + + will be represented as:: + + d[ ('A', 'y') ] = -50 + + """ + + line = next(fh) + if not line.startswith(b'StartKernPairs'): + raise RuntimeError('Bad start of kern pairs data: %s' % line) + + d = {} + for line in fh: + line = line.rstrip() + if not line: + continue + if line.startswith(b'EndKernPairs'): + next(fh) # EndKernData + return d + vals = line.split() + if len(vals) != 4 or vals[0] != b'KPX': + raise RuntimeError('Bad kern pairs line: %s' % line) + c1, c2, val = _to_str(vals[1]), _to_str(vals[2]), _to_float(vals[3]) + d[(c1, c2)] = val + raise RuntimeError('Bad kern pairs parse') + + +CompositePart = namedtuple('CompositePart', 'name, dx, dy') +CompositePart.__doc__ = """ + Represents the information on a composite element of a composite char.""" +CompositePart.name.__doc__ = """Name of the part, e.g. 'acute'.""" +CompositePart.dx.__doc__ = """x-displacement of the part from the origin.""" +CompositePart.dy.__doc__ = """y-displacement of the part from the origin.""" + + +def _parse_composites(fh): + """ + Parse the given filehandle for composites information return them as a + dict. + + It is assumed that the file cursor is on the line behind 'StartComposites'. + + Returns + ------- + dict + A dict mapping composite character names to a parts list. The parts + list is a list of `.CompositePart` entries describing the parts of + the composite. + + Examples + -------- + A composite definition line:: + + CC Aacute 2 ; PCC A 0 0 ; PCC acute 160 170 ; + + will be represented as:: + + composites['Aacute'] = [CompositePart(name='A', dx=0, dy=0), + CompositePart(name='acute', dx=160, dy=170)] + + """ + composites = {} + for line in fh: + line = line.rstrip() + if not line: + continue + if line.startswith(b'EndComposites'): + return composites + vals = line.split(b';') + cc = vals[0].split() + name, _num_parts = cc[1], _to_int(cc[2]) + pccParts = [] + for s in vals[1:-1]: + pcc = s.split() + part = CompositePart(pcc[1], _to_float(pcc[2]), _to_float(pcc[3])) + pccParts.append(part) + composites[name] = pccParts + + raise RuntimeError('Bad composites parse') + + +def _parse_optional(fh): + """ + Parse the optional fields for kern pair data and composites. + + Returns + ------- + kern_data : dict + A dict containing kerning information. May be empty. + See `._parse_kern_pairs`. + composites : dict + A dict containing composite information. May be empty. + See `._parse_composites`. + """ + optional = { + b'StartKernData': _parse_kern_pairs, + b'StartComposites': _parse_composites, + } + + d = {b'StartKernData': {}, + b'StartComposites': {}} + for line in fh: + line = line.rstrip() + if not line: + continue + key = line.split()[0] + + if key in optional: + d[key] = optional[key](fh) + + return d[b'StartKernData'], d[b'StartComposites'] + + +class AFM: + + def __init__(self, fh): + """Parse the AFM file in file object *fh*.""" + self._header = _parse_header(fh) + self._metrics, self._metrics_by_name = _parse_char_metrics(fh) + self._kern, self._composite = _parse_optional(fh) + + def get_bbox_char(self, c, isord=False): + if not isord: + c = ord(c) + return self._metrics[c].bbox + + def string_width_height(self, s): + """ + Return the string width (including kerning) and string height + as a (*w*, *h*) tuple. + """ + if not len(s): + return 0, 0 + total_width = 0 + namelast = None + miny = 1e9 + maxy = 0 + for c in s: + if c == '\n': + continue + wx, name, bbox = self._metrics[ord(c)] + + total_width += wx + self._kern.get((namelast, name), 0) + l, b, w, h = bbox + miny = min(miny, b) + maxy = max(maxy, b + h) + + namelast = name + + return total_width, maxy - miny + + def get_str_bbox_and_descent(self, s): + """Return the string bounding box and the maximal descent.""" + if not len(s): + return 0, 0, 0, 0, 0 + total_width = 0 + namelast = None + miny = 1e9 + maxy = 0 + left = 0 + if not isinstance(s, str): + s = _to_str(s) + for c in s: + if c == '\n': + continue + name = uni2type1.get(ord(c), f"uni{ord(c):04X}") + try: + wx, _, bbox = self._metrics_by_name[name] + except KeyError: + name = 'question' + wx, _, bbox = self._metrics_by_name[name] + total_width += wx + self._kern.get((namelast, name), 0) + l, b, w, h = bbox + left = min(left, l) + miny = min(miny, b) + maxy = max(maxy, b + h) + + namelast = name + + return left, miny, total_width, maxy - miny, -miny + + def get_str_bbox(self, s): + """Return the string bounding box.""" + return self.get_str_bbox_and_descent(s)[:4] + + def get_name_char(self, c, isord=False): + """Get the name of the character, i.e., ';' is 'semicolon'.""" + if not isord: + c = ord(c) + return self._metrics[c].name + + def get_width_char(self, c, isord=False): + """ + Get the width of the character from the character metric WX field. + """ + if not isord: + c = ord(c) + return self._metrics[c].width + + def get_width_from_char_name(self, name): + """Get the width of the character from a type1 character name.""" + return self._metrics_by_name[name].width + + def get_height_char(self, c, isord=False): + """Get the bounding box (ink) height of character *c* (space is 0).""" + if not isord: + c = ord(c) + return self._metrics[c].bbox[-1] + + def get_kern_dist(self, c1, c2): + """ + Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. + """ + name1, name2 = self.get_name_char(c1), self.get_name_char(c2) + return self.get_kern_dist_from_name(name1, name2) + + def get_kern_dist_from_name(self, name1, name2): + """ + Return the kerning pair distance (possibly 0) for chars + *name1* and *name2*. + """ + return self._kern.get((name1, name2), 0) + + def get_fontname(self): + """Return the font name, e.g., 'Times-Roman'.""" + return self._header[b'FontName'] + + @property + def postscript_name(self): # For consistency with FT2Font. + return self.get_fontname() + + def get_fullname(self): + """Return the font full name, e.g., 'Times-Roman'.""" + name = self._header.get(b'FullName') + if name is None: # use FontName as a substitute + name = self._header[b'FontName'] + return name + + def get_familyname(self): + """Return the font family name, e.g., 'Times'.""" + name = self._header.get(b'FamilyName') + if name is not None: + return name + + # FamilyName not specified so we'll make a guess + name = self.get_fullname() + extras = (r'(?i)([ -](regular|plain|italic|oblique|bold|semibold|' + r'light|ultralight|extra|condensed))+$') + return re.sub(extras, '', name) + + @property + def family_name(self): + """The font family name, e.g., 'Times'.""" + return self.get_familyname() + + def get_weight(self): + """Return the font weight, e.g., 'Bold' or 'Roman'.""" + return self._header[b'Weight'] + + def get_angle(self): + """Return the fontangle as float.""" + return self._header[b'ItalicAngle'] + + def get_capheight(self): + """Return the cap height as float.""" + return self._header[b'CapHeight'] + + def get_xheight(self): + """Return the xheight as float.""" + return self._header[b'XHeight'] + + def get_underline_thickness(self): + """Return the underline thickness as float.""" + return self._header[b'UnderlineThickness'] + + def get_horizontal_stem_width(self): + """ + Return the standard horizontal stem width as float, or *None* if + not specified in AFM file. + """ + return self._header.get(b'StdHW', None) + + def get_vertical_stem_width(self): + """ + Return the standard vertical stem width as float, or *None* if + not specified in AFM file. + """ + return self._header.get(b'StdVW', None) diff --git a/lib/matplotlib/_fontconfig_pattern.py b/lib/matplotlib/_fontconfig_pattern.py new file mode 100644 index 000000000000..c47e19bf99dc --- /dev/null +++ b/lib/matplotlib/_fontconfig_pattern.py @@ -0,0 +1,209 @@ +""" +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 lru_cache +import re +import numpy as np +from pyparsing import (Literal, ZeroOrMore, Optional, Regex, StringEnd, + ParseException, Suppress) + +family_punc = r'\\\-:,' +family_unescape = re.compile(r'\\([%s])' % family_punc).sub +family_escape = re.compile(r'([%s])' % family_punc).sub + +value_punc = r'\\=_:,' +value_unescape = re.compile(r'\\([%s])' % value_punc).sub +value_escape = re.compile(r'([%s])' % value_punc).sub + + +class FontconfigPatternParser: + """ + A simple pyparsing-based parser for `fontconfig patterns`_. + + .. _fontconfig patterns: + https://www.freedesktop.org/software/fontconfig/fontconfig-user.html + """ + + _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') + } + + def __init__(self): + + family = Regex( + r'([^%s]|(\\[%s]))*' % (family_punc, family_punc) + ).setParseAction(self._family) + + size = Regex( + r"([0-9]+\.?[0-9]*|\.[0-9]+)" + ).setParseAction(self._size) + + name = Regex( + r'[a-z]+' + ).setParseAction(self._name) + + value = Regex( + r'([^%s]|(\\[%s]))*' % (value_punc, value_punc) + ).setParseAction(self._value) + + families = ( + family + + ZeroOrMore( + Literal(',') + + family) + ).setParseAction(self._families) + + point_sizes = ( + size + + ZeroOrMore( + Literal(',') + + size) + ).setParseAction(self._point_sizes) + + property = ( + (name + + Suppress(Literal('=')) + + value + + ZeroOrMore( + Suppress(Literal(',')) + + value)) + | name + ).setParseAction(self._property) + + pattern = ( + Optional( + families) + + Optional( + Literal('-') + + point_sizes) + + ZeroOrMore( + Literal(':') + + property) + + StringEnd() + ) + + self._parser = pattern + self.ParseException = ParseException + + def parse(self, pattern): + """ + Parse the given fontconfig *pattern* and return a dictionary + of key/value pairs useful for initializing a + `.font_manager.FontProperties` object. + """ + props = self._properties = {} + try: + self._parser.parseString(pattern) + except self.ParseException as e: + raise ValueError( + "Could not parse font string: '%s'\n%s" % (pattern, e)) from e + + self._properties = None + + self._parser.resetCache() + + return props + + def _family(self, s, loc, tokens): + return [family_unescape(r'\1', str(tokens[0]))] + + def _size(self, s, loc, tokens): + return [float(tokens[0])] + + def _name(self, s, loc, tokens): + return [str(tokens[0])] + + def _value(self, s, loc, tokens): + return [value_unescape(r'\1', str(tokens[0]))] + + def _families(self, s, loc, tokens): + self._properties['family'] = [str(x) for x in tokens] + return [] + + def _point_sizes(self, s, loc, tokens): + self._properties['size'] = [str(x) for x in tokens] + return [] + + def _property(self, s, loc, tokens): + if len(tokens) == 1: + if tokens[0] in self._constants: + key, val = self._constants[tokens[0]] + self._properties.setdefault(key, []).append(val) + else: + key = tokens[0] + val = tokens[1:] + self._properties.setdefault(key, []).extend(val) + return [] + + +# `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. +parse_fontconfig_pattern = lru_cache()(FontconfigPatternParser().parse) + + +def _escape_val(val, escape_func): + """ + Given a string value or a list of string values, run each value through + the input escape function to make the values into legal font config + strings. The result is returned as a string. + """ + if not np.iterable(val) or isinstance(val, str): + val = [val] + + return ','.join(escape_func(r'\\\1', str(x)) for x in val + if x is not None) + + +def generate_fontconfig_pattern(d): + """ + Given a dictionary of key/value pairs, generates a fontconfig + pattern string. + """ + props = [] + + # Family is added first w/o a keyword + family = d.get_family() + if family is not None and family != []: + props.append(_escape_val(family, family_escape)) + + # The other keys are added as key=value + for key in ['style', 'variant', 'weight', 'stretch', 'file', 'size']: + val = getattr(d, 'get_' + key)() + # Don't use 'if not val' because 0 is a valid input. + if val is not None and val != []: + props.append(":%s=%s" % (key, _escape_val(val, value_escape))) + + return ''.join(props) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 58033fbfd7fe..5d28c59eba51 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -22,7 +22,7 @@ from ._mathtext_data import ( latex_to_bakoma, latex_to_standard, stix_glyph_fixes, stix_virtual_fonts, tex2uni) -from .afm import AFM +from ._afm import AFM from .font_manager import FontProperties, findfont, get_font from .ft2font import KERNING_DEFAULT diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py new file mode 100644 index 000000000000..0413cb0016a0 --- /dev/null +++ b/lib/matplotlib/_type1font.py @@ -0,0 +1,877 @@ +""" +A class representing a Type 1 font. + +This version reads pfa and pfb files and splits them for embedding in +pdf files. It also supports SlantFont and ExtendFont transformations, +similarly to pdfTeX and friends. There is no support yet for subsetting. + +Usage:: + + font = Type1Font(filename) + clear_part, encrypted_part, finale = font.parts + slanted_font = font.transform({'slant': 0.167}) + extended_font = font.transform({'extend': 1.2}) + +Sources: + +* Adobe Technical Note #5040, Supporting Downloadable PostScript + Language Fonts. + +* Adobe Type 1 Font Format, Adobe Systems Incorporated, third printing, + v1.1, 1993. ISBN 0-201-57044-0. +""" + +import binascii +import functools +import logging +import re +import string +import struct + +import numpy as np + +from matplotlib.cbook import _format_approx +from . import _api + +_log = logging.getLogger(__name__) + + +class _Token: + """ + A token in a PostScript stream. + + Attributes + ---------- + pos : int + Position, i.e. offset from the beginning of the data. + raw : str + Raw text of the token. + kind : str + Description of the token (for debugging or testing). + """ + __slots__ = ('pos', 'raw') + kind = '?' + + def __init__(self, pos, raw): + _log.debug('type1font._Token %s at %d: %r', self.kind, pos, raw) + self.pos = pos + self.raw = raw + + def __str__(self): + return f"<{self.kind} {self.raw} @{self.pos}>" + + def endpos(self): + """Position one past the end of the token""" + return self.pos + len(self.raw) + + def is_keyword(self, *names): + """Is this a name token with one of the names?""" + return False + + def is_slash_name(self): + """Is this a name token that starts with a slash?""" + return False + + def is_delim(self): + """Is this a delimiter token?""" + return False + + def is_number(self): + """Is this a number token?""" + return False + + def value(self): + return self.raw + + +class _NameToken(_Token): + kind = 'name' + + def is_slash_name(self): + return self.raw.startswith('/') + + def value(self): + return self.raw[1:] + + +class _BooleanToken(_Token): + kind = 'boolean' + + def value(self): + return self.raw == 'true' + + +class _KeywordToken(_Token): + kind = 'keyword' + + def is_keyword(self, *names): + return self.raw in names + + +class _DelimiterToken(_Token): + kind = 'delimiter' + + def is_delim(self): + return True + + def opposite(self): + return {'[': ']', ']': '[', + '{': '}', '}': '{', + '<<': '>>', '>>': '<<' + }[self.raw] + + +class _WhitespaceToken(_Token): + kind = 'whitespace' + + +class _StringToken(_Token): + kind = 'string' + _escapes_re = re.compile(r'\\([\\()nrtbf]|[0-7]{1,3})') + _replacements = {'\\': '\\', '(': '(', ')': ')', 'n': '\n', + 'r': '\r', 't': '\t', 'b': '\b', 'f': '\f'} + _ws_re = re.compile('[\0\t\r\f\n ]') + + @classmethod + def _escape(cls, match): + group = match.group(1) + try: + return cls._replacements[group] + except KeyError: + return chr(int(group, 8)) + + @functools.lru_cache() + def value(self): + if self.raw[0] == '(': + return self._escapes_re.sub(self._escape, self.raw[1:-1]) + else: + data = self._ws_re.sub('', self.raw[1:-1]) + if len(data) % 2 == 1: + data += '0' + return binascii.unhexlify(data) + + +class _BinaryToken(_Token): + kind = 'binary' + + def value(self): + return self.raw[1:] + + +class _NumberToken(_Token): + kind = 'number' + + def is_number(self): + return True + + def value(self): + if '.' not in self.raw: + return int(self.raw) + else: + return float(self.raw) + + +def _tokenize(data: bytes, skip_ws: bool): + """ + A generator that produces _Token instances from Type-1 font code. + + The consumer of the generator may send an integer to the tokenizer to + indicate that the next token should be _BinaryToken of the given length. + + Parameters + ---------- + data : bytes + The data of the font to tokenize. + + skip_ws : bool + If true, the generator will drop any _WhitespaceTokens from the output. + """ + + text = data.decode('ascii', 'replace') + whitespace_or_comment_re = re.compile(r'[\0\t\r\f\n ]+|%[^\r\n]*') + token_re = re.compile(r'/{0,2}[^]\0\t\r\f\n ()<>{}/%[]+') + instring_re = re.compile(r'[()\\]') + hex_re = re.compile(r'^<[0-9a-fA-F\0\t\r\f\n ]*>$') + oct_re = re.compile(r'[0-7]{1,3}') + pos = 0 + next_binary = None + + while pos < len(text): + if next_binary is not None: + n = next_binary + next_binary = (yield _BinaryToken(pos, data[pos:pos+n])) + pos += n + continue + match = whitespace_or_comment_re.match(text, pos) + if match: + if not skip_ws: + next_binary = (yield _WhitespaceToken(pos, match.group())) + pos = match.end() + elif text[pos] == '(': + # PostScript string rules: + # - parentheses must be balanced + # - backslashes escape backslashes and parens + # - also codes \n\r\t\b\f and octal escapes are recognized + # - other backslashes do not escape anything + start = pos + pos += 1 + depth = 1 + while depth: + match = instring_re.search(text, pos) + if match is None: + raise ValueError( + f'Unterminated string starting at {start}') + pos = match.end() + if match.group() == '(': + depth += 1 + elif match.group() == ')': + depth -= 1 + else: # a backslash + char = text[pos] + if char in r'\()nrtbf': + pos += 1 + else: + octal = oct_re.match(text, pos) + if octal: + pos = octal.end() + else: + pass # non-escaping backslash + next_binary = (yield _StringToken(start, text[start:pos])) + elif text[pos:pos + 2] in ('<<', '>>'): + next_binary = (yield _DelimiterToken(pos, text[pos:pos + 2])) + pos += 2 + elif text[pos] == '<': + start = pos + try: + pos = text.index('>', pos) + 1 + except ValueError as e: + raise ValueError(f'Unterminated hex string starting at {start}' + ) from e + if not hex_re.match(text[start:pos]): + raise ValueError(f'Malformed hex string starting at {start}') + next_binary = (yield _StringToken(pos, text[start:pos])) + else: + match = token_re.match(text, pos) + if match: + raw = match.group() + if raw.startswith('/'): + next_binary = (yield _NameToken(pos, raw)) + elif match.group() in ('true', 'false'): + next_binary = (yield _BooleanToken(pos, raw)) + else: + try: + float(raw) + next_binary = (yield _NumberToken(pos, raw)) + except ValueError: + next_binary = (yield _KeywordToken(pos, raw)) + pos = match.end() + else: + next_binary = (yield _DelimiterToken(pos, text[pos])) + pos += 1 + + +class _BalancedExpression(_Token): + pass + + +def _expression(initial, tokens, data): + """ + Consume some number of tokens and return a balanced PostScript expression. + + Parameters + ---------- + initial : _Token + The token that triggered parsing a balanced expression. + tokens : iterator of _Token + Following tokens. + data : bytes + Underlying data that the token positions point to. + + Returns + ------- + _BalancedExpression + """ + delim_stack = [] + token = initial + while True: + if token.is_delim(): + if token.raw in ('[', '{'): + delim_stack.append(token) + elif token.raw in (']', '}'): + if not delim_stack: + raise RuntimeError(f"unmatched closing token {token}") + match = delim_stack.pop() + if match.raw != token.opposite(): + raise RuntimeError( + f"opening token {match} closed by {token}" + ) + if not delim_stack: + break + else: + raise RuntimeError(f'unknown delimiter {token}') + elif not delim_stack: + break + token = next(tokens) + return _BalancedExpression( + initial.pos, + data[initial.pos:token.endpos()].decode('ascii', 'replace') + ) + + +class Type1Font: + """ + A class representing a Type-1 font, for use by backends. + + Attributes + ---------- + parts : tuple + A 3-tuple of the cleartext part, the encrypted part, and the finale of + zeros. + + decrypted : bytes + The decrypted form of ``parts[1]``. + + prop : dict[str, Any] + A dictionary of font properties. Noteworthy keys include: + + - FontName: PostScript name of the font + - Encoding: dict from numeric codes to glyph names + - FontMatrix: bytes object encoding a matrix + - UniqueID: optional font identifier, dropped when modifying the font + - CharStrings: dict from glyph names to byte code + - Subrs: array of byte code subroutines + - OtherSubrs: bytes object encoding some PostScript code + """ + __slots__ = ('parts', 'decrypted', 'prop', '_pos', '_abbr') + # the _pos dict contains (begin, end) indices to parts[0] + decrypted + # so that they can be replaced when transforming the font; + # but since sometimes a definition appears in both parts[0] and decrypted, + # _pos[name] is an array of such pairs + # + # _abbr maps three standard abbreviations to their particular names in + # this font (e.g. 'RD' is named '-|' in some fonts) + + def __init__(self, input): + """ + Initialize a Type-1 font. + + Parameters + ---------- + input : str or 3-tuple + Either a pfb file name, or a 3-tuple of already-decoded Type-1 + font `~.Type1Font.parts`. + """ + if isinstance(input, tuple) and len(input) == 3: + self.parts = input + else: + with open(input, 'rb') as file: + data = self._read(file) + self.parts = self._split(data) + + self.decrypted = self._decrypt(self.parts[1], 'eexec') + self._abbr = {'RD': 'RD', 'ND': 'ND', 'NP': 'NP'} + self._parse() + + def _read(self, file): + """Read the font from a file, decoding into usable parts.""" + rawdata = file.read() + if not rawdata.startswith(b'\x80'): + return rawdata + + data = b'' + while rawdata: + if not rawdata.startswith(b'\x80'): + raise RuntimeError('Broken pfb file (expected byte 128, ' + 'got %d)' % rawdata[0]) + type = rawdata[1] + if type in (1, 2): + length, = struct.unpack('> 8)) + key = ((key+byte) * 52845 + 22719) & 0xffff + + return bytes(plaintext[ndiscard:]) + + @staticmethod + def _encrypt(plaintext, key, ndiscard=4): + """ + Encrypt plaintext using the Type-1 font algorithm. + + The algorithm is described in Adobe's "Adobe Type 1 Font Format". + The key argument can be an integer, or one of the strings + 'eexec' and 'charstring', which map to the key specified for the + corresponding part of Type-1 fonts. + + The ndiscard argument should be an integer, usually 4. That + number of bytes is prepended to the plaintext before encryption. + This function prepends NUL bytes for reproducibility, even though + the original algorithm uses random bytes, presumably to avoid + cryptanalysis. + """ + + key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key) + ciphertext = [] + for byte in b'\0' * ndiscard + plaintext: + c = byte ^ (key >> 8) + ciphertext.append(c) + key = ((key + c) * 52845 + 22719) & 0xffff + + return bytes(ciphertext) + + def _parse(self): + """ + Find the values of various font properties. This limited kind + of parsing is described in Chapter 10 "Adobe Type Manager + Compatibility" of the Type-1 spec. + """ + # Start with reasonable defaults + prop = {'Weight': 'Regular', 'ItalicAngle': 0.0, 'isFixedPitch': False, + 'UnderlinePosition': -100, 'UnderlineThickness': 50} + pos = {} + data = self.parts[0] + self.decrypted + + source = _tokenize(data, True) + while True: + # See if there is a key to be assigned a value + # e.g. /FontName in /FontName /Helvetica def + try: + token = next(source) + except StopIteration: + break + if token.is_delim(): + # skip over this - we want top-level keys only + _expression(token, source, data) + if token.is_slash_name(): + key = token.value() + keypos = token.pos + else: + continue + + # Some values need special parsing + if key in ('Subrs', 'CharStrings', 'Encoding', 'OtherSubrs'): + prop[key], endpos = { + 'Subrs': self._parse_subrs, + 'CharStrings': self._parse_charstrings, + 'Encoding': self._parse_encoding, + 'OtherSubrs': self._parse_othersubrs + }[key](source, data) + pos.setdefault(key, []).append((keypos, endpos)) + continue + + try: + token = next(source) + except StopIteration: + break + + if isinstance(token, _KeywordToken): + # constructs like + # FontDirectory /Helvetica known {...} {...} ifelse + # mean the key was not really a key + continue + + if token.is_delim(): + value = _expression(token, source, data).raw + else: + value = token.value() + + # look for a 'def' possibly preceded by access modifiers + try: + kw = next( + kw for kw in source + if not kw.is_keyword('readonly', 'noaccess', 'executeonly') + ) + except StopIteration: + break + + # sometimes noaccess def and readonly def are abbreviated + if kw.is_keyword('def', self._abbr['ND'], self._abbr['NP']): + prop[key] = value + pos.setdefault(key, []).append((keypos, kw.endpos())) + + # detect the standard abbreviations + if value == '{noaccess def}': + self._abbr['ND'] = key + elif value == '{noaccess put}': + self._abbr['NP'] = key + elif value == '{string currentfile exch readstring pop}': + self._abbr['RD'] = key + + # Fill in the various *Name properties + if 'FontName' not in prop: + prop['FontName'] = (prop.get('FullName') or + prop.get('FamilyName') or + 'Unknown') + if 'FullName' not in prop: + prop['FullName'] = prop['FontName'] + if 'FamilyName' not in prop: + extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' + '(ultra)?light|extra|condensed))+$') + prop['FamilyName'] = re.sub(extras, '', prop['FullName']) + # Decrypt the encrypted parts + ndiscard = prop.get('lenIV', 4) + cs = prop['CharStrings'] + for key, value in cs.items(): + cs[key] = self._decrypt(value, 'charstring', ndiscard) + if 'Subrs' in prop: + prop['Subrs'] = [ + self._decrypt(value, 'charstring', ndiscard) + for value in prop['Subrs'] + ] + + self.prop = prop + self._pos = pos + + def _parse_subrs(self, tokens, _data): + count_token = next(tokens) + if not count_token.is_number(): + raise RuntimeError( + f"Token following /Subrs must be a number, was {count_token}" + ) + count = count_token.value() + array = [None] * count + next(t for t in tokens if t.is_keyword('array')) + for _ in range(count): + next(t for t in tokens if t.is_keyword('dup')) + index_token = next(tokens) + if not index_token.is_number(): + raise RuntimeError( + "Token following dup in Subrs definition must be a " + f"number, was {index_token}" + ) + nbytes_token = next(tokens) + if not nbytes_token.is_number(): + raise RuntimeError( + "Second token following dup in Subrs definition must " + f"be a number, was {nbytes_token}" + ) + token = next(tokens) + if not token.is_keyword(self._abbr['RD']): + raise RuntimeError( + f"Token preceding subr must be {self._abbr['RD']}, " + f"was {token}" + ) + binary_token = tokens.send(1+nbytes_token.value()) + array[index_token.value()] = binary_token.value() + + return array, next(tokens).endpos() + + @staticmethod + def _parse_charstrings(tokens, _data): + count_token = next(tokens) + if not count_token.is_number(): + raise RuntimeError( + "Token following /CharStrings must be a number, " + f"was {count_token}" + ) + count = count_token.value() + charstrings = {} + next(t for t in tokens if t.is_keyword('begin')) + while True: + token = next(t for t in tokens + if t.is_keyword('end') or t.is_slash_name()) + if token.raw == 'end': + return charstrings, token.endpos() + glyphname = token.value() + nbytes_token = next(tokens) + if not nbytes_token.is_number(): + raise RuntimeError( + f"Token following /{glyphname} in CharStrings definition " + f"must be a number, was {nbytes_token}" + ) + next(tokens) # usually RD or |- + binary_token = tokens.send(1+nbytes_token.value()) + charstrings[glyphname] = binary_token.value() + + @staticmethod + def _parse_encoding(tokens, _data): + # this only works for encodings that follow the Adobe manual + # but some old fonts include non-compliant data - we log a warning + # and return a possibly incomplete encoding + encoding = {} + while True: + token = next(t for t in tokens + if t.is_keyword('StandardEncoding', 'dup', 'def')) + if token.is_keyword('StandardEncoding'): + return _StandardEncoding, token.endpos() + if token.is_keyword('def'): + return encoding, token.endpos() + index_token = next(tokens) + if not index_token.is_number(): + _log.warning( + f"Parsing encoding: expected number, got {index_token}" + ) + continue + name_token = next(tokens) + if not name_token.is_slash_name(): + _log.warning( + f"Parsing encoding: expected slash-name, got {name_token}" + ) + continue + encoding[index_token.value()] = name_token.value() + + @staticmethod + def _parse_othersubrs(tokens, data): + init_pos = None + while True: + token = next(tokens) + if init_pos is None: + init_pos = token.pos + if token.is_delim(): + _expression(token, tokens, data) + elif token.is_keyword('def', 'ND', '|-'): + return data[init_pos:token.endpos()], token.endpos() + + def transform(self, effects): + """ + Return a new font that is slanted and/or extended. + + Parameters + ---------- + effects : dict + A dict with optional entries: + + - 'slant' : float, default: 0 + Tangent of the angle that the font is to be slanted to the + right. Negative values slant to the left. + - 'extend' : float, default: 1 + Scaling factor for the font width. Values less than 1 condense + the glyphs. + + Returns + ------- + `Type1Font` + """ + fontname = self.prop['FontName'] + italicangle = self.prop['ItalicAngle'] + + array = [ + float(x) for x in (self.prop['FontMatrix'] + .lstrip('[').rstrip(']').split()) + ] + oldmatrix = np.eye(3, 3) + oldmatrix[0:3, 0] = array[::2] + oldmatrix[0:3, 1] = array[1::2] + modifier = np.eye(3, 3) + + if 'slant' in effects: + slant = effects['slant'] + fontname += '_Slant_%d' % int(1000 * slant) + italicangle = round( + float(italicangle) - np.arctan(slant) / np.pi * 180, + 5 + ) + modifier[1, 0] = slant + + if 'extend' in effects: + extend = effects['extend'] + fontname += '_Extend_%d' % int(1000 * extend) + modifier[0, 0] = extend + + newmatrix = np.dot(modifier, oldmatrix) + array[::2] = newmatrix[0:3, 0] + array[1::2] = newmatrix[0:3, 1] + fontmatrix = ( + '[%s]' % ' '.join(_format_approx(x, 6) for x in array) + ) + replacements = ( + [(x, '/FontName/%s def' % fontname) + for x in self._pos['FontName']] + + [(x, '/ItalicAngle %a def' % italicangle) + for x in self._pos['ItalicAngle']] + + [(x, '/FontMatrix %s readonly def' % fontmatrix) + for x in self._pos['FontMatrix']] + + [(x, '') for x in self._pos.get('UniqueID', [])] + ) + + data = bytearray(self.parts[0]) + data.extend(self.decrypted) + len0 = len(self.parts[0]) + for (pos0, pos1), value in sorted(replacements, reverse=True): + data[pos0:pos1] = value.encode('ascii', 'replace') + if pos0 < len(self.parts[0]): + if pos1 >= len(self.parts[0]): + raise RuntimeError( + f"text to be replaced with {value} spans " + "the eexec boundary" + ) + len0 += len(value) - pos1 + pos0 + + data = bytes(data) + return Type1Font(( + data[:len0], + self._encrypt(data[len0:], 'eexec'), + self.parts[2] + )) + + +_StandardEncoding = { + **{ord(letter): letter for letter in string.ascii_letters}, + 0: '.notdef', + 32: 'space', + 33: 'exclam', + 34: 'quotedbl', + 35: 'numbersign', + 36: 'dollar', + 37: 'percent', + 38: 'ampersand', + 39: 'quoteright', + 40: 'parenleft', + 41: 'parenright', + 42: 'asterisk', + 43: 'plus', + 44: 'comma', + 45: 'hyphen', + 46: 'period', + 47: 'slash', + 48: 'zero', + 49: 'one', + 50: 'two', + 51: 'three', + 52: 'four', + 53: 'five', + 54: 'six', + 55: 'seven', + 56: 'eight', + 57: 'nine', + 58: 'colon', + 59: 'semicolon', + 60: 'less', + 61: 'equal', + 62: 'greater', + 63: 'question', + 64: 'at', + 91: 'bracketleft', + 92: 'backslash', + 93: 'bracketright', + 94: 'asciicircum', + 95: 'underscore', + 96: 'quoteleft', + 123: 'braceleft', + 124: 'bar', + 125: 'braceright', + 126: 'asciitilde', + 161: 'exclamdown', + 162: 'cent', + 163: 'sterling', + 164: 'fraction', + 165: 'yen', + 166: 'florin', + 167: 'section', + 168: 'currency', + 169: 'quotesingle', + 170: 'quotedblleft', + 171: 'guillemotleft', + 172: 'guilsinglleft', + 173: 'guilsinglright', + 174: 'fi', + 175: 'fl', + 177: 'endash', + 178: 'dagger', + 179: 'daggerdbl', + 180: 'periodcentered', + 182: 'paragraph', + 183: 'bullet', + 184: 'quotesinglbase', + 185: 'quotedblbase', + 186: 'quotedblright', + 187: 'guillemotright', + 188: 'ellipsis', + 189: 'perthousand', + 191: 'questiondown', + 193: 'grave', + 194: 'acute', + 195: 'circumflex', + 196: 'tilde', + 197: 'macron', + 198: 'breve', + 199: 'dotaccent', + 200: 'dieresis', + 202: 'ring', + 203: 'cedilla', + 205: 'hungarumlaut', + 206: 'ogonek', + 207: 'caron', + 208: 'emdash', + 225: 'AE', + 227: 'ordfeminine', + 232: 'Lslash', + 233: 'Oslash', + 234: 'OE', + 235: 'ordmasculine', + 241: 'ae', + 245: 'dotlessi', + 248: 'lslash', + 249: 'oslash', + 250: 'oe', + 251: 'germandbls', +} diff --git a/lib/matplotlib/afm.py b/lib/matplotlib/afm.py index 3d02d7f9c1d6..c0b28604bd8c 100644 --- a/lib/matplotlib/afm.py +++ b/lib/matplotlib/afm.py @@ -1,532 +1,5 @@ -""" -A python interface to Adobe Font Metrics Files. - -Although a number of other Python implementations exist, and may be more -complete than this, it was decided not to go with them because they were -either: - -1) copyrighted or used a non-BSD compatible license -2) had too many dependencies and a free standing lib was needed -3) did more than needed and it was easier to write afresh rather than - figure out how to get just what was needed. - -It is pretty easy to use, and has no external dependencies: - ->>> import matplotlib as mpl ->>> from pathlib import Path ->>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') ->>> ->>> from matplotlib.afm import AFM ->>> with afm_path.open('rb') as fh: -... afm = AFM(fh) ->>> afm.string_width_height('What the heck?') -(6220.0, 694) ->>> afm.get_fontname() -'Times-Roman' ->>> afm.get_kern_dist('A', 'f') -0 ->>> afm.get_kern_dist('A', 'y') --92.0 ->>> afm.get_bbox_char('!') -[130, -9, 238, 676] - -As in the Adobe Font Metrics File Format Specification, all dimensions -are given in units of 1/1000 of the scale factor (point size) of the font -being used. -""" - -from collections import namedtuple -import logging -import re - -from ._mathtext_data import uni2type1 - - -_log = logging.getLogger(__name__) - - -def _to_int(x): - # Some AFM files have floats where we are expecting ints -- there is - # probably a better way to handle this (support floats, round rather than - # truncate). But I don't know what the best approach is now and this - # change to _to_int should at least prevent Matplotlib from crashing on - # these. JDH (2009-11-06) - return int(float(x)) - - -def _to_float(x): - # Some AFM files use "," instead of "." as decimal separator -- this - # shouldn't be ambiguous (unless someone is wicked enough to use "," as - # thousands separator...). - if isinstance(x, bytes): - # Encoding doesn't really matter -- if we have codepoints >127 the call - # to float() will error anyways. - x = x.decode('latin-1') - return float(x.replace(',', '.')) - - -def _to_str(x): - return x.decode('utf8') - - -def _to_list_of_ints(s): - s = s.replace(b',', b' ') - return [_to_int(val) for val in s.split()] - - -def _to_list_of_floats(s): - return [_to_float(val) for val in s.split()] - - -def _to_bool(s): - if s.lower().strip() in (b'false', b'0', b'no'): - return False - else: - return True - - -def _parse_header(fh): - """ - Read the font metrics header (up to the char metrics) and returns - a dictionary mapping *key* to *val*. *val* will be converted to the - appropriate python type as necessary; e.g.: - - * 'False'->False - * '0'->0 - * '-168 -218 1000 898'-> [-168, -218, 1000, 898] - - Dictionary keys are - - StartFontMetrics, FontName, FullName, FamilyName, Weight, - ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, - UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, - XHeight, Ascender, Descender, StartCharMetrics - """ - header_converters = { - b'StartFontMetrics': _to_float, - b'FontName': _to_str, - b'FullName': _to_str, - b'FamilyName': _to_str, - b'Weight': _to_str, - b'ItalicAngle': _to_float, - b'IsFixedPitch': _to_bool, - b'FontBBox': _to_list_of_ints, - b'UnderlinePosition': _to_float, - b'UnderlineThickness': _to_float, - b'Version': _to_str, - # Some AFM files have non-ASCII characters (which are not allowed by - # the spec). Given that there is actually no public API to even access - # this field, just return it as straight bytes. - b'Notice': lambda x: x, - b'EncodingScheme': _to_str, - b'CapHeight': _to_float, # Is the second version a mistake, or - b'Capheight': _to_float, # do some AFM files contain 'Capheight'? -JKS - b'XHeight': _to_float, - b'Ascender': _to_float, - b'Descender': _to_float, - b'StdHW': _to_float, - b'StdVW': _to_float, - b'StartCharMetrics': _to_int, - b'CharacterSet': _to_str, - b'Characters': _to_int, - } - d = {} - first_line = True - for line in fh: - line = line.rstrip() - if line.startswith(b'Comment'): - continue - lst = line.split(b' ', 1) - key = lst[0] - if first_line: - # AFM spec, Section 4: The StartFontMetrics keyword - # [followed by a version number] must be the first line in - # the file, and the EndFontMetrics keyword must be the - # last non-empty line in the file. We just check the - # first header entry. - if key != b'StartFontMetrics': - raise RuntimeError('Not an AFM file') - first_line = False - if len(lst) == 2: - val = lst[1] - else: - val = b'' - try: - converter = header_converters[key] - except KeyError: - _log.error('Found an unknown keyword in AFM header (was %r)' % key) - continue - try: - d[key] = converter(val) - except ValueError: - _log.error('Value error parsing header in AFM: %s, %s', key, val) - continue - if key == b'StartCharMetrics': - break - else: - raise RuntimeError('Bad parse') - return d - - -CharMetrics = namedtuple('CharMetrics', 'width, name, bbox') -CharMetrics.__doc__ = """ - Represents the character metrics of a single character. - - Notes - ----- - The fields do currently only describe a subset of character metrics - information defined in the AFM standard. - """ -CharMetrics.width.__doc__ = """The character width (WX).""" -CharMetrics.name.__doc__ = """The character name (N).""" -CharMetrics.bbox.__doc__ = """ - The bbox of the character (B) as a tuple (*llx*, *lly*, *urx*, *ury*).""" - - -def _parse_char_metrics(fh): - """ - Parse the given filehandle for character metrics information and return - the information as dicts. - - It is assumed that the file cursor is on the line behind - 'StartCharMetrics'. - - Returns - ------- - ascii_d : dict - A mapping "ASCII num of the character" to `.CharMetrics`. - name_d : dict - A mapping "character name" to `.CharMetrics`. - - Notes - ----- - This function is incomplete per the standard, but thus far parses - all the sample afm files tried. - """ - required_keys = {'C', 'WX', 'N', 'B'} - - ascii_d = {} - name_d = {} - for line in fh: - # We are defensively letting values be utf8. The spec requires - # ascii, but there are non-compliant fonts in circulation - line = _to_str(line.rstrip()) # Convert from byte-literal - if line.startswith('EndCharMetrics'): - return ascii_d, name_d - # Split the metric line into a dictionary, keyed by metric identifiers - vals = dict(s.strip().split(' ', 1) for s in line.split(';') if s) - # There may be other metrics present, but only these are needed - if not required_keys.issubset(vals): - raise RuntimeError('Bad char metrics line: %s' % line) - num = _to_int(vals['C']) - wx = _to_float(vals['WX']) - name = vals['N'] - bbox = _to_list_of_floats(vals['B']) - bbox = list(map(int, bbox)) - metrics = CharMetrics(wx, name, bbox) - # Workaround: If the character name is 'Euro', give it the - # corresponding character code, according to WinAnsiEncoding (see PDF - # Reference). - if name == 'Euro': - num = 128 - elif name == 'minus': - num = ord("\N{MINUS SIGN}") # 0x2212 - if num != -1: - ascii_d[num] = metrics - name_d[name] = metrics - raise RuntimeError('Bad parse') - - -def _parse_kern_pairs(fh): - """ - Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and - values are the kern pair value. For example, a kern pairs line like - ``KPX A y -50`` - - will be represented as:: - - d[ ('A', 'y') ] = -50 - - """ - - line = next(fh) - if not line.startswith(b'StartKernPairs'): - raise RuntimeError('Bad start of kern pairs data: %s' % line) - - d = {} - for line in fh: - line = line.rstrip() - if not line: - continue - if line.startswith(b'EndKernPairs'): - next(fh) # EndKernData - return d - vals = line.split() - if len(vals) != 4 or vals[0] != b'KPX': - raise RuntimeError('Bad kern pairs line: %s' % line) - c1, c2, val = _to_str(vals[1]), _to_str(vals[2]), _to_float(vals[3]) - d[(c1, c2)] = val - raise RuntimeError('Bad kern pairs parse') - - -CompositePart = namedtuple('CompositePart', 'name, dx, dy') -CompositePart.__doc__ = """ - Represents the information on a composite element of a composite char.""" -CompositePart.name.__doc__ = """Name of the part, e.g. 'acute'.""" -CompositePart.dx.__doc__ = """x-displacement of the part from the origin.""" -CompositePart.dy.__doc__ = """y-displacement of the part from the origin.""" - - -def _parse_composites(fh): - """ - Parse the given filehandle for composites information return them as a - dict. - - It is assumed that the file cursor is on the line behind 'StartComposites'. - - Returns - ------- - dict - A dict mapping composite character names to a parts list. The parts - list is a list of `.CompositePart` entries describing the parts of - the composite. - - Examples - -------- - A composite definition line:: - - CC Aacute 2 ; PCC A 0 0 ; PCC acute 160 170 ; - - will be represented as:: - - composites['Aacute'] = [CompositePart(name='A', dx=0, dy=0), - CompositePart(name='acute', dx=160, dy=170)] - - """ - composites = {} - for line in fh: - line = line.rstrip() - if not line: - continue - if line.startswith(b'EndComposites'): - return composites - vals = line.split(b';') - cc = vals[0].split() - name, _num_parts = cc[1], _to_int(cc[2]) - pccParts = [] - for s in vals[1:-1]: - pcc = s.split() - part = CompositePart(pcc[1], _to_float(pcc[2]), _to_float(pcc[3])) - pccParts.append(part) - composites[name] = pccParts - - raise RuntimeError('Bad composites parse') - - -def _parse_optional(fh): - """ - Parse the optional fields for kern pair data and composites. - - Returns - ------- - kern_data : dict - A dict containing kerning information. May be empty. - See `._parse_kern_pairs`. - composites : dict - A dict containing composite information. May be empty. - See `._parse_composites`. - """ - optional = { - b'StartKernData': _parse_kern_pairs, - b'StartComposites': _parse_composites, - } - - d = {b'StartKernData': {}, - b'StartComposites': {}} - for line in fh: - line = line.rstrip() - if not line: - continue - key = line.split()[0] - - if key in optional: - d[key] = optional[key](fh) - - return d[b'StartKernData'], d[b'StartComposites'] - - -class AFM: - - def __init__(self, fh): - """Parse the AFM file in file object *fh*.""" - self._header = _parse_header(fh) - self._metrics, self._metrics_by_name = _parse_char_metrics(fh) - self._kern, self._composite = _parse_optional(fh) - - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - return self._metrics[c].bbox - - def string_width_height(self, s): - """ - Return the string width (including kerning) and string height - as a (*w*, *h*) tuple. - """ - if not len(s): - return 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == '\n': - continue - wx, name, bbox = self._metrics[ord(c)] - - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return total_width, maxy - miny - - def get_str_bbox_and_descent(self, s): - """Return the string bounding box and the maximal descent.""" - if not len(s): - return 0, 0, 0, 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - left = 0 - if not isinstance(s, str): - s = _to_str(s) - for c in s: - if c == '\n': - continue - name = uni2type1.get(ord(c), f"uni{ord(c):04X}") - try: - wx, _, bbox = self._metrics_by_name[name] - except KeyError: - name = 'question' - wx, _, bbox = self._metrics_by_name[name] - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - left = min(left, l) - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return left, miny, total_width, maxy - miny, -miny - - def get_str_bbox(self, s): - """Return the string bounding box.""" - return self.get_str_bbox_and_descent(s)[:4] - - def get_name_char(self, c, isord=False): - """Get the name of the character, i.e., ';' is 'semicolon'.""" - if not isord: - c = ord(c) - return self._metrics[c].name - - def get_width_char(self, c, isord=False): - """ - Get the width of the character from the character metric WX field. - """ - if not isord: - c = ord(c) - return self._metrics[c].width - - def get_width_from_char_name(self, name): - """Get the width of the character from a type1 character name.""" - return self._metrics_by_name[name].width - - def get_height_char(self, c, isord=False): - """Get the bounding box (ink) height of character *c* (space is 0).""" - if not isord: - c = ord(c) - return self._metrics[c].bbox[-1] - - def get_kern_dist(self, c1, c2): - """ - Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. - """ - name1, name2 = self.get_name_char(c1), self.get_name_char(c2) - return self.get_kern_dist_from_name(name1, name2) - - def get_kern_dist_from_name(self, name1, name2): - """ - Return the kerning pair distance (possibly 0) for chars - *name1* and *name2*. - """ - return self._kern.get((name1, name2), 0) - - def get_fontname(self): - """Return the font name, e.g., 'Times-Roman'.""" - return self._header[b'FontName'] - - @property - def postscript_name(self): # For consistency with FT2Font. - return self.get_fontname() - - def get_fullname(self): - """Return the font full name, e.g., 'Times-Roman'.""" - name = self._header.get(b'FullName') - if name is None: # use FontName as a substitute - name = self._header[b'FontName'] - return name - - def get_familyname(self): - """Return the font family name, e.g., 'Times'.""" - name = self._header.get(b'FamilyName') - if name is not None: - return name - - # FamilyName not specified so we'll make a guess - name = self.get_fullname() - extras = (r'(?i)([ -](regular|plain|italic|oblique|bold|semibold|' - r'light|ultralight|extra|condensed))+$') - return re.sub(extras, '', name) - - @property - def family_name(self): - """The font family name, e.g., 'Times'.""" - return self.get_familyname() - - def get_weight(self): - """Return the font weight, e.g., 'Bold' or 'Roman'.""" - return self._header[b'Weight'] - - def get_angle(self): - """Return the fontangle as float.""" - return self._header[b'ItalicAngle'] - - def get_capheight(self): - """Return the cap height as float.""" - return self._header[b'CapHeight'] - - def get_xheight(self): - """Return the xheight as float.""" - return self._header[b'XHeight'] - - def get_underline_thickness(self): - """Return the underline thickness as float.""" - return self._header[b'UnderlineThickness'] - - def get_horizontal_stem_width(self): - """ - Return the standard horizontal stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdHW', None) - - def get_vertical_stem_width(self): - """ - Return the standard vertical stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdVW', None) +from matplotlib._afm import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated( + "3.6", message="The module %(name)s is deprecated since %(since)s.", + name=f"{__name__}") diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 3224fb90e3a9..85e6c267923a 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -9,7 +9,7 @@ import matplotlib as mpl from .. import font_manager, ft2font -from ..afm import AFM +from .._afm import AFM from ..backend_bases import RendererBase diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index e0d04a56eb33..e4636e3adcae 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -26,7 +26,7 @@ from PIL import Image import matplotlib as mpl -from matplotlib import _api, _text_helpers, cbook +from matplotlib import _api, _text_helpers, _type1font, cbook, dviread from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, @@ -34,9 +34,7 @@ from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.figure import Figure from matplotlib.font_manager import findfont, get_font -from matplotlib.afm import AFM -import matplotlib.type1font as type1font -import matplotlib.dviread as dviread +from matplotlib._afm import AFM from matplotlib.ft2font import (FIXED_WIDTH, ITALIC, LOAD_NO_SCALE, LOAD_NO_HINTING, KERNING_UNFITTED, FT2Font) from matplotlib.mathtext import MathTextParser @@ -982,7 +980,7 @@ def _embedTeXFont(self, fontinfo): return fontdictObject # We have a font file to embed - read it in and apply any effects - t1font = type1font.Type1Font(fontinfo.fontfile) + t1font = _type1font.Type1Font(fontinfo.fontfile) if fontinfo.effects: t1font = t1font.transform(fontinfo.effects) fontdict['BaseFont'] = Name(t1font.prop['FontName']) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 33c41202dbb4..6631811eafae 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -22,7 +22,7 @@ import matplotlib as mpl from matplotlib import _api, cbook, _path, _text_helpers -from matplotlib.afm import AFM +from matplotlib._afm import AFM from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 9b58008a7ff8..b7f4aa28bc41 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -43,8 +43,8 @@ from dummy_threading import Timer import matplotlib as mpl -from matplotlib import _api, afm, cbook, ft2font, rcParams -from matplotlib.fontconfig_pattern import ( +from matplotlib import _api, _afm, cbook, ft2font, rcParams +from matplotlib._fontconfig_pattern import ( parse_fontconfig_pattern, generate_fontconfig_pattern) from matplotlib.rcsetup import _validators @@ -553,7 +553,7 @@ def afmFontProperty(fontpath, font): Parameters ---------- - font : `.AFM` + font : AFM The AFM font file from which information will be extracted. Returns @@ -1107,7 +1107,7 @@ def addfont(self, path): """ if Path(path).suffix.lower() == ".afm": with open(path, "rb") as fh: - font = afm.AFM(fh) + font = _afm.AFM(fh) prop = afmFontProperty(path, font) self.afmlist.append(prop) else: diff --git a/lib/matplotlib/fontconfig_pattern.py b/lib/matplotlib/fontconfig_pattern.py index c47e19bf99dc..353cdc8f5c43 100644 --- a/lib/matplotlib/fontconfig_pattern.py +++ b/lib/matplotlib/fontconfig_pattern.py @@ -1,209 +1,5 @@ -""" -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 lru_cache -import re -import numpy as np -from pyparsing import (Literal, ZeroOrMore, Optional, Regex, StringEnd, - ParseException, Suppress) - -family_punc = r'\\\-:,' -family_unescape = re.compile(r'\\([%s])' % family_punc).sub -family_escape = re.compile(r'([%s])' % family_punc).sub - -value_punc = r'\\=_:,' -value_unescape = re.compile(r'\\([%s])' % value_punc).sub -value_escape = re.compile(r'([%s])' % value_punc).sub - - -class FontconfigPatternParser: - """ - A simple pyparsing-based parser for `fontconfig patterns`_. - - .. _fontconfig patterns: - https://www.freedesktop.org/software/fontconfig/fontconfig-user.html - """ - - _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') - } - - def __init__(self): - - family = Regex( - r'([^%s]|(\\[%s]))*' % (family_punc, family_punc) - ).setParseAction(self._family) - - size = Regex( - r"([0-9]+\.?[0-9]*|\.[0-9]+)" - ).setParseAction(self._size) - - name = Regex( - r'[a-z]+' - ).setParseAction(self._name) - - value = Regex( - r'([^%s]|(\\[%s]))*' % (value_punc, value_punc) - ).setParseAction(self._value) - - families = ( - family - + ZeroOrMore( - Literal(',') - + family) - ).setParseAction(self._families) - - point_sizes = ( - size - + ZeroOrMore( - Literal(',') - + size) - ).setParseAction(self._point_sizes) - - property = ( - (name - + Suppress(Literal('=')) - + value - + ZeroOrMore( - Suppress(Literal(',')) - + value)) - | name - ).setParseAction(self._property) - - pattern = ( - Optional( - families) - + Optional( - Literal('-') - + point_sizes) - + ZeroOrMore( - Literal(':') - + property) - + StringEnd() - ) - - self._parser = pattern - self.ParseException = ParseException - - def parse(self, pattern): - """ - Parse the given fontconfig *pattern* and return a dictionary - of key/value pairs useful for initializing a - `.font_manager.FontProperties` object. - """ - props = self._properties = {} - try: - self._parser.parseString(pattern) - except self.ParseException as e: - raise ValueError( - "Could not parse font string: '%s'\n%s" % (pattern, e)) from e - - self._properties = None - - self._parser.resetCache() - - return props - - def _family(self, s, loc, tokens): - return [family_unescape(r'\1', str(tokens[0]))] - - def _size(self, s, loc, tokens): - return [float(tokens[0])] - - def _name(self, s, loc, tokens): - return [str(tokens[0])] - - def _value(self, s, loc, tokens): - return [value_unescape(r'\1', str(tokens[0]))] - - def _families(self, s, loc, tokens): - self._properties['family'] = [str(x) for x in tokens] - return [] - - def _point_sizes(self, s, loc, tokens): - self._properties['size'] = [str(x) for x in tokens] - return [] - - def _property(self, s, loc, tokens): - if len(tokens) == 1: - if tokens[0] in self._constants: - key, val = self._constants[tokens[0]] - self._properties.setdefault(key, []).append(val) - else: - key = tokens[0] - val = tokens[1:] - self._properties.setdefault(key, []).extend(val) - return [] - - -# `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. -parse_fontconfig_pattern = lru_cache()(FontconfigPatternParser().parse) - - -def _escape_val(val, escape_func): - """ - Given a string value or a list of string values, run each value through - the input escape function to make the values into legal font config - strings. The result is returned as a string. - """ - if not np.iterable(val) or isinstance(val, str): - val = [val] - - return ','.join(escape_func(r'\\\1', str(x)) for x in val - if x is not None) - - -def generate_fontconfig_pattern(d): - """ - Given a dictionary of key/value pairs, generates a fontconfig - pattern string. - """ - props = [] - - # Family is added first w/o a keyword - family = d.get_family() - if family is not None and family != []: - props.append(_escape_val(family, family_escape)) - - # The other keys are added as key=value - for key in ['style', 'variant', 'weight', 'stretch', 'file', 'size']: - val = getattr(d, 'get_' + key)() - # Don't use 'if not val' because 0 is a valid input. - if val is not None and val != []: - props.append(":%s=%s" % (key, _escape_val(val, value_escape))) - - return ''.join(props) +from matplotlib._fontconfig_pattern import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated( + "3.6", message="The module %(name)s is deprecated since %(since)s.", + name=f"{__name__}") diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 35531a8cc247..0eafec792b04 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -25,7 +25,7 @@ from matplotlib import _api, cbook from matplotlib.cbook import ls_mapper from matplotlib.colors import Colormap, is_color_like -from matplotlib.fontconfig_pattern import parse_fontconfig_pattern +from matplotlib._fontconfig_pattern import parse_fontconfig_pattern from matplotlib._enums import JoinStyle, CapStyle # Don't let the original cycler collide with our validating cycler diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index 2d54c16cb402..e5c6a83937cd 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -2,7 +2,7 @@ import pytest import logging -from matplotlib import afm +from matplotlib import _afm from matplotlib import font_manager as fm @@ -39,13 +39,13 @@ def test_nonascii_str(): inp_str = "привет" byte_str = inp_str.encode("utf8") - ret = afm._to_str(byte_str) + ret = _afm._to_str(byte_str) assert ret == inp_str def test_parse_header(): fh = BytesIO(AFM_TEST_DATA) - header = afm._parse_header(fh) + header = _afm._parse_header(fh) assert header == { b'StartFontMetrics': 2.0, b'FontName': 'MyFont-Bold', @@ -66,8 +66,8 @@ def test_parse_header(): def test_parse_char_metrics(): fh = BytesIO(AFM_TEST_DATA) - afm._parse_header(fh) # position - metrics = afm._parse_char_metrics(fh) + _afm._parse_header(fh) # position + metrics = _afm._parse_char_metrics(fh) assert metrics == ( {0: (250.0, 'space', [0, 0, 0, 0]), 42: (1141.0, 'foo', [40, 60, 800, 360]), @@ -81,13 +81,13 @@ def test_parse_char_metrics(): def test_get_familyname_guessed(): fh = BytesIO(AFM_TEST_DATA) - font = afm.AFM(fh) + font = _afm.AFM(fh) del font._header[b'FamilyName'] # remove FamilyName, so we have to guess assert font.get_familyname() == 'My Font' def test_font_manager_weight_normalization(): - font = afm.AFM(BytesIO( + font = _afm.AFM(BytesIO( AFM_TEST_DATA.replace(b"Weight Bold\n", b"Weight Custom\n"))) assert fm.afmFontProperty("", font).weight == "normal" @@ -107,7 +107,7 @@ def test_font_manager_weight_normalization(): def test_bad_afm(afm_data): fh = BytesIO(afm_data) with pytest.raises(RuntimeError): - afm._parse_header(fh) + _afm._parse_header(fh) @pytest.mark.parametrize( @@ -132,6 +132,6 @@ def test_bad_afm(afm_data): def test_malformed_header(afm_data, caplog): fh = BytesIO(afm_data) with caplog.at_level(logging.ERROR): - afm._parse_header(fh) + _afm._parse_header(fh) assert len(caplog.records) == 1 diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 926e1cee35d4..aced39bc1afe 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -199,7 +199,7 @@ def test_antialiasing(): def test_afm_kerning(): fn = mpl.font_manager.findfont("Helvetica", fontext="afm") with open(fn, 'rb') as fh: - afm = mpl.afm.AFM(fh) + afm = mpl._afm.AFM(fh) assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718) diff --git a/lib/matplotlib/tests/test_type1font.py b/lib/matplotlib/tests/test_type1font.py index 6a16da10def1..1e173d5ea84d 100644 --- a/lib/matplotlib/tests/test_type1font.py +++ b/lib/matplotlib/tests/test_type1font.py @@ -1,4 +1,4 @@ -import matplotlib.type1font as t1f +import matplotlib._type1font as t1f import os.path import difflib import pytest diff --git a/lib/matplotlib/type1font.py b/lib/matplotlib/type1font.py index 0413cb0016a0..5a9faa1c66d4 100644 --- a/lib/matplotlib/type1font.py +++ b/lib/matplotlib/type1font.py @@ -1,877 +1,5 @@ -""" -A class representing a Type 1 font. - -This version reads pfa and pfb files and splits them for embedding in -pdf files. It also supports SlantFont and ExtendFont transformations, -similarly to pdfTeX and friends. There is no support yet for subsetting. - -Usage:: - - font = Type1Font(filename) - clear_part, encrypted_part, finale = font.parts - slanted_font = font.transform({'slant': 0.167}) - extended_font = font.transform({'extend': 1.2}) - -Sources: - -* Adobe Technical Note #5040, Supporting Downloadable PostScript - Language Fonts. - -* Adobe Type 1 Font Format, Adobe Systems Incorporated, third printing, - v1.1, 1993. ISBN 0-201-57044-0. -""" - -import binascii -import functools -import logging -import re -import string -import struct - -import numpy as np - -from matplotlib.cbook import _format_approx -from . import _api - -_log = logging.getLogger(__name__) - - -class _Token: - """ - A token in a PostScript stream. - - Attributes - ---------- - pos : int - Position, i.e. offset from the beginning of the data. - raw : str - Raw text of the token. - kind : str - Description of the token (for debugging or testing). - """ - __slots__ = ('pos', 'raw') - kind = '?' - - def __init__(self, pos, raw): - _log.debug('type1font._Token %s at %d: %r', self.kind, pos, raw) - self.pos = pos - self.raw = raw - - def __str__(self): - return f"<{self.kind} {self.raw} @{self.pos}>" - - def endpos(self): - """Position one past the end of the token""" - return self.pos + len(self.raw) - - def is_keyword(self, *names): - """Is this a name token with one of the names?""" - return False - - def is_slash_name(self): - """Is this a name token that starts with a slash?""" - return False - - def is_delim(self): - """Is this a delimiter token?""" - return False - - def is_number(self): - """Is this a number token?""" - return False - - def value(self): - return self.raw - - -class _NameToken(_Token): - kind = 'name' - - def is_slash_name(self): - return self.raw.startswith('/') - - def value(self): - return self.raw[1:] - - -class _BooleanToken(_Token): - kind = 'boolean' - - def value(self): - return self.raw == 'true' - - -class _KeywordToken(_Token): - kind = 'keyword' - - def is_keyword(self, *names): - return self.raw in names - - -class _DelimiterToken(_Token): - kind = 'delimiter' - - def is_delim(self): - return True - - def opposite(self): - return {'[': ']', ']': '[', - '{': '}', '}': '{', - '<<': '>>', '>>': '<<' - }[self.raw] - - -class _WhitespaceToken(_Token): - kind = 'whitespace' - - -class _StringToken(_Token): - kind = 'string' - _escapes_re = re.compile(r'\\([\\()nrtbf]|[0-7]{1,3})') - _replacements = {'\\': '\\', '(': '(', ')': ')', 'n': '\n', - 'r': '\r', 't': '\t', 'b': '\b', 'f': '\f'} - _ws_re = re.compile('[\0\t\r\f\n ]') - - @classmethod - def _escape(cls, match): - group = match.group(1) - try: - return cls._replacements[group] - except KeyError: - return chr(int(group, 8)) - - @functools.lru_cache() - def value(self): - if self.raw[0] == '(': - return self._escapes_re.sub(self._escape, self.raw[1:-1]) - else: - data = self._ws_re.sub('', self.raw[1:-1]) - if len(data) % 2 == 1: - data += '0' - return binascii.unhexlify(data) - - -class _BinaryToken(_Token): - kind = 'binary' - - def value(self): - return self.raw[1:] - - -class _NumberToken(_Token): - kind = 'number' - - def is_number(self): - return True - - def value(self): - if '.' not in self.raw: - return int(self.raw) - else: - return float(self.raw) - - -def _tokenize(data: bytes, skip_ws: bool): - """ - A generator that produces _Token instances from Type-1 font code. - - The consumer of the generator may send an integer to the tokenizer to - indicate that the next token should be _BinaryToken of the given length. - - Parameters - ---------- - data : bytes - The data of the font to tokenize. - - skip_ws : bool - If true, the generator will drop any _WhitespaceTokens from the output. - """ - - text = data.decode('ascii', 'replace') - whitespace_or_comment_re = re.compile(r'[\0\t\r\f\n ]+|%[^\r\n]*') - token_re = re.compile(r'/{0,2}[^]\0\t\r\f\n ()<>{}/%[]+') - instring_re = re.compile(r'[()\\]') - hex_re = re.compile(r'^<[0-9a-fA-F\0\t\r\f\n ]*>$') - oct_re = re.compile(r'[0-7]{1,3}') - pos = 0 - next_binary = None - - while pos < len(text): - if next_binary is not None: - n = next_binary - next_binary = (yield _BinaryToken(pos, data[pos:pos+n])) - pos += n - continue - match = whitespace_or_comment_re.match(text, pos) - if match: - if not skip_ws: - next_binary = (yield _WhitespaceToken(pos, match.group())) - pos = match.end() - elif text[pos] == '(': - # PostScript string rules: - # - parentheses must be balanced - # - backslashes escape backslashes and parens - # - also codes \n\r\t\b\f and octal escapes are recognized - # - other backslashes do not escape anything - start = pos - pos += 1 - depth = 1 - while depth: - match = instring_re.search(text, pos) - if match is None: - raise ValueError( - f'Unterminated string starting at {start}') - pos = match.end() - if match.group() == '(': - depth += 1 - elif match.group() == ')': - depth -= 1 - else: # a backslash - char = text[pos] - if char in r'\()nrtbf': - pos += 1 - else: - octal = oct_re.match(text, pos) - if octal: - pos = octal.end() - else: - pass # non-escaping backslash - next_binary = (yield _StringToken(start, text[start:pos])) - elif text[pos:pos + 2] in ('<<', '>>'): - next_binary = (yield _DelimiterToken(pos, text[pos:pos + 2])) - pos += 2 - elif text[pos] == '<': - start = pos - try: - pos = text.index('>', pos) + 1 - except ValueError as e: - raise ValueError(f'Unterminated hex string starting at {start}' - ) from e - if not hex_re.match(text[start:pos]): - raise ValueError(f'Malformed hex string starting at {start}') - next_binary = (yield _StringToken(pos, text[start:pos])) - else: - match = token_re.match(text, pos) - if match: - raw = match.group() - if raw.startswith('/'): - next_binary = (yield _NameToken(pos, raw)) - elif match.group() in ('true', 'false'): - next_binary = (yield _BooleanToken(pos, raw)) - else: - try: - float(raw) - next_binary = (yield _NumberToken(pos, raw)) - except ValueError: - next_binary = (yield _KeywordToken(pos, raw)) - pos = match.end() - else: - next_binary = (yield _DelimiterToken(pos, text[pos])) - pos += 1 - - -class _BalancedExpression(_Token): - pass - - -def _expression(initial, tokens, data): - """ - Consume some number of tokens and return a balanced PostScript expression. - - Parameters - ---------- - initial : _Token - The token that triggered parsing a balanced expression. - tokens : iterator of _Token - Following tokens. - data : bytes - Underlying data that the token positions point to. - - Returns - ------- - _BalancedExpression - """ - delim_stack = [] - token = initial - while True: - if token.is_delim(): - if token.raw in ('[', '{'): - delim_stack.append(token) - elif token.raw in (']', '}'): - if not delim_stack: - raise RuntimeError(f"unmatched closing token {token}") - match = delim_stack.pop() - if match.raw != token.opposite(): - raise RuntimeError( - f"opening token {match} closed by {token}" - ) - if not delim_stack: - break - else: - raise RuntimeError(f'unknown delimiter {token}') - elif not delim_stack: - break - token = next(tokens) - return _BalancedExpression( - initial.pos, - data[initial.pos:token.endpos()].decode('ascii', 'replace') - ) - - -class Type1Font: - """ - A class representing a Type-1 font, for use by backends. - - Attributes - ---------- - parts : tuple - A 3-tuple of the cleartext part, the encrypted part, and the finale of - zeros. - - decrypted : bytes - The decrypted form of ``parts[1]``. - - prop : dict[str, Any] - A dictionary of font properties. Noteworthy keys include: - - - FontName: PostScript name of the font - - Encoding: dict from numeric codes to glyph names - - FontMatrix: bytes object encoding a matrix - - UniqueID: optional font identifier, dropped when modifying the font - - CharStrings: dict from glyph names to byte code - - Subrs: array of byte code subroutines - - OtherSubrs: bytes object encoding some PostScript code - """ - __slots__ = ('parts', 'decrypted', 'prop', '_pos', '_abbr') - # the _pos dict contains (begin, end) indices to parts[0] + decrypted - # so that they can be replaced when transforming the font; - # but since sometimes a definition appears in both parts[0] and decrypted, - # _pos[name] is an array of such pairs - # - # _abbr maps three standard abbreviations to their particular names in - # this font (e.g. 'RD' is named '-|' in some fonts) - - def __init__(self, input): - """ - Initialize a Type-1 font. - - Parameters - ---------- - input : str or 3-tuple - Either a pfb file name, or a 3-tuple of already-decoded Type-1 - font `~.Type1Font.parts`. - """ - if isinstance(input, tuple) and len(input) == 3: - self.parts = input - else: - with open(input, 'rb') as file: - data = self._read(file) - self.parts = self._split(data) - - self.decrypted = self._decrypt(self.parts[1], 'eexec') - self._abbr = {'RD': 'RD', 'ND': 'ND', 'NP': 'NP'} - self._parse() - - def _read(self, file): - """Read the font from a file, decoding into usable parts.""" - rawdata = file.read() - if not rawdata.startswith(b'\x80'): - return rawdata - - data = b'' - while rawdata: - if not rawdata.startswith(b'\x80'): - raise RuntimeError('Broken pfb file (expected byte 128, ' - 'got %d)' % rawdata[0]) - type = rawdata[1] - if type in (1, 2): - length, = struct.unpack('> 8)) - key = ((key+byte) * 52845 + 22719) & 0xffff - - return bytes(plaintext[ndiscard:]) - - @staticmethod - def _encrypt(plaintext, key, ndiscard=4): - """ - Encrypt plaintext using the Type-1 font algorithm. - - The algorithm is described in Adobe's "Adobe Type 1 Font Format". - The key argument can be an integer, or one of the strings - 'eexec' and 'charstring', which map to the key specified for the - corresponding part of Type-1 fonts. - - The ndiscard argument should be an integer, usually 4. That - number of bytes is prepended to the plaintext before encryption. - This function prepends NUL bytes for reproducibility, even though - the original algorithm uses random bytes, presumably to avoid - cryptanalysis. - """ - - key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key) - ciphertext = [] - for byte in b'\0' * ndiscard + plaintext: - c = byte ^ (key >> 8) - ciphertext.append(c) - key = ((key + c) * 52845 + 22719) & 0xffff - - return bytes(ciphertext) - - def _parse(self): - """ - Find the values of various font properties. This limited kind - of parsing is described in Chapter 10 "Adobe Type Manager - Compatibility" of the Type-1 spec. - """ - # Start with reasonable defaults - prop = {'Weight': 'Regular', 'ItalicAngle': 0.0, 'isFixedPitch': False, - 'UnderlinePosition': -100, 'UnderlineThickness': 50} - pos = {} - data = self.parts[0] + self.decrypted - - source = _tokenize(data, True) - while True: - # See if there is a key to be assigned a value - # e.g. /FontName in /FontName /Helvetica def - try: - token = next(source) - except StopIteration: - break - if token.is_delim(): - # skip over this - we want top-level keys only - _expression(token, source, data) - if token.is_slash_name(): - key = token.value() - keypos = token.pos - else: - continue - - # Some values need special parsing - if key in ('Subrs', 'CharStrings', 'Encoding', 'OtherSubrs'): - prop[key], endpos = { - 'Subrs': self._parse_subrs, - 'CharStrings': self._parse_charstrings, - 'Encoding': self._parse_encoding, - 'OtherSubrs': self._parse_othersubrs - }[key](source, data) - pos.setdefault(key, []).append((keypos, endpos)) - continue - - try: - token = next(source) - except StopIteration: - break - - if isinstance(token, _KeywordToken): - # constructs like - # FontDirectory /Helvetica known {...} {...} ifelse - # mean the key was not really a key - continue - - if token.is_delim(): - value = _expression(token, source, data).raw - else: - value = token.value() - - # look for a 'def' possibly preceded by access modifiers - try: - kw = next( - kw for kw in source - if not kw.is_keyword('readonly', 'noaccess', 'executeonly') - ) - except StopIteration: - break - - # sometimes noaccess def and readonly def are abbreviated - if kw.is_keyword('def', self._abbr['ND'], self._abbr['NP']): - prop[key] = value - pos.setdefault(key, []).append((keypos, kw.endpos())) - - # detect the standard abbreviations - if value == '{noaccess def}': - self._abbr['ND'] = key - elif value == '{noaccess put}': - self._abbr['NP'] = key - elif value == '{string currentfile exch readstring pop}': - self._abbr['RD'] = key - - # Fill in the various *Name properties - if 'FontName' not in prop: - prop['FontName'] = (prop.get('FullName') or - prop.get('FamilyName') or - 'Unknown') - if 'FullName' not in prop: - prop['FullName'] = prop['FontName'] - if 'FamilyName' not in prop: - extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' - '(ultra)?light|extra|condensed))+$') - prop['FamilyName'] = re.sub(extras, '', prop['FullName']) - # Decrypt the encrypted parts - ndiscard = prop.get('lenIV', 4) - cs = prop['CharStrings'] - for key, value in cs.items(): - cs[key] = self._decrypt(value, 'charstring', ndiscard) - if 'Subrs' in prop: - prop['Subrs'] = [ - self._decrypt(value, 'charstring', ndiscard) - for value in prop['Subrs'] - ] - - self.prop = prop - self._pos = pos - - def _parse_subrs(self, tokens, _data): - count_token = next(tokens) - if not count_token.is_number(): - raise RuntimeError( - f"Token following /Subrs must be a number, was {count_token}" - ) - count = count_token.value() - array = [None] * count - next(t for t in tokens if t.is_keyword('array')) - for _ in range(count): - next(t for t in tokens if t.is_keyword('dup')) - index_token = next(tokens) - if not index_token.is_number(): - raise RuntimeError( - "Token following dup in Subrs definition must be a " - f"number, was {index_token}" - ) - nbytes_token = next(tokens) - if not nbytes_token.is_number(): - raise RuntimeError( - "Second token following dup in Subrs definition must " - f"be a number, was {nbytes_token}" - ) - token = next(tokens) - if not token.is_keyword(self._abbr['RD']): - raise RuntimeError( - f"Token preceding subr must be {self._abbr['RD']}, " - f"was {token}" - ) - binary_token = tokens.send(1+nbytes_token.value()) - array[index_token.value()] = binary_token.value() - - return array, next(tokens).endpos() - - @staticmethod - def _parse_charstrings(tokens, _data): - count_token = next(tokens) - if not count_token.is_number(): - raise RuntimeError( - "Token following /CharStrings must be a number, " - f"was {count_token}" - ) - count = count_token.value() - charstrings = {} - next(t for t in tokens if t.is_keyword('begin')) - while True: - token = next(t for t in tokens - if t.is_keyword('end') or t.is_slash_name()) - if token.raw == 'end': - return charstrings, token.endpos() - glyphname = token.value() - nbytes_token = next(tokens) - if not nbytes_token.is_number(): - raise RuntimeError( - f"Token following /{glyphname} in CharStrings definition " - f"must be a number, was {nbytes_token}" - ) - next(tokens) # usually RD or |- - binary_token = tokens.send(1+nbytes_token.value()) - charstrings[glyphname] = binary_token.value() - - @staticmethod - def _parse_encoding(tokens, _data): - # this only works for encodings that follow the Adobe manual - # but some old fonts include non-compliant data - we log a warning - # and return a possibly incomplete encoding - encoding = {} - while True: - token = next(t for t in tokens - if t.is_keyword('StandardEncoding', 'dup', 'def')) - if token.is_keyword('StandardEncoding'): - return _StandardEncoding, token.endpos() - if token.is_keyword('def'): - return encoding, token.endpos() - index_token = next(tokens) - if not index_token.is_number(): - _log.warning( - f"Parsing encoding: expected number, got {index_token}" - ) - continue - name_token = next(tokens) - if not name_token.is_slash_name(): - _log.warning( - f"Parsing encoding: expected slash-name, got {name_token}" - ) - continue - encoding[index_token.value()] = name_token.value() - - @staticmethod - def _parse_othersubrs(tokens, data): - init_pos = None - while True: - token = next(tokens) - if init_pos is None: - init_pos = token.pos - if token.is_delim(): - _expression(token, tokens, data) - elif token.is_keyword('def', 'ND', '|-'): - return data[init_pos:token.endpos()], token.endpos() - - def transform(self, effects): - """ - Return a new font that is slanted and/or extended. - - Parameters - ---------- - effects : dict - A dict with optional entries: - - - 'slant' : float, default: 0 - Tangent of the angle that the font is to be slanted to the - right. Negative values slant to the left. - - 'extend' : float, default: 1 - Scaling factor for the font width. Values less than 1 condense - the glyphs. - - Returns - ------- - `Type1Font` - """ - fontname = self.prop['FontName'] - italicangle = self.prop['ItalicAngle'] - - array = [ - float(x) for x in (self.prop['FontMatrix'] - .lstrip('[').rstrip(']').split()) - ] - oldmatrix = np.eye(3, 3) - oldmatrix[0:3, 0] = array[::2] - oldmatrix[0:3, 1] = array[1::2] - modifier = np.eye(3, 3) - - if 'slant' in effects: - slant = effects['slant'] - fontname += '_Slant_%d' % int(1000 * slant) - italicangle = round( - float(italicangle) - np.arctan(slant) / np.pi * 180, - 5 - ) - modifier[1, 0] = slant - - if 'extend' in effects: - extend = effects['extend'] - fontname += '_Extend_%d' % int(1000 * extend) - modifier[0, 0] = extend - - newmatrix = np.dot(modifier, oldmatrix) - array[::2] = newmatrix[0:3, 0] - array[1::2] = newmatrix[0:3, 1] - fontmatrix = ( - '[%s]' % ' '.join(_format_approx(x, 6) for x in array) - ) - replacements = ( - [(x, '/FontName/%s def' % fontname) - for x in self._pos['FontName']] - + [(x, '/ItalicAngle %a def' % italicangle) - for x in self._pos['ItalicAngle']] - + [(x, '/FontMatrix %s readonly def' % fontmatrix) - for x in self._pos['FontMatrix']] - + [(x, '') for x in self._pos.get('UniqueID', [])] - ) - - data = bytearray(self.parts[0]) - data.extend(self.decrypted) - len0 = len(self.parts[0]) - for (pos0, pos1), value in sorted(replacements, reverse=True): - data[pos0:pos1] = value.encode('ascii', 'replace') - if pos0 < len(self.parts[0]): - if pos1 >= len(self.parts[0]): - raise RuntimeError( - f"text to be replaced with {value} spans " - "the eexec boundary" - ) - len0 += len(value) - pos1 + pos0 - - data = bytes(data) - return Type1Font(( - data[:len0], - self._encrypt(data[len0:], 'eexec'), - self.parts[2] - )) - - -_StandardEncoding = { - **{ord(letter): letter for letter in string.ascii_letters}, - 0: '.notdef', - 32: 'space', - 33: 'exclam', - 34: 'quotedbl', - 35: 'numbersign', - 36: 'dollar', - 37: 'percent', - 38: 'ampersand', - 39: 'quoteright', - 40: 'parenleft', - 41: 'parenright', - 42: 'asterisk', - 43: 'plus', - 44: 'comma', - 45: 'hyphen', - 46: 'period', - 47: 'slash', - 48: 'zero', - 49: 'one', - 50: 'two', - 51: 'three', - 52: 'four', - 53: 'five', - 54: 'six', - 55: 'seven', - 56: 'eight', - 57: 'nine', - 58: 'colon', - 59: 'semicolon', - 60: 'less', - 61: 'equal', - 62: 'greater', - 63: 'question', - 64: 'at', - 91: 'bracketleft', - 92: 'backslash', - 93: 'bracketright', - 94: 'asciicircum', - 95: 'underscore', - 96: 'quoteleft', - 123: 'braceleft', - 124: 'bar', - 125: 'braceright', - 126: 'asciitilde', - 161: 'exclamdown', - 162: 'cent', - 163: 'sterling', - 164: 'fraction', - 165: 'yen', - 166: 'florin', - 167: 'section', - 168: 'currency', - 169: 'quotesingle', - 170: 'quotedblleft', - 171: 'guillemotleft', - 172: 'guilsinglleft', - 173: 'guilsinglright', - 174: 'fi', - 175: 'fl', - 177: 'endash', - 178: 'dagger', - 179: 'daggerdbl', - 180: 'periodcentered', - 182: 'paragraph', - 183: 'bullet', - 184: 'quotesinglbase', - 185: 'quotedblbase', - 186: 'quotedblright', - 187: 'guillemotright', - 188: 'ellipsis', - 189: 'perthousand', - 191: 'questiondown', - 193: 'grave', - 194: 'acute', - 195: 'circumflex', - 196: 'tilde', - 197: 'macron', - 198: 'breve', - 199: 'dotaccent', - 200: 'dieresis', - 202: 'ring', - 203: 'cedilla', - 205: 'hungarumlaut', - 206: 'ogonek', - 207: 'caron', - 208: 'emdash', - 225: 'AE', - 227: 'ordfeminine', - 232: 'Lslash', - 233: 'Oslash', - 234: 'OE', - 235: 'ordmasculine', - 241: 'ae', - 245: 'dotlessi', - 248: 'lslash', - 249: 'oslash', - 250: 'oe', - 251: 'germandbls', -} +from matplotlib._type1font import * # noqa: F401, F403 +from matplotlib import _api +_api.warn_deprecated( + "3.6", message="The module %(name)s is deprecated since %(since)s.", + name=f"{__name__}")