From 01fade7045227ebb12b628bfca9f4f71b5825749 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Tue, 9 Mar 2021 13:08:55 +0100 Subject: [PATCH 1/3] Move the font scanning code into private modules --- kiva/fonttools/_constants.py | 26 ++ kiva/fonttools/_scanner.py | 538 +++++++++++++++++++++++++++ kiva/fonttools/_util.py | 63 ++++ kiva/fonttools/tests/test_scanner.py | 304 +++++++++++++++ 4 files changed, 931 insertions(+) create mode 100644 kiva/fonttools/_constants.py create mode 100644 kiva/fonttools/_scanner.py create mode 100644 kiva/fonttools/_util.py create mode 100644 kiva/fonttools/tests/test_scanner.py diff --git a/kiva/fonttools/_constants.py b/kiva/fonttools/_constants.py new file mode 100644 index 000000000..f744cb430 --- /dev/null +++ b/kiva/fonttools/_constants.py @@ -0,0 +1,26 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +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, +} diff --git a/kiva/fonttools/_scanner.py b/kiva/fonttools/_scanner.py new file mode 100644 index 000000000..0caee80a5 --- /dev/null +++ b/kiva/fonttools/_scanner.py @@ -0,0 +1,538 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# 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 +#################### + +Authors : John Hunter + Paul Barrett + Michael Droettboom +Copyright : John Hunter (2004,2005), Paul Barrett (2004,2005) +License : matplotlib license (PSF compatible) + The font directory code is from ttfquery, + see license/LICENSE_TTFQUERY. +""" +import glob +import logging +import os +import subprocess +import sys + +from fontTools.ttLib import TTCollection, TTFont, TTLibError + +from kiva.fonttools import afm +from kiva.fonttools._constants import weight_dict +from kiva.fonttools._util import get_ttf_prop_dict, weight_as_number + +logger = logging.getLogger(__name__) +# Error message when fonts fail to load +_FONT_ENTRY_ERR_MSG = "Could not convert font to FontEntry for file %s" + +# 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", +] +OSXFontDirectories = [ + "/Library/Fonts/", + "/Network/Library/Fonts/", + "/System/Library/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/", +] + +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) + + +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"): + self.fname = fname + self.name = name + self.style = style + self.variant = variant + self.weight = weight + self.stretch = stretch + try: + self.size = str(float(size)) + except ValueError: + self.size = size + + def __repr__(self): + fname = os.path.basename(self.fname) + return ( + f"" + ) + + +def create_font_list(fontfiles, fontext="ttf"): + """ Creates a list of :class`FontEntry` instances from a list of provided + filepaths. + + 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 = set() + + for fpath in fontfiles: + logger.debug("create_font_list %s", fpath) + fname = os.path.basename(fpath) + if fname in seen: + continue + + seen.add(fname) + if fontext == "afm": + fontlist.extend(_build_afm_entries(fpath)) + else: + fontlist.extend(_build_ttf_entries(fpath)) + + return fontlist + + +def scan_system_fonts(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 = set() + fontexts = _get_fontext_synonyms(fontext) + + if fontpaths is None: + fontpaths = [] + if sys.platform in ("win32", "cygwin"): + fontdir = _win32_font_directory() + fontpaths.append(fontdir) + # now get all installed fonts directly... + for fname in _win32_installed_fonts(fontdir): + ext = os.path.splitext(fname)[-1] + if ext[1:].lower() in fontexts: + fontfiles.add(fname) + else: + # check for macOS & load its fonts if present + if sys.platform == "darwin": + for fname in _macos_installed_fonts(fontext=fontext): + fontfiles.add(fname) + else: + # Otherwise, check X11. + fontpaths = _x11_font_directory() + + for fname in _get_fontconfig_fonts(fontext): + fontfiles.add(fname) + + 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.add(abs_path) + + return [fname for fname in fontfiles if os.path.exists(fname)] + + +# ---------------------------------------------------------------------------- +# Font directory scanning + +def _get_fontext_synonyms(fontext): + """ Return a list of file extensions extensions that are synonyms for + the given file extension *fileext*. + """ + synonyms = { + "ttf": ("ttf", "otf", "ttc"), + "otf": ("ttf", "otf", "ttc"), + "ttc": ("ttf", "otf", "ttc"), + "afm": ("afm",), + } + return synonyms.get(fontext, ()) + + +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 = set() + 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 + + if pipe.returncode == 0: + output = output.decode("utf8") + 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.add(fname) + + return fontfiles + + +def _macos_font_directory(): + """ 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 _macos_installed_fonts(directory=None, fontext="ttf"): + """ Get list of font files on OS X - ignores font suffix by default. + """ + directories = directory + if directories is None: + directories = _macos_font_directory() + + fontexts = _get_fontext_synonyms(fontext) + files = [] + for path in directories: + if not fontexts: + files.extend(glob.glob(os.path.join(path, "*"))) + else: + for ext in fontexts: + files.extend(glob.glob(os.path.join(path, "*." + ext))) + files.extend(glob.glob(os.path.join(path, "*." + ext.upper()))) + return files + + +def _win32_font_directory(): + 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 _win32_installed_fonts(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 = _win32_font_directory() + + 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 _x11_font_directory(): + """ 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 + + +# ---------------------------------------------------------------------------- +# FontEntry Creation + +def _build_afm_entries(fpath): + """ Given the path to an AFM file, return a list of one :class:`FontEntry` + instance or an empty list if there was an error. + """ + try: + fh = open(fpath, "r") + except OSError: + logger.error(f"Could not open font file {fpath}", exc_info=True) + return [] + + try: + font = afm.AFM(fh) + except RuntimeError: + logger.error(f"Could not parse font file {fpath}", exc_info=True) + return [] + finally: + fh.close() + + try: + return [_afm_font_property(fpath, font)] + except Exception: + logger.error(_FONT_ENTRY_ERR_MSG, fpath, exc_info=True) + + return [] + + +def _build_ttf_entries(fpath): + """ Given the path to a TTF/TTC file, return a list of :class:`FontEntry` + instances. + """ + entries = [] + + ext = os.path.splitext(fpath)[-1] + try: + with open(fpath, "rb") as fp: + if ext.lower() == ".ttc": + collection = TTCollection(fp) + try: + for font in collection.fonts: + entries.append(_ttf_font_property(fpath, font)) + except Exception: + logger.error(_FONT_ENTRY_ERR_MSG, fpath, exc_info=True) + else: + font = TTFont(fp) + try: + entries.append(_ttf_font_property(fpath, font)) + except Exception: + logger.error(_FONT_ENTRY_ERR_MSG, fpath, exc_info=True) + except (RuntimeError, TTLibError): + logger.error(f"Could not open font file {fpath}", exc_info=True) + except UnicodeError: + logger.error(f"Cannot handle unicode file: {fpath}", exc_info=True) + + return entries + + +def _afm_font_property(fontpath, font): + """ A function for populating a :class:`FontEntry` 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 + variant = "normal" + for value in ("capitals", "small-caps"): + if value in name.lower(): + variant = "small-caps" + break + + # 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("demi cond") >= 0: + stretch = "semi-condensed" + elif (fontname.find("narrow") >= 0 + or fontname.find("condensed") >= 0 + or fontname.find("cond") >= 0): + stretch = "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 _ttf_font_property(fpath, font): + """ A function for populating the :class:`FontEntry` by extracting + information from the TrueType font file. + + *font* is a :class:`TTFont` instance. + """ + props = get_ttf_prop_dict(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) + variant = "normal" + for value in ("capitals", "small-caps"): + if value in name.lower(): + variant = "small-caps" + break + + # 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("demi cond") >= 0: + stretch = "semi-condensed" + elif (sfnt4.find("narrow") >= 0 + or sfnt4.find("condensed") >= 0 + or sfnt4.find("cond") >= 0): + stretch = "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) diff --git a/kiva/fonttools/_util.py b/kiva/fonttools/_util.py new file mode 100644 index 000000000..b07e151c2 --- /dev/null +++ b/kiva/fonttools/_util.py @@ -0,0 +1,63 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from kiva.fonttools._constants import weight_dict + + +def get_ttf_prop_dict(font): + """ Return the property dictionary from a :class:`TTFont` instance. + """ + 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 + + +def weight_as_number(weight): + """ Return the weight property as a numeric value. + + String values are converted to their corresponding numeric value. + """ + allowed_weights = set(weight_dict.values()) + if isinstance(weight, str): + try: + weight = weight_dict[weight.lower()] + except KeyError: + weight = weight_dict["regular"] + elif weight in allowed_weights: + pass + else: + raise ValueError("weight not a valid integer") + return weight + + +def _decode_prop(prop): + """ Decode a prop string. + + Parameters + ---------- + prop : bytestring + + 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) diff --git a/kiva/fonttools/tests/test_scanner.py b/kiva/fonttools/tests/test_scanner.py new file mode 100644 index 000000000..41999dd11 --- /dev/null +++ b/kiva/fonttools/tests/test_scanner.py @@ -0,0 +1,304 @@ +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +import os +import sys +import unittest +from unittest import mock + +from fontTools.ttLib import TTFont +from pkg_resources import resource_filename + +from .._scanner import ( + _afm_font_property, _build_afm_entries, _ttf_font_property, + create_font_list, FontEntry, scan_system_fonts +) + +data_dir = resource_filename("kiva.fonttools.tests", "data") +is_macos = (sys.platform == "darwin") +is_windows = (sys.platform in ("win32", "cygwin")) +is_generic = not (is_macos or is_windows) + + +class TestFontEntryCreation(unittest.TestCase): + def setUp(self): + self.ttc_fontpath = os.path.join(data_dir, "TestTTC.ttc") + self.ttf_fontpath = os.path.join(data_dir, "TestTTF.ttf") + + def test_fontlist_duplicates(self): + # When + three_duplicate_ttfs = [self.ttf_fontpath] * 3 + fontlist = create_font_list(three_duplicate_ttfs) + + # Then + self.assertEqual(len(fontlist), 1) + self.assertIsInstance(fontlist[0], FontEntry) + + def test_fontlist_from_ttc(self): + # When + 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._scanner._ttf_font_property", + side_effect=ValueError) + def test_ttc_exception_on__ttf_font_property(self, m_ttf_font_property): + # When + with self.assertLogs("kiva"): + fontlist = create_font_list([self.ttc_fontpath]) + + # Then + self.assertEqual(len(fontlist), 0) + self.assertEqual(m_ttf_font_property.call_count, 1) + + @mock.patch("kiva.fonttools._scanner.TTCollection", + side_effect=RuntimeError) + def test_ttc_exception_on_TTCollection(self, m_TTCollection): + # When + with self.assertLogs("kiva"): + fontlist = create_font_list([self.ttc_fontpath]) + + # Then + self.assertEqual(len(fontlist), 0) + self.assertEqual(m_TTCollection.call_count, 1) + + +class TestFontDirectoryScanning(unittest.TestCase): + def test_directory_scanning(self): + expected = [ + os.path.join(data_dir, fname) + for fname in os.listdir(data_dir) + ] + fonts = scan_system_fonts(data_dir, fontext="ttf") + self.assertListEqual(sorted(expected), sorted(fonts)) + + # There are no AFM fonts in the test data + fonts = scan_system_fonts(data_dir, fontext="afm") + self.assertListEqual([], fonts) + + def test_directories_scanning(self): + expected = sorted([ + os.path.join(data_dir, fname) + for fname in os.listdir(data_dir) + ]) + # Pass a list of directories instead of a single path string + fonts = scan_system_fonts([data_dir], fontext="ttf") + self.assertListEqual(sorted(expected), sorted(fonts)) + + @unittest.skipIf(not is_generic, "This test is only for generic platforms") + def test_generic_scanning(self): + fonts = scan_system_fonts(fontext="ttf") + self.assertNotEqual([], fonts) + + @unittest.skipIf(not is_macos, "This test is only for macOS") + def test_macos_scanning(self): + fonts = scan_system_fonts(fontext="ttf") + self.assertNotEqual([], fonts) + + @unittest.skipIf(not is_generic, "This test is only for Windows") + def test_windows_scanning(self): + fonts = scan_system_fonts(fontext="ttf") + self.assertNotEqual([], fonts) + + +class TestAFMFontEntry(unittest.TestCase): + def test_afm_font_failure(self): + # We have no AFM fonts, so test some error handling + with self.assertLogs("kiva"): + entries = _build_afm_entries("nonexistant.path") + self.assertListEqual([], entries) + + # XXX: Once AFM code has been converted to expect bytestrings: + # Add a test which passes an existing file (non-afm) to + # _build_afm_entries. + + def test_property_branches(self): + fake_path = os.path.join(data_dir, "TestAFM.afm") + + class FakeAFM: + def __init__(self, name, family, angle, weight): + self.name = name + self.family = family + self.angle = angle + self.weight = weight + + def get_angle(self): + return self.angle + + def get_familyname(self): + return self.family + + def get_fontname(self): + return self.name + + def get_weight(self): + return self.weight + + # Given + fake_font = FakeAFM("TestyFont", "Testy", 0, "Bold") + exp_name = "Testy" + exp_style = "normal" + exp_variant = "normal" + exp_weight = 700 + exp_stretch = "normal" + exp_size = "scalable" + # When + entry = _afm_font_property(fake_path, fake_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) + + # Style variations + italics = ( + FakeAFM("TestyFont", "Testy Italic", 0, "Bold"), + FakeAFM("TestyFont", "Testy Fancy", 30, "Bold"), + ) + oblique = FakeAFM("TestyFont", "Testy Oblique", 0, "Bold") + # Oblique + entry = _afm_font_property(fake_path, oblique) + self.assertEqual(entry.style, "oblique") + # Italic + for font in italics: + entry = _afm_font_property(fake_path, font) + self.assertEqual(entry.style, "italic") + + # Given + fake_font = FakeAFM("TestyFont", "Testy Capitals", 0, "Bold") + exp_variant = "small-caps" + # When + entry = _afm_font_property(fake_path, fake_font) + # Then + self.assertEqual(entry.variant, exp_variant) + + # Given + stretches = { + "condensed": FakeAFM("TestyFont Narrow", "Testy", 0, "Regular"), + "expanded": FakeAFM("Testy Wide", "Testy", 0, "Light"), + "semi-condensed": FakeAFM("Testy Demi Cond", "Testy", 0, "Light"), + } + for stretch, font in stretches.items(): + # When + entry = _afm_font_property(fake_path, font) + # Then + self.assertEqual(entry.stretch, stretch) + + +class TestTTFFontEntry(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 = _ttf_font_property(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 = _ttf_font_property(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_nameless_font(self): + # Given + test_font = os.path.join(data_dir, "TestTTF.ttf") + + # When + target = "kiva.fonttools._scanner.get_ttf_prop_dict" + with mock.patch(target, return_value={}): + with self.assertRaises(KeyError): + # Pass None since we're mocking get_ttf_prop_dict + _ttf_font_property(test_font, None) + + def test_property_branches(self): + target = "kiva.fonttools._scanner.get_ttf_prop_dict" + test_font = os.path.join(data_dir, "TestTTF.ttf") + + # Given + exp_name = "TestyFont Bold Capitals" + prop_dict = { + "name": exp_name, + "sfnt4": exp_name, + } + # When + with mock.patch(target, return_value=prop_dict): + # Pass None since we're mocking get_ttf_prop_dict + entry = _ttf_font_property(test_font, None) + # Then + self.assertEqual(entry.variant, "small-caps") + + # Given + exp_name = "TestyFont Bold Oblique" + prop_dict = { + "name": exp_name, + "sfnt4": exp_name, + } + # When + with mock.patch(target, return_value=prop_dict): + # Pass None since we're mocking get_ttf_prop_dict + entry = _ttf_font_property(test_font, None) + # Then + self.assertEqual(entry.style, "oblique") + + stretch_options = { + "TestyFont Narrow": "condensed", + "TestyFont Condensed": "condensed", + "TestyFont Demi Cond": "semi-condensed", + "TestyFont Wide": "expanded", + "TestyFont Expanded": "expanded", + } + for name, stretch in stretch_options.items(): + # Given + prop_dict = { + "name": name, + "sfnt4": name, + } + # When + with mock.patch(target, return_value=prop_dict): + # Pass None since we're mocking get_ttf_prop_dict + entry = _ttf_font_property(test_font, None) + # Then + self.assertEqual(entry.stretch, stretch) From 3f4952f28da5b7e9d005e21b02312a105127f9ae Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Tue, 9 Mar 2021 17:07:14 +0100 Subject: [PATCH 2/3] Correctly test scan_system_fonts on Windows --- kiva/fonttools/tests/test_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiva/fonttools/tests/test_scanner.py b/kiva/fonttools/tests/test_scanner.py index 41999dd11..8f0d84fc9 100644 --- a/kiva/fonttools/tests/test_scanner.py +++ b/kiva/fonttools/tests/test_scanner.py @@ -104,7 +104,7 @@ def test_macos_scanning(self): fonts = scan_system_fonts(fontext="ttf") self.assertNotEqual([], fonts) - @unittest.skipIf(not is_generic, "This test is only for Windows") + @unittest.skipIf(not is_windows, "This test is only for Windows") def test_windows_scanning(self): fonts = scan_system_fonts(fontext="ttf") self.assertNotEqual([], fonts) From a0a1a9321551cb6a23f4fb98f94fa50466b267d2 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Wed, 10 Mar 2021 10:01:40 +0100 Subject: [PATCH 3/3] PR feedback --- kiva/fonttools/_scanner.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/kiva/fonttools/_scanner.py b/kiva/fonttools/_scanner.py index 0caee80a5..3b7aabc6c 100644 --- a/kiva/fonttools/_scanner.py +++ b/kiva/fonttools/_scanner.py @@ -100,12 +100,10 @@ def create_font_list(fontfiles, fontext="ttf"): 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. + # Use a set() to filter out files which were already scanned seen = set() + fontlist = [] for fpath in fontfiles: logger.debug("create_font_list %s", fpath) fname = os.path.basename(fpath) @@ -429,7 +427,7 @@ def _afm_font_property(fontpath, font): style = "normal" # Variants are: small-caps and normal (default) - # !!!! Untested + # NOTE: Not sure how many fonts actually have these strings in their name variant = "normal" for value in ("capitals", "small-caps"): if value in name.lower(): @@ -491,6 +489,7 @@ def _ttf_font_property(fpath, font): style = "normal" # Variants are: small-caps and normal (default) + # NOTE: Not sure how many fonts actually have these strings in their name variant = "normal" for value in ("capitals", "small-caps"): if value in name.lower():