From 94522a393f21a847c3f2fe07ae3bfd6ea574e8a8 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Thu, 11 Mar 2021 18:25:05 +0100 Subject: [PATCH] Break up kiva.fonttools into manageable chunks (#707) --- kiva/fonttools/afm.py | 432 -------- kiva/fonttools/font.py | 53 +- kiva/fonttools/font_manager.py | 1173 +++------------------ kiva/fonttools/tests/test_font_manager.py | 71 +- kiva/fonttools/tests/test_scan_parse.py | 7 +- 5 files changed, 160 insertions(+), 1576 deletions(-) delete mode 100644 kiva/fonttools/afm.py diff --git a/kiva/fonttools/afm.py b/kiva/fonttools/afm.py deleted file mode 100644 index b6e7a51fa..000000000 --- a/kiva/fonttools/afm.py +++ /dev/null @@ -1,432 +0,0 @@ -""" -This is a python interface to Adobe Font Metrics Files. Although a -number of other python implementations exist (and may be more complete -than mine) I decided not to go with them because either they were -either - - 1) copyighted or used a non-BSD compatible license - - 2) had too many dependencies and I wanted a free standing lib - - 3) Did more than I needed and it was easier to write my own than - figure out how to just get what I needed from theirs - -It is pretty easy to use, and requires only built-in python libs - - >>> from afm import AFM - >>> fh = file('ptmr8a.afm') - >>> afm = AFM(fh) - >>> afm.string_width_height('What the heck?') - (6220.0, 683) - >>> 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] - >>> afm.get_bbox_font() - [-168, -218, 1000, 898] - - -AUTHOR: - John D. Hunter -""" - -import logging -import os - -logger = logging.getLogger(__name__) - -# Convert value to a python type -_to_int = int -_to_float = float -_to_str = str - - -def _to_list_of_ints(s): - s = s.replace(",", " ") - 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): - return s.lower().strip() in ("false", "0", "no") - - -def _parse_header(fh): - """ - Reads 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; eg '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 - - """ - headerConverters = { - "StartFontMetrics": _to_float, - "FontName": _to_str, - "FullName": _to_str, - "FamilyName": _to_str, - "Weight": _to_str, - "ItalicAngle": _to_float, - "IsFixedPitch": _to_bool, - "FontBBox": _to_list_of_ints, - "UnderlinePosition": _to_int, - "UnderlineThickness": _to_int, - "Version": _to_str, - "Notice": _to_str, - "EncodingScheme": _to_str, - "CapHeight": _to_float, - "XHeight": _to_float, - "Ascender": _to_float, - "Descender": _to_float, - "StartCharMetrics": _to_int, - "Characters": _to_int, - "Capheight": _to_int, - } - - d = {} - while True: - line = fh.readline() - if not line: - break - line = line.rstrip() - if line.startswith("Comment"): - continue - lst = line.split(" ", 1) - key = lst[0] - if len(lst) == 2: - val = lst[1] - else: - val = "" - try: - d[key] = headerConverters[key](val) - except ValueError: - msg = "Value error parsing header in AFM: {} {}".format(key, val) - logger.error(msg) - continue - except KeyError: - logging.error("Key error converting in AFM") - continue - if key == "StartCharMetrics": - return d - raise RuntimeError("Bad parse") - - -def _parse_char_metrics(fh): - """ - Return a character metric dictionary. Keys are the ASCII num of - the character, values are a (wx, name, bbox) tuple, where - - wx is the character width - name is the postscript language name - bbox (llx, lly, urx, ury) - - This function is incomplete per the standard, but thus far parse - all the sample afm files I have - """ - - d = {} - while 1: - line = fh.readline() - if not line: - break - line = line.rstrip() - if line.startswith("EndCharMetrics"): - return d - vals = line.split(";")[:4] - if len(vals) != 4: - raise RuntimeError("Bad char metrics line: %s" % line) - num = _to_int(vals[0].split()[1]) - if num == -1: - continue - wx = _to_float(vals[1].split()[1]) - name = vals[2].split()[1] - bbox = _to_list_of_ints(vals[3][2:]) - d[num] = (wx, name, bbox) - 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 = fh.readline() - if not line.startswith("StartKernPairs"): - raise RuntimeError("Bad start of kern pairs data: %s" % line) - - d = {} - while 1: - line = fh.readline() - if not line: - break - line = line.rstrip() - if len(line) == 0: - continue - if line.startswith("EndKernPairs"): - fh.readline() # EndKernData - return d - vals = line.split() - if len(vals) != 4 or vals[0] != "KPX": - raise RuntimeError("Bad kern pairs line: %s" % line) - c1, c2, val = vals[1], vals[2], _to_float(vals[3]) - d[(c1, c2)] = val - raise RuntimeError("Bad kern pairs parse") - - -def _parse_composites(fh): - """ - Return a composites dictionary. Keys are the names of the - composites. vals are a num parts list of composite information, - with each element being a (name, dx, dy) tuple. Thus if a - composites line reading: - - CC Aacute 2 ; PCC A 0 0 ; PCC acute 160 170 ; - - will be represented as - - d['Aacute'] = [ ('A', 0, 0), ('acute', 160, 170) ] - - """ - d = {} - while 1: - line = fh.readline() - if not line: - break - line = line.rstrip() - if len(line) == 0: - continue - if line.startswith("EndComposites"): - return d - vals = line.split(";") - cc = vals[0].split() - name = cc[1] - pccParts = [] - for s in vals[1:-1]: - pcc = s.split() - name, dx, dy = pcc[1], _to_float(pcc[2]), _to_float(pcc[3]) - pccParts.append((name, dx, dy)) - d[name] = pccParts - - raise RuntimeError("Bad composites parse") - - -def _parse_optional(fh): - """ - Parse the optional fields for kern pair data and composites - - return value is a kernDict, compositeDict which are the return - values from parse_kern_pairs, and parse_composites if the data - exists, or empty dicts otherwise - """ - optional = { - "StartKernData": _parse_kern_pairs, - "StartComposites": _parse_composites, - } - - d = {"StartKernData": {}, "StartComposites": {}} - while 1: - line = fh.readline() - if not line: - break - line = line.rstrip() - if len(line) == 0: - continue - key = line.split()[0] - - if key in optional: - d[key] = optional[key](fh) - - return (d["StartKernData"], d["StartComposites"]) - - -def parse_afm(fh): - """ - Parse the Adobe Font Metics file in file handle fh - Return value is a (dhead, dcmetrics, dkernpairs, dcomposite) tuple where - - dhead : a parse_header dict - dcmetrics : a parse_composites dict - dkernpairs : a parse_kern_pairs dict, possibly {} - dcomposite : a parse_composites dict , possibly {} - """ - dhead = _parse_header(fh) - dcmetrics = _parse_char_metrics(fh) - doptional = _parse_optional(fh) - return dhead, dcmetrics, doptional[0], doptional[1] - - -class AFM(object): - def __init__(self, fh): - """ Parse the AFM file in file object fh """ - (dhead, dcmetrics, dkernpairs, dcomposite) = parse_afm(fh) - self._header = dhead - self._kern = dkernpairs - self._metrics = dcmetrics - self._composite = dcomposite - - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - wx, name, bbox = self._metrics[c] - return 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) - totalw = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == "\n": - continue - wx, name, bbox = self._metrics[ord(c)] - l, b, w, h = bbox - - # find the width with kerning - try: - kp = self._kern[(namelast, name)] - except KeyError: - kp = 0 - totalw += wx + kp - - # find the max y - thismax = b + h - if thismax > maxy: - maxy = thismax - - # find the min y - thismin = b - if thismin < miny: - miny = thismin - - return totalw, maxy - miny - - def get_str_bbox(self, s): - """ - Return the string bounding box - """ - if not len(s): - return (0, 0, 0, 0) - totalw = 0 - namelast = None - miny = 1e9 - maxy = 0 - left = 0 - for c in s: - if c == "\n": - continue - wx, name, bbox = self._metrics[ord(c)] - l, b, w, h = bbox - if l < left: - left = l - # find the width with kerning - try: - kp = self._kern[(namelast, name)] - except KeyError: - kp = 0 - totalw += wx + kp - - # find the max y - thismax = b + h - if thismax > maxy: - maxy = thismax - - # find the min y - thismin = b - if thismin < miny: - miny = thismin - - return left, miny, totalw, maxy - miny - - def get_name_char(self, c): - """ - Get the name of the character, ie, ';' is 'semicolon' - """ - wx, name, bbox = self._metrics[ord(c)] - return 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) - wx, name, bbox = self._metrics[c] - return wx - - def get_height_char(self, c, isord=False): - """ - Get the height of character c from the bounding box. This is - the ink height (space is 0) - """ - if not isord: - c = ord(c) - wx, name, bbox = self._metrics[c] - return 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) - try: - return self._kern[(name1, name2)] - except Exception: - return 0 - - def get_fontname(self): - "Return the font name, eg, Times-Roman" - return self._header["FontName"] - - def get_fullname(self): - "Return the font full name, eg, Times-Roman" - return self._header["FullName"] - - def get_familyname(self): - "Return the font family name, eg, Times" - return self._header["FamilyName"] - - def get_weight(self): - "Return the font weight, eg, 'Bold' or 'Roman'" - return self._header["Weight"] - - def get_angle(self): - "Return the fontangle as float" - return self._header["ItalicAngle"] - - -if __name__ == "__main__": - pathname = "/usr/local/share/fonts/afms/adobe" - - for fname in os.listdir(pathname): - fh = open(os.path.join(pathname, fname)) - afm = AFM(fh) - w, h = afm.string_width_height("John Hunter is the Man!") diff --git a/kiva/fonttools/font.py b/kiva/fonttools/font.py index 84ed98278..c7b6a2382 100644 --- a/kiva/fonttools/font.py +++ b/kiva/fonttools/font.py @@ -7,8 +7,7 @@ # is also available online at http://www.enthought.com/licenses/BSD.txt # # Thanks for using Enthought open source! -""" -Defines the Kiva Font class and a utility method to parse free-form font +""" Defines the Kiva Font class and a utility method to parse free-form font specification strings into Font instances. """ import copy @@ -17,7 +16,8 @@ BOLD_ITALIC, BOLD, DECORATIVE, DEFAULT, ITALIC, MODERN, NORMAL, ROMAN, SCRIPT, SWISS, TELETYPE, ) -from .font_manager import default_font_manager, FontProperties +from kiva.fonttools._font_properties import FontProperties +from kiva.fonttools.font_manager import default_font_manager # Various maps used by str_to_font font_families = { @@ -30,7 +30,7 @@ } font_styles = {"italic": ITALIC} font_weights = {"bold": BOLD} -font_noise = ["pt", "point", "family"] +font_noise = {"pt", "point", "family"} def str_to_font(fontspec): @@ -95,20 +95,21 @@ class Font(object): def __init__(self, face_name="", size=12, family=SWISS, weight=NORMAL, style=NORMAL, underline=0, encoding=DEFAULT): - if ((type(size) != int) - or (type(family) != type(SWISS)) - or (type(weight) != type(NORMAL)) - or (type(style) != type(NORMAL)) - or (type(underline) != int) - or (not isinstance(face_name, str)) - or (type(encoding) != type(DEFAULT))): + if (not isinstance(face_name, str) + or not isinstance(size, int) + or not isinstance(family, int) + or not isinstance(weight, int) + or not isinstance(style, int) + or not isinstance(underline, int) + or not isinstance(encoding, int)): raise RuntimeError("Bad value in Font() constructor.") + + self.face_name = face_name self.size = size self.family = family self.weight = weight self.style = style self.underline = underline - self.face_name = face_name self.encoding = encoding def findfont(self): @@ -157,37 +158,29 @@ def _set_name(self, val): name = property(_get_name, _set_name) def copy(self): - """ Returns a copy of the font object.""" + """ Returns a copy of the font object. + """ return copy.deepcopy(self) def __eq__(self, other): - result = False try: - if (self.family == other.family + return (self.family == other.family + and self.face_name == other.face_name and self.size == other.size and self.weight == other.weight and self.style == other.style and self.underline == other.underline - and self.face_name == other.face_name - and self.encoding == other.encoding): - result = True + and self.encoding == other.encoding) except AttributeError: pass - return result + return False def __ne__(self, other): return not self.__eq__(other) def __repr__(self): - fmt = ( - "Font(size=%d,family=%d,weight=%d, style=%d, face_name='%s', " - "encoding=%d)" - ) - return fmt % ( - self.size, - self.family, - self.weight, - self.style, - self.face_name, - self.encoding, + return ( + f"Font(face_name='{self.face_name}', size={self.size}, " + f"family={self.family}, weight={self.weight}, style={self.style}, " + f"underline={self.underline}, encoding={self.encoding})" ) diff --git a/kiva/fonttools/font_manager.py b/kiva/fonttools/font_manager.py index a1edf4b93..d39465e64 100644 --- a/kiva/fonttools/font_manager.py +++ b/kiva/fonttools/font_manager.py @@ -9,35 +9,17 @@ # Thanks for using Enthought open source! """ ####### NOTE ####### -This is based heavily on matplotlib's font_manager.py rev 8713, -but has been modified to not use other matplotlib modules +This is based heavily on matplotlib's font_manager.py SVN rev 8713 +(git commit f8e4c6ce2408044bc89b78b3c72e54deb1999fb5), +but has been modified quite a bit in the decade since it was copied. #################### A module for finding, managing, and using fonts across platforms. -This module provides a single :class:`FontManager` instance that can -be shared across backends and platforms. The :func:`findfont` -function returns the best TrueType (TTF) font file in the local or -system font path that matches the specified :class:`FontProperties` -instance. The :class:`FontManager` also handles Adobe Font Metrics -(AFM) font files for use by the PostScript backend. - The design is based on the `W3C Cascading Style Sheet, Level 1 (CSS1) font specification `_. Future versions may implement the Level 2 or 2.1 specifications. -KNOWN ISSUES - - - documentation - - font variant is untested - - font stretch is incomplete - - font size is incomplete - - font size_adjust is incomplete - - default font algorithm needs improvement and testing - - setWeights function needs improvement - - 'light' is an invalid weight value, remove it. - - update_fonts not implemented - Authors : John Hunter Paul Barrett Michael Droettboom @@ -47,1000 +29,58 @@ see license/LICENSE_TTFQUERY. """ import errno -import glob import logging import os import pickle -import subprocess -import sys import tempfile import warnings -from fontTools.ttLib import TTCollection, TTFont, TTLibError - from traits.etsconfig.api import ETSConfig -from . import afm -from ._score import ( +from kiva.fonttools._scan_parse import create_font_list +from kiva.fonttools._scan_sys import scan_system_fonts +from kiva.fonttools._score import ( score_family, score_size, score_stretch, score_style, score_variant, score_weight ) logger = logging.getLogger(__name__) -font_scalings = { - "xx-small": 0.579, - "x-small": 0.694, - "small": 0.833, - "medium": 1.0, - "large": 1.200, - "x-large": 1.440, - "xx-large": 1.728, - "larger": 1.2, - "smaller": 0.833, - None: 1.0, -} - -stretch_dict = { - "ultra-condensed": 100, - "extra-condensed": 200, - "condensed": 300, - "semi-condensed": 400, - "normal": 500, - "semi-expanded": 600, - "expanded": 700, - "extra-expanded": 800, - "ultra-expanded": 900, -} - -weight_dict = { - "ultralight": 100, - "light": 200, - "normal": 400, - "regular": 400, - "book": 400, - "medium": 500, - "roman": 500, - "semibold": 600, - "demibold": 600, - "demi": 600, - "bold": 700, - "heavy": 800, - "extra bold": 800, - "black": 900, -} - -# OS Font paths -MSFolders = r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" - -MSFontDirectories = [ - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", - r"SOFTWARE\Microsoft\Windows\CurrentVersion\Fonts", -] - -X11FontDirectories = [ - # an old standard installation point - "/usr/X11R6/lib/X11/fonts/TTF/", - # here is the new standard location for fonts - "/usr/share/fonts/", - # documented as a good place to install new fonts - "/usr/local/share/fonts/", - # common application, not really useful - "/usr/lib/openoffice/share/fonts/truetype/", -] - -OSXFontDirectories = [ - "/Library/Fonts/", - "/Network/Library/Fonts/", - "/System/Library/Fonts/", -] - -home = os.environ.get("HOME") -if home is not None: - # user fonts on OSX - path = os.path.join(home, "Library", "Fonts") - OSXFontDirectories.append(path) - path = os.path.join(home, ".fonts") - X11FontDirectories.append(path) - -############################################################################### -# functions to replace those that matplotlib ship in different modules -############################################################################### - - -def _is_writable_dir(p): - """ - p is a string pointing to a putative writable dir -- return True p - is such a string, else False - """ - if not isinstance(p, str): - return False - - try: - t = tempfile.TemporaryFile(dir=p) - t.write(b"kiva.test") - t.close() - except OSError: - return False - else: - return True - - -def get_configdir(): - """ - Return the string representing the configuration dir. If s is the - special string _default_, use HOME/.kiva. s must be writable - """ - - p = os.path.join(ETSConfig.application_data, "kiva") - try: - os.makedirs(p) - except OSError as e: - if e.errno != errno.EEXIST: - raise - if not _is_writable_dir(p): - raise IOError("Configuration directory %s must be writable" % p) - return p - +# Global singleton of FontManager, cached at the module level. +fontManager = None -def decode_prop(prop): - """ Decode a prop string. - Parameters - ---------- - prop : bytestring +def default_font_manager(): + """ Return the default font manager, which is a singleton FontManager + cached in the module. Returns ------- - string - """ - # Adapted from: https://gist.github.com/pklaus/dce37521579513c574d0 - encoding = "utf-16-be" if b"\x00" in prop else "utf-8" - - return prop.decode(encoding) - - -def getPropDict(font): - n = font["name"] - propdict = {} - for prop in n.names: - try: - if "name" in propdict and "sfnt4" in propdict: - break - elif prop.nameID == 1 and "name" not in propdict: - propdict["name"] = decode_prop(prop.string) - elif prop.nameID == 4 and "sfnt4" not in propdict: - propdict["sfnt4"] = decode_prop(prop.string) - except UnicodeDecodeError: - continue - - return propdict - - -############################################################################### -# matplotlib code below -############################################################################### - -synonyms = { - "ttf": ("ttf", "otf", "ttc"), - "otf": ("ttf", "otf", "ttc"), - "ttc": ("ttf", "otf", "ttc"), - "afm": ("afm",), -} - - -def get_fontext_synonyms(fontext): - """ - Return a list of file extensions extensions that are synonyms for - the given file extension *fileext*. - """ - return synonyms[fontext] - - -def win32FontDirectory(): - r""" - Return the user-specified font directory for Win32. This is - looked up from the registry key:: - - \\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders\Fonts # noqa - - If the key is not found, $WINDIR/Fonts will be returned. - """ - try: - import winreg - except ImportError: - pass # Fall through to default - else: - try: - user = winreg.OpenKey(winreg.HKEY_CURRENT_USER, MSFolders) - try: - try: - return winreg.QueryValueEx(user, "Fonts")[0] - except OSError: - pass # Fall through to default - finally: - winreg.CloseKey(user) - except OSError: - pass # Fall through to default - return os.path.join(os.environ["WINDIR"], "Fonts") - - -def win32InstalledFonts(directory=None, fontext="ttf"): - """ - Search for fonts in the specified font directory, or use the - system directories if none given. A list of TrueType font - filenames are returned by default, or AFM fonts if *fontext* == - 'afm'. - """ - - import winreg - - if directory is None: - directory = win32FontDirectory() - - fontext = get_fontext_synonyms(fontext) - - key, items = None, {} - for fontdir in MSFontDirectories: - try: - local = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, fontdir) - except OSError: - continue - - if not local: - files = [] - for ext in fontext: - files.extend(glob.glob(os.path.join(directory, "*." + ext))) - return files - try: - for j in range(winreg.QueryInfoKey(local)[1]): - try: - key, direc, any = winreg.EnumValue(local, j) - if not os.path.dirname(direc): - direc = os.path.join(directory, direc) - direc = os.path.abspath(direc).lower() - if os.path.splitext(direc)[1][1:] in fontext: - items[direc] = 1 - except EnvironmentError: - continue - except WindowsError: - continue - - return list(items.keys()) - finally: - winreg.CloseKey(local) - return None - - -def OSXFontDirectory(): - """ - Return the system font directories for OS X. This is done by - starting at the list of hardcoded paths in - :attr:`OSXFontDirectories` and returning all nested directories - within them. - """ - - fontpaths = [] - - for fontdir in OSXFontDirectories: - try: - if os.path.isdir(fontdir): - fontpaths.append(fontdir) - for dirpath, dirs, _files in os.walk(fontdir): - fontpaths.extend([os.path.join(dirpath, d) for d in dirs]) - - except (IOError, OSError, TypeError, ValueError): - pass - return fontpaths - - -def OSXInstalledFonts(directory=None, fontext="ttf"): - """ - Get list of font files on OS X - ignores font suffix by default. - """ - if directory is None: - directory = OSXFontDirectory() - - fontext = get_fontext_synonyms(fontext) - - files = [] - for path in directory: - if fontext is None: - files.extend(glob.glob(os.path.join(path, "*"))) - else: - for ext in fontext: - files.extend(glob.glob(os.path.join(path, "*." + ext))) - files.extend(glob.glob(os.path.join(path, "*." + ext.upper()))) - return files - - -def x11FontDirectory(): - """ - Return the system font directories for X11. This is done by - starting at the list of hardcoded paths in - :attr:`X11FontDirectories` and returning all nested directories - within them. - """ - fontpaths = [] - - for fontdir in X11FontDirectories: - try: - if os.path.isdir(fontdir): - fontpaths.append(fontdir) - for dirpath, dirs, _files in os.walk(fontdir): - fontpaths.extend([os.path.join(dirpath, d) for d in dirs]) - - except (IOError, OSError, TypeError, ValueError): - pass - return fontpaths - - -def get_fontconfig_fonts(fontext="ttf"): - """ - Grab a list of all the fonts that are being tracked by fontconfig - by making a system call to ``fc-list``. This is an easy way to - grab all of the fonts the user wants to be made available to - applications, without needing knowing where all of them reside. - """ - fontext = get_fontext_synonyms(fontext) - - fontfiles = {} - try: - cmd = ["fc-list", "", "file"] - pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE) - output = pipe.communicate()[0] - except OSError: - # Calling fc-list did not work, so we'll just return nothing - return fontfiles - - output = output.decode("utf8") - if pipe.returncode == 0: - for line in output.split("\n"): - fname = line.split(":")[0] - if (os.path.splitext(fname)[1][1:] in fontext - and os.path.exists(fname)): - fontfiles[fname] = 1 - - return fontfiles - - -def findSystemFonts(fontpaths=None, fontext="ttf"): - """ - Search for fonts in the specified font paths. If no paths are - given, will use a standard set of system paths, as well as the - list of fonts tracked by fontconfig if fontconfig is installed and - available. A list of TrueType fonts are returned by default with - AFM fonts as an option. - """ - fontfiles = {} - fontexts = get_fontext_synonyms(fontext) - - if fontpaths is None: - if sys.platform == "win32": - fontdir = win32FontDirectory() - - fontpaths = [fontdir] - # now get all installed fonts directly... - for f in win32InstalledFonts(fontdir): - base, ext = os.path.splitext(f) - if len(ext) > 1 and ext[1:].lower() in fontexts: - fontfiles[f] = 1 - else: - # check for OS X & load its fonts if present - if sys.platform == "darwin": - fontpaths = [] - for f in OSXInstalledFonts(fontext=fontext): - fontfiles[f] = 1 - else: - # Otherwise, check X11. - fontpaths = x11FontDirectory() - - for f in get_fontconfig_fonts(fontext): - fontfiles[f] = 1 - - elif isinstance(fontpaths, str): - fontpaths = [fontpaths] - - for path in fontpaths: - files = [] - for ext in fontexts: - files.extend(glob.glob(os.path.join(path, "*." + ext))) - files.extend(glob.glob(os.path.join(path, "*." + ext.upper()))) - for fname in files: - abs_path = os.path.abspath(fname) - - # Handle dirs which look like font files, but may contain font - # files - if os.path.isdir(abs_path): - fontpaths.append(abs_path) - else: - fontfiles[abs_path] = 1 - - return [fname for fname in fontfiles.keys() if os.path.exists(fname)] - - -def weight_as_number(weight): - """ - Return the weight property as a numeric value. String values - are converted to their corresponding numeric value. - """ - if isinstance(weight, str): - try: - weight = weight_dict[weight.lower()] - except KeyError: - weight = 400 - elif weight in range(100, 1000, 100): - pass - else: - raise ValueError("weight not a valid integer") - return weight - - -class FontEntry(object): - """ - A class for storing Font properties. It is used when populating - the font lookup dictionary. - """ - - def __init__(self, fname="", name="", style="normal", variant="normal", - weight="normal", stretch="normal", size="medium", - face_index=0): - self.fname = fname - self.name = name - self.style = style - self.variant = variant - self.weight = weight - self.stretch = stretch - self.face_index = face_index - try: - self.size = str(float(size)) - except ValueError: - self.size = size - - def __repr__(self): - return "" % ( - self.name, - os.path.basename(self.fname), - self.face_index, - self.style, - self.variant, - self.weight, - self.stretch, - ) - - -def ttfFontProperty(fpath, font, index=0): - """ - A function for populating the :class:`FontKey` by extracting - information from the TrueType font file. - - *font* is a :class:`FT2Font` instance. - """ - props = getPropDict(font) - name = props.get("name") - if name is None: - raise KeyError("No name could be found for: {}".format(fpath)) - - # Styles are: italic, oblique, and normal (default) - sfnt4 = props.get("sfnt4", "").lower() - - if sfnt4.find("oblique") >= 0: - style = "oblique" - elif sfnt4.find("italic") >= 0: - style = "italic" - else: - style = "normal" - - # Variants are: small-caps and normal (default) - - # !!!! Untested - if name.lower() in ["capitals", "small-caps"]: - variant = "small-caps" - else: - variant = "normal" - - # Weights are: 100, 200, 300, 400 (normal: default), 500 (medium), - # 600 (semibold, demibold), 700 (bold), 800 (heavy), 900 (black) - # lighter and bolder are also allowed. - - weight = None - for w in weight_dict.keys(): - if sfnt4.find(w) >= 0: - weight = w - break - if not weight: - weight = 400 - weight = weight_as_number(weight) - - # Stretch can be absolute and relative - # Absolute stretches are: ultra-condensed, extra-condensed, condensed, - # semi-condensed, normal, semi-expanded, expanded, extra-expanded, - # and ultra-expanded. - # Relative stretches are: wider, narrower - # Child value is: inherit - - if (sfnt4.find("narrow") >= 0 - or sfnt4.find("condensed") >= 0 - or sfnt4.find("cond") >= 0): - stretch = "condensed" - elif sfnt4.find("demi cond") >= 0: - stretch = "semi-condensed" - elif sfnt4.find("wide") >= 0 or sfnt4.find("expanded") >= 0: - stretch = "expanded" - else: - stretch = "normal" - - # Sizes can be absolute and relative. - # Absolute sizes are: xx-small, x-small, small, medium, large, x-large, - # and xx-large. - # Relative sizes are: larger, smaller - # Length value is an absolute font size, e.g. 12pt - # Percentage values are in 'em's. Most robust specification. - - # !!!! Incomplete - size = "scalable" - - return FontEntry(fpath, name, style, variant, weight, stretch, size, index) - - -def afmFontProperty(fontpath, font): - """ - A function for populating a :class:`FontKey` instance by - extracting information from the AFM font file. - - *font* is a class:`AFM` instance. - """ - - name = font.get_familyname() - fontname = font.get_fontname().lower() - - # Styles are: italic, oblique, and normal (default) - - if font.get_angle() != 0 or name.lower().find("italic") >= 0: - style = "italic" - elif name.lower().find("oblique") >= 0: - style = "oblique" - else: - style = "normal" - - # Variants are: small-caps and normal (default) - - # !!!! Untested - if name.lower() in ["capitals", "small-caps"]: - variant = "small-caps" - else: - variant = "normal" - - # Weights are: 100, 200, 300, 400 (normal: default), 500 (medium), - # 600 (semibold, demibold), 700 (bold), 800 (heavy), 900 (black) - # lighter and bolder are also allowed. - - weight = weight_as_number(font.get_weight().lower()) - - # Stretch can be absolute and relative - # Absolute stretches are: ultra-condensed, extra-condensed, condensed, - # semi-condensed, normal, semi-expanded, expanded, extra-expanded, - # and ultra-expanded. - # Relative stretches are: wider, narrower - # Child value is: inherit - if (fontname.find("narrow") >= 0 - or fontname.find("condensed") >= 0 - or fontname.find("cond") >= 0): - stretch = "condensed" - elif fontname.find("demi cond") >= 0: - stretch = "semi-condensed" - elif fontname.find("wide") >= 0 or fontname.find("expanded") >= 0: - stretch = "expanded" - else: - stretch = "normal" - - # Sizes can be absolute and relative. - # Absolute sizes are: xx-small, x-small, small, medium, large, x-large, - # and xx-large. - # Relative sizes are: larger, smaller - # Length value is an absolute font size, e.g. 12pt - # Percentage values are in 'em's. Most robust specification. - - # All AFM fonts are apparently scalable. - - size = "scalable" - - return FontEntry(fontpath, name, style, variant, weight, stretch, size) - - -def createFontList(fontfiles, fontext="ttf"): - """ - A function to create a font lookup list. The default is to create - a list of TrueType fonts. An AFM font list can optionally be - created. - """ - # FIXME: This function is particularly difficult to debug - fontlist = [] - # Add fonts from list of known font files. - seen = {} - - font_entry_err_msg = "Could not convert font to FontEntry for file %s" - - for fpath in fontfiles: - logger.debug("createFontDict %s", fpath) - fname = os.path.split(fpath)[1] - if fname in seen: - continue - else: - seen[fname] = 1 - if fontext == "afm": - try: - fh = open(fpath, "r") - except Exception: - logger.error( - "Could not open font file %s", fpath, exc_info=True - ) - continue - try: - try: - font = afm.AFM(fh) - finally: - fh.close() - except RuntimeError: - logger.error( - "Could not parse font file %s", fpath, exc_info=True - ) - continue - try: - prop = afmFontProperty(fpath, font) - except Exception: - logger.error(font_entry_err_msg, fpath, exc_info=True) - continue - else: - _, ext = os.path.splitext(fpath) - try: - if ext.lower() == ".ttc": - with open(fpath, "rb") as f: - collection = TTCollection(f) - try: - props = [] - for idx, font in enumerate(collection.fonts): - props.append(ttfFontProperty(fpath, font, idx)) - fontlist.extend(props) - continue - except Exception: - logger.error( - font_entry_err_msg, fpath, exc_info=True - ) - continue - else: - font = TTFont(str(fpath)) - except (RuntimeError, TTLibError): - logger.error( - "Could not open font file %s", fpath, exc_info=True - ) - continue - except UnicodeError: - logger.error( - "Cannot handle unicode file: %s", fpath, exc_info=True - ) - continue - - try: - prop = ttfFontProperty(fpath, font) - except Exception: - logger.error(font_entry_err_msg, fpath, exc_info=True) - continue - - fontlist.append(prop) - return fontlist - - -class FontProperties(object): - """ - A class for storing and manipulating font properties. - - The font properties are those described in the `W3C Cascading - Style Sheet, Level 1 - `_ font - specification. The six properties are: - - - family: A list of font names in decreasing order of priority. - The items may include a generic font family name, either - 'serif', 'sans-serif', 'cursive', 'fantasy', or 'monospace'. - - - style: Either 'normal', 'italic' or 'oblique'. - - - variant: Either 'normal' or 'small-caps'. - - - stretch: A numeric value in the range 0-1000 or one of - 'ultra-condensed', 'extra-condensed', 'condensed', - 'semi-condensed', 'normal', 'semi-expanded', 'expanded', - 'extra-expanded' or 'ultra-expanded' - - - weight: A numeric value in the range 0-1000 or one of - 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', - 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', - 'extra bold', 'black' - - - size: Either an relative value of 'xx-small', 'x-small', - 'small', 'medium', 'large', 'x-large', 'xx-large' or an - absolute font size, e.g. 12 - - Alternatively, a font may be specified using an absolute path to a - .ttf file, by using the *fname* kwarg. - - The preferred usage of font sizes is to use the relative values, - e.g. 'large', instead of absolute font sizes, e.g. 12. This - approach allows all text sizes to be made larger or smaller based - on the font manager's default font size. - """ - - def __init__(self, family=None, style=None, variant=None, weight=None, - stretch=None, size=None, fname=None, _init=None): - # if fname is set, it's a hardcoded filename to use - # _init is used only by copy() - - self._family = None - self._slant = None - self._variant = None - self._weight = None - self._stretch = None - self._size = None - self._file = None - - # This is used only by copy() - if _init is not None: - self.__dict__.update(_init.__dict__) - return - - self.set_family(family) - self.set_style(style) - self.set_variant(variant) - self.set_weight(weight) - self.set_stretch(stretch) - self.set_file(fname) - self.set_size(size) - - def __hash__(self): - lst = [(k, getattr(self, "get" + k)()) for k in sorted(self.__dict__)] - return hash(repr(lst)) - - def __str__(self): - attrs = ( - self._family, self._slant, self._variant, self._weight, - self._stretch, self._size, - ) - return str(attrs) - - def get_family(self): - """ - Return a list of font names that comprise the font family. - """ - return self._family - - def get_name(self): - """ - Return the name of the font that best matches the font - properties. - """ - spec = default_font_manager().findfont(self) - if spec.filename.endswith(".afm"): - return afm.AFM(open(spec.filename)).get_familyname() - - spec = default_font_manager().findfont(self) - prop_dict = getPropDict( - TTFont(spec.filename, fontNumber=spec.face_index) - ) - return prop_dict["name"] - - def get_style(self): - """ - Return the font style. Values are: 'normal', 'italic' or - 'oblique'. - """ - return self._slant - - get_slant = get_style - - def get_variant(self): - """ - Return the font variant. Values are: 'normal' or - 'small-caps'. - """ - return self._variant - - def get_weight(self): - """ - Set the font weight. Options are: A numeric value in the - range 0-1000 or one of 'light', 'normal', 'regular', 'book', - 'medium', 'roman', 'semibold', 'demibold', 'demi', 'bold', - 'heavy', 'extra bold', 'black' - """ - return self._weight - - def get_stretch(self): - """ - Return the font stretch or width. Options are: 'ultra-condensed', - 'extra-condensed', 'condensed', 'semi-condensed', 'normal', - 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded'. - """ - return self._stretch - - def get_size(self): - """ - Return the font size. - """ - return self._size - - def get_size_in_points(self): - if self._size is not None: - try: - return float(self._size) - except ValueError: - pass - default_size = default_font_manager().get_default_size() - return default_size * font_scalings.get(self._size) - - def get_file(self): - """ - Return the filename of the associated font. - """ - return self._file - - def set_family(self, family): - """ - Change the font family. May be either an alias (generic name - is CSS parlance), such as: 'serif', 'sans-serif', 'cursive', - 'fantasy', or 'monospace', or a real font name. - """ - if family is None: - self._family = None - else: - if isinstance(family, bytes): - family = [family.decode("utf8")] - elif isinstance(family, str): - family = [family] - self._family = family - - set_name = set_family - - def set_style(self, style): - """ - Set the font style. Values are: 'normal', 'italic' or - 'oblique'. - """ - if style not in ("normal", "italic", "oblique", None): - raise ValueError("style must be normal, italic or oblique") - self._slant = style - - set_slant = set_style - - def set_variant(self, variant): - """ - Set the font variant. Values are: 'normal' or 'small-caps'. - """ - if variant not in ("normal", "small-caps", None): - raise ValueError("variant must be normal or small-caps") - self._variant = variant - - def set_weight(self, weight): - """ - Set the font weight. May be either a numeric value in the - range 0-1000 or one of 'ultralight', 'light', 'normal', - 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', - 'demi', 'bold', 'heavy', 'extra bold', 'black' - """ - if weight is not None: - try: - weight = int(weight) - if weight < 0 or weight > 1000: - raise ValueError() - except ValueError: - if weight not in weight_dict: - raise ValueError("weight is invalid") - self._weight = weight - - def set_stretch(self, stretch): - """ - Set the font stretch or width. Options are: 'ultra-condensed', - 'extra-condensed', 'condensed', 'semi-condensed', 'normal', - 'semi-expanded', 'expanded', 'extra-expanded' or - 'ultra-expanded', or a numeric value in the range 0-1000. - """ - if stretch is not None: - try: - stretch = int(stretch) - if stretch < 0 or stretch > 1000: - raise ValueError() - except ValueError: - if stretch not in stretch_dict: - raise ValueError("stretch is invalid") - else: - stretch = 500 - self._stretch = stretch - - def set_size(self, size): - """ - Set the font size. Either an relative value of 'xx-small', - 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large' - or an absolute font size, e.g. 12. - """ - if size is not None: - try: - size = float(size) - except ValueError: - if size is not None and size not in font_scalings: - raise ValueError("size is invalid") - self._size = size - - def set_file(self, file): - """ - Set the filename of the fontfile to use. In this case, all - other properties will be ignored. - """ - self._file = file - - def copy(self): - """Return a deep copy of self""" - return FontProperties(_init=self) - - -def ttfdict_to_fnames(d): - """ - flatten a ttfdict to all the filenames it contains - """ - fnames = [] - for named in d.values(): - for styled in named.values(): - for variantd in styled.values(): - for weightd in variantd.values(): - for stretchd in weightd.values(): - for fname in stretchd.values(): - fnames.append(fname) - return fnames - - -def pickle_dump(data, filename): - """ - Equivalent to pickle.dump(data, open(filename, 'wb')) - but closes the file to prevent filehandle leakage. - """ - fh = open(filename, "wb") - try: - pickle.dump(data, fh) - finally: - fh.close() - - -def pickle_load(filename): - """ - Equivalent to pickle.load(open(filename, 'rb')) - but closes the file to prevent filehandle leakage. + font_manager : FontManager """ - fh = open(filename, "rb") - try: - data = pickle.load(fh) - finally: - fh.close() - return data + global fontManager + if fontManager is None: + fontManager = _load_from_cache_or_rebuild(_get_font_cache_path()) + return fontManager class FontManager: + """ The :class:`FontManager` singleton instance is created with a list of + TrueType fonts based on the font properties: name, style, variant, weight, + stretch, and size. The :meth:`findfont` method does a nearest neighbor + search to find the font that most closely matches the specification. If no + good enough match is found, a default font is returned. """ - On import, the :class:`FontManager` singleton instance creates a - list of TrueType fonts based on the font properties: name, style, - variant, weight, stretch, and size. The :meth:`findfont` method - does a nearest neighbor search to find the font that most closely - matches the specification. If no good enough match is found, a - default font is returned. - """ - # Increment this version number whenever the font cache data # format or behavior has changed and requires a existing font # cache files to be rebuilt. - __version__ = 8 + __version__ = 9 def __init__(self, size=None, weight="normal"): self._version = self.__version__ self.__default_weight = weight - self.default_size = size + self.default_size = size if size is not None else 12.0 paths = [] @@ -1058,7 +98,7 @@ def __init__(self, size=None, weight="normal"): logger.debug("font search path %s", str(paths)) # Load TrueType fonts and create font dictionary. - self.ttffiles = findSystemFonts(paths) + findSystemFonts() + self.ttffiles = scan_system_fonts(paths) + scan_system_fonts() self.defaultFamily = {"ttf": "Bitstream Vera Sans", "afm": "Helvetica"} self.defaultFont = {} @@ -1071,38 +111,34 @@ def __init__(self, size=None, weight="normal"): # use anything self.defaultFont["ttf"] = self.ttffiles[0] - self.ttflist = createFontList(self.ttffiles) + self.ttflist = create_font_list(self.ttffiles) - self.afmfiles = findSystemFonts( + self.afmfiles = scan_system_fonts( paths, fontext="afm" - ) + findSystemFonts(fontext="afm") - self.afmlist = createFontList(self.afmfiles, fontext="afm") + ) + scan_system_fonts(fontext="afm") + self.afmlist = create_font_list(self.afmfiles, fontext="afm") self.defaultFont["afm"] = None self.ttf_lookup_cache = {} self.afm_lookup_cache = {} def get_default_weight(self): - """ - Return the default font weight. + """ Return the default font weight. """ return self.__default_weight def get_default_size(self): - """ - Return the default font size. + """ Return the default font size. """ return self.default_size def set_default_weight(self, weight): - """ - Set the default font weight. The initial value is 'normal'. + """ Set the default font weight. The initial value is 'normal'. """ self.__default_weight = weight def update_fonts(self, filenames): - """ - Update the font dictionary with new font files. + """ Update the font lists with new font files. Currently not implemented. """ # !!!! Needs implementing @@ -1110,8 +146,7 @@ def update_fonts(self, filenames): def findfont(self, prop, fontext="ttf", directory=None, fallback_to_default=True, rebuild_if_missing=True): - """ - Search the font list for the font that most closely matches + """ Search the font list for the font that most closely matches the :class:`FontProperties` *prop*. :meth:`findfont` performs a nearest neighbor search. Each @@ -1134,6 +169,8 @@ def findfont(self, prop, fontext="ttf", directory=None, `_ documentation for a description of the font finding algorithm. """ + from kiva.fonttools._font_properties import FontProperties + class FontSpec(object): """ An object to represent the return value of findfont(). """ @@ -1146,8 +183,13 @@ def __fspath__(self): """ return self.filename + def __repr__(self): + args = f"{self.filename}, face_index={self.face_index}" + return f"FontSpec({args})" + if not isinstance(prop, FontProperties): prop = FontProperties(prop) + fname = prop.get_file() if fname is not None: logger.debug("findfont returning %s", fname) @@ -1178,16 +220,18 @@ def __fspath__(self): # Matching family should have highest priority, so it is multiplied # by 10.0 score = ( - score_family(prop.get_family(), font.name) * 10.0 + score_family(prop.get_family(), font.family) * 10.0 + score_style(prop.get_style(), font.style) + score_variant(prop.get_variant(), font.variant) + score_weight(prop.get_weight(), font.weight) + score_stretch(prop.get_stretch(), font.stretch) + score_size(prop.get_size(), font.size, self.default_size) ) + # Lowest score wins if score < best_score: best_score = score best_font = font + # Exact matches stop the search if score == 0: break @@ -1199,7 +243,10 @@ def __fspath__(self): ) default_prop = prop.copy() default_prop.set_family(self.defaultFamily[fontext]) - return self.findfont(default_prop, fontext, directory, False) + return self.findfont( + default_prop, fontext, directory, + fallback_to_default=False, + ) else: # This is a hard fail -- we can't find anything reasonable, # so just return the vera.ttf @@ -1214,7 +261,7 @@ def __fspath__(self): logger.debug( "findfont: Matching %s to %s (%s[%d]) with score of %f", prop, - best_font.name, + best_font.family, best_font.fname, best_font.face_index, best_score, @@ -1228,7 +275,9 @@ def __fspath__(self): ) _rebuild() return default_font_manager().findfont( - prop, fontext, directory, True, False + prop, fontext, directory, + fallback_to_default=True, + rebuild_if_missing=False, ) else: raise ValueError("No valid font could be found") @@ -1238,29 +287,23 @@ def __fspath__(self): return result -_is_opentype_cff_font_cache = {} +# --------------------------------------------------------------------------- +# Utilities - -def is_opentype_cff_font(filename): - """ - Returns True if the given font is a Postscript Compact Font Format - Font embedded in an OpenType wrapper. Used by the PostScript and - PDF backends that can not subset these fonts. +def _get_config_dir(): + """ Return the string representing the configuration dir. """ - if os.path.splitext(filename)[1].lower() == ".otf": - result = _is_opentype_cff_font_cache.get(filename) - if result is None: - fd = open(filename, "rb") - tag = fd.read(4) - fd.close() - result = tag == "OTTO" - _is_opentype_cff_font_cache[filename] = result - return result - return False + path = os.path.join(ETSConfig.application_data, "kiva") + try: + os.makedirs(path) + except OSError as e: + if e.errno != errno.EEXIST: + raise + if not _is_writable_dir(path): + raise IOError(f"Configuration directory {path} must be writable") -# Global singleton of FontManager, cached at the module level. -fontManager = None + return path def _get_font_cache_path(): @@ -1271,33 +314,23 @@ def _get_font_cache_path(): path : str Path to the font cache file. """ - return os.path.join(get_configdir(), "fontList.cache") + return os.path.join(_get_config_dir(), "fontList.cache") -def _rebuild(): - """ Rebuild the default font manager and cache its content. +def _is_writable_dir(p): + """ p is a string pointing to a putative writable dir -- return True p + is such a string, else False """ - global fontManager - fontManager = _new_font_manager(_get_font_cache_path()) - - -def _new_font_manager(cache_file): - """ Create a new FontManager (which will reload font files) and immediately - cache its content with the given file path. - - Parameters - ---------- - cache_file : str - Path to the cache to be created. + if not isinstance(p, str): + return False - Returns - ------- - font_manager : FontManager - """ - fontManager = FontManager() - pickle_dump(fontManager, cache_file) - logger.debug("generated new fontManager") - return fontManager + try: + with tempfile.TemporaryFile(dir=p) as fp: + fp.write(b"kiva.test") + return True + except OSError: + pass + return False def _load_from_cache_or_rebuild(cache_file): @@ -1314,9 +347,8 @@ def _load_from_cache_or_rebuild(cache_file): ------- font_manager : FontManager """ - try: - fontManager = pickle_load(cache_file) + fontManager = _pickle_load(cache_file) if (not hasattr(fontManager, "_version") or fontManager._version != FontManager.__version__): fontManager = _new_font_manager(cache_file) @@ -1329,15 +361,52 @@ def _load_from_cache_or_rebuild(cache_file): return fontManager -def default_font_manager(): - """ Return the default font manager, which is a singleton FontManager - cached in the module. +def _new_font_manager(cache_file): + """ Create a new FontManager (which will reload font files) and immediately + cache its content with the given file path. + + Parameters + ---------- + cache_file : str + Path to the cache to be created. Returns ------- font_manager : FontManager """ - global fontManager - if fontManager is None: - fontManager = _load_from_cache_or_rebuild(_get_font_cache_path()) + fontManager = FontManager() + _pickle_dump(fontManager, cache_file) + logger.debug("generated new fontManager") return fontManager + + +def _pickle_dump(data, filename): + """ + Equivalent to pickle.dump(data, open(filename, 'wb')) + but closes the file to prevent filehandle leakage. + """ + fh = open(filename, "wb") + try: + pickle.dump(data, fh) + finally: + fh.close() + + +def _pickle_load(filename): + """ + Equivalent to pickle.load(open(filename, 'rb')) + but closes the file to prevent filehandle leakage. + """ + fh = open(filename, "rb") + try: + data = pickle.load(fh) + finally: + fh.close() + return data + + +def _rebuild(): + """ Rebuild the default font manager and cache its content. + """ + global fontManager + fontManager = _new_font_manager(_get_font_cache_path()) diff --git a/kiva/fonttools/tests/test_font_manager.py b/kiva/fonttools/tests/test_font_manager.py index dbcf47542..5b3563f32 100644 --- a/kiva/fonttools/tests/test_font_manager.py +++ b/kiva/fonttools/tests/test_font_manager.py @@ -16,16 +16,13 @@ from unittest import mock from pkg_resources import resource_filename -from fontTools.ttLib import TTFont from traits.etsconfig.api import ETSConfig -from .. import font_manager as font_manager_module -from ..font_manager import ( - createFontList, default_font_manager, FontEntry, FontManager, - ttfFontProperty, -) from ._testing import patch_global_font_manager +from .. import font_manager as font_manager_module +from .._scan_parse import create_font_list, FontEntry +from ..font_manager import default_font_manager, FontManager data_dir = resource_filename("kiva.fonttools.tests", "data") @@ -36,83 +33,36 @@ def setUp(self): def test_fontlist_from_ttc(self): # When - fontlist = createFontList([self.ttc_fontpath]) + fontlist = create_font_list([self.ttc_fontpath]) # Then self.assertEqual(len(fontlist), 2) for fontprop in fontlist: self.assertIsInstance(fontprop, FontEntry) - @mock.patch("kiva.fonttools.font_manager.ttfFontProperty", + @mock.patch("kiva.fonttools._scan_parse._ttf_font_property", side_effect=ValueError) def test_ttc_exception_on_ttfFontProperty(self, m_ttfFontProperty): # When with self.assertLogs("kiva"): - fontlist = createFontList([self.ttc_fontpath]) + fontlist = create_font_list([self.ttc_fontpath]) # Then self.assertEqual(len(fontlist), 0) self.assertEqual(m_ttfFontProperty.call_count, 1) - @mock.patch("kiva.fonttools.font_manager.TTCollection", + @mock.patch("kiva.fonttools._scan_parse.TTCollection", side_effect=RuntimeError) def test_ttc_exception_on_TTCollection(self, m_TTCollection): # When with self.assertLogs("kiva"): - fontlist = createFontList([self.ttc_fontpath]) + fontlist = create_font_list([self.ttc_fontpath]) # Then self.assertEqual(len(fontlist), 0) self.assertEqual(m_TTCollection.call_count, 1) -class TestTTFFontProperty(unittest.TestCase): - def test_font(self): - # Given - test_font = os.path.join(data_dir, "TestTTF.ttf") - exp_name = "Test TTF" - exp_style = "normal" - exp_variant = "normal" - exp_weight = 400 - exp_stretch = "normal" - exp_size = "scalable" - - # When - entry = ttfFontProperty(test_font, TTFont(test_font)) - - # Then - self.assertEqual(entry.name, exp_name) - self.assertEqual(entry.style, exp_style) - self.assertEqual(entry.variant, exp_variant) - self.assertEqual(entry.weight, exp_weight) - self.assertEqual(entry.stretch, exp_stretch) - self.assertEqual(entry.size, exp_size) - - def test_font_with_italic_style(self): - """Test that a font with Italic style, writing with a capital - "I" is correctly identified as "italic" style. - """ - # Given - test_font = os.path.join(data_dir, "TestTTF Italic.ttf") - exp_name = "Test TTF" - exp_style = "italic" - exp_variant = "normal" - exp_weight = 400 - exp_stretch = "normal" - exp_size = "scalable" - - # When - entry = ttfFontProperty(test_font, TTFont(test_font)) - - # Then - self.assertEqual(entry.name, exp_name) - self.assertEqual(entry.style, exp_style) - self.assertEqual(entry.variant, exp_variant) - self.assertEqual(entry.weight, exp_weight) - self.assertEqual(entry.stretch, exp_stretch) - self.assertEqual(entry.size, exp_size) - - class TestFontCache(unittest.TestCase): """ Test internal font cache building mechanism.""" @@ -226,7 +176,7 @@ def patch_font_cache(dirpath, ttf_files): def patch_system_fonts(ttf_files): - """ Patch findSystemFonts with the given list of font file paths. + """ Patch scan_system_fonts with the given list of font file paths. This speeds up tests by avoiding having to parse a lot of font files on a system. @@ -242,6 +192,7 @@ def fake_find_system_fonts(fontpaths=None, fontext="ttf"): return ttf_files return [] + # Patch the version which was imported into `kiva.fonttools.font_manager` return mock.patch( - "kiva.fonttools.font_manager.findSystemFonts", fake_find_system_fonts + "kiva.fonttools.font_manager.scan_system_fonts", fake_find_system_fonts ) diff --git a/kiva/fonttools/tests/test_scan_parse.py b/kiva/fonttools/tests/test_scan_parse.py index 1cff4c450..901f6cdd7 100644 --- a/kiva/fonttools/tests/test_scan_parse.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -15,6 +15,7 @@ from fontTools.ttLib import TTFont from pkg_resources import resource_filename +from .._constants import weight_dict from .._scan_parse import ( _afm_font_property, _build_afm_entries, _ttf_font_property, create_font_list, FontEntry @@ -248,11 +249,12 @@ def test_property_branches(self): # Then self.assertEqual(entry.variant, "small-caps") + # ref: https://github.com/enthought/enable/issues/391 # Given prop_dict = { - "family": "TestyFont", + "family": "TestyFont Roman", "style": "Bold Oblique", - "full_name": "TestyFont Bold Oblique", + "full_name": "TestyFont Roman Bold Oblique", } # When with mock.patch(target, return_value=prop_dict): @@ -260,6 +262,7 @@ def test_property_branches(self): entry = _ttf_font_property(test_font, None) # Then self.assertEqual(entry.style, "oblique") + self.assertEqual(entry.weight, weight_dict["bold"]) stretch_options = { "TestyFont Narrow": "condensed",