From 3546c0c65a12302a01f75d49de0b6dd2611b9a47 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Wed, 10 Mar 2021 11:13:45 +0100 Subject: [PATCH] Split kiva.fonttools._scanner into two modules --- kiva/fonttools/_scan_parse.py | 266 ++++++++++++++++++ kiva/fonttools/{_scanner.py => _scan_sys.py} | 259 +---------------- .../{test_scanner.py => test_scan_parse.py} | 54 +--- kiva/fonttools/tests/test_scan_sys.py | 59 ++++ 4 files changed, 335 insertions(+), 303 deletions(-) create mode 100644 kiva/fonttools/_scan_parse.py rename kiva/fonttools/{_scanner.py => _scan_sys.py} (51%) rename kiva/fonttools/tests/{test_scanner.py => test_scan_parse.py} (81%) create mode 100644 kiva/fonttools/tests/test_scan_sys.py diff --git a/kiva/fonttools/_scan_parse.py b/kiva/fonttools/_scan_parse.py new file mode 100644 index 000000000..18c81f1c6 --- /dev/null +++ b/kiva/fonttools/_scan_parse.py @@ -0,0 +1,266 @@ +# (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 SVN rev 8713 +(git commit f8e4c6ce2408044bc89b78b3c72e54deb1999fb5), +but has been modified quite a bit in the decade since it was copied. +#################### +""" +import logging +import os + +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" + + +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. + """ + # 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) + 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 + + +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"" + ) + + +# ---------------------------------------------------------------------------- +# utility funcs + +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) + # 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(): + 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) + # 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(): + 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/_scanner.py b/kiva/fonttools/_scan_sys.py similarity index 51% rename from kiva/fonttools/_scanner.py rename to kiva/fonttools/_scan_sys.py index 3b7aabc6c..721dad84f 100644 --- a/kiva/fonttools/_scanner.py +++ b/kiva/fonttools/_scan_sys.py @@ -9,17 +9,10 @@ # 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. #################### - -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 @@ -27,12 +20,6 @@ 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" @@ -68,57 +55,6 @@ 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. - """ - # 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) - 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. @@ -174,7 +110,7 @@ def scan_system_fonts(fontpaths=None, fontext="ttf"): # ---------------------------------------------------------------------------- -# Font directory scanning +# utility funcs def _get_fontext_synonyms(fontext): """ Return a list of file extensions extensions that are synonyms for @@ -348,190 +284,3 @@ def _x11_font_directory(): 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) - # 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(): - 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) - # 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(): - 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/tests/test_scanner.py b/kiva/fonttools/tests/test_scan_parse.py similarity index 81% rename from kiva/fonttools/tests/test_scanner.py rename to kiva/fonttools/tests/test_scan_parse.py index 8f0d84fc9..474cbc5c4 100644 --- a/kiva/fonttools/tests/test_scanner.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -8,22 +8,18 @@ # # 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 ( +from .._scan_parse import ( _afm_font_property, _build_afm_entries, _ttf_font_property, - create_font_list, FontEntry, scan_system_fonts + create_font_list, FontEntry ) 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): @@ -49,7 +45,7 @@ def test_fontlist_from_ttc(self): for fontprop in fontlist: self.assertIsInstance(fontprop, FontEntry) - @mock.patch("kiva.fonttools._scanner._ttf_font_property", + @mock.patch("kiva.fonttools._scan_parse._ttf_font_property", side_effect=ValueError) def test_ttc_exception_on__ttf_font_property(self, m_ttf_font_property): # When @@ -60,7 +56,7 @@ def test_ttc_exception_on__ttf_font_property(self, m_ttf_font_property): self.assertEqual(len(fontlist), 0) self.assertEqual(m_ttf_font_property.call_count, 1) - @mock.patch("kiva.fonttools._scanner.TTCollection", + @mock.patch("kiva.fonttools._scan_parse.TTCollection", side_effect=RuntimeError) def test_ttc_exception_on_TTCollection(self, m_TTCollection): # When @@ -72,44 +68,6 @@ def test_ttc_exception_on_TTCollection(self, m_TTCollection): 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_windows, "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 @@ -247,14 +205,14 @@ def test_nameless_font(self): test_font = os.path.join(data_dir, "TestTTF.ttf") # When - target = "kiva.fonttools._scanner.get_ttf_prop_dict" + target = "kiva.fonttools._scan_parse.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" + target = "kiva.fonttools._scan_parse.get_ttf_prop_dict" test_font = os.path.join(data_dir, "TestTTF.ttf") # Given diff --git a/kiva/fonttools/tests/test_scan_sys.py b/kiva/fonttools/tests/test_scan_sys.py new file mode 100644 index 000000000..a43a78234 --- /dev/null +++ b/kiva/fonttools/tests/test_scan_sys.py @@ -0,0 +1,59 @@ +# (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 pkg_resources import resource_filename + +from .._scan_sys import 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 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_windows, "This test is only for Windows") + def test_windows_scanning(self): + fonts = scan_system_fonts(fontext="ttf") + self.assertNotEqual([], fonts)