diff --git a/kiva/fonttools/_database.py b/kiva/fonttools/_database.py new file mode 100644 index 000000000..fd66722c7 --- /dev/null +++ b/kiva/fonttools/_database.py @@ -0,0 +1,119 @@ +# (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 itertools +import os + +from kiva.fonttools._constants import preferred_fonts + + +class FontEntry(object): + """ A class for storing properties of fonts which have been discovered on + the local machine. + + `__hash__` is implemented so that set() can be used to prune duplicates. + """ + def __init__(self, fname="", family="", style="normal", variant="normal", + weight="normal", stretch="normal", size="medium", + face_index=0): + + self.fname = fname + self.family = family + 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 __hash__(self): + c = tuple(getattr(self, k) for k in sorted(self.__dict__)) + return hash(c) + + def __repr__(self): + fname = os.path.basename(self.fname) + return ( + f"" + ) + + +class FontDatabase: + """ A container for :class`FontEntry` instances of a specific type + (TrueType/OpenType, AFM) which can be queried in different ways. + """ + def __init__(self, entries): + # Use a set to keep out the duplicates + self._entries = {ent for ent in entries if isinstance(ent, FontEntry)} + self._family_map = self._build_family_map(self._entries) + self._file_map = self._build_file_map(self._entries) + + def add_fonts(self, entries): + """ Add more :class`FontEntry` instances to the database. + """ + for entry in entries: + # Avoid non-FontEntry objects and duplicates + if not isinstance(entry, FontEntry) or entry in self._entries: + continue + + self._entries.add(entry) + self._family_map.setdefault(entry.family, []).append(entry) + self._file_map.setdefault(entry.fname, []).append(entry) + + def fonts_for_directory(self, directory): + """ Returns all fonts whose file is in a directory. + """ + result = [] + for fname, entries in self._file_map.items(): + if os.path.commonprefix([fname, directory]): + result.extend(entries) + return result + + def fonts_for_family(self, families): + """ Returns all fonts which best match a particular family query or + all possible fonts if exact families are not matched. + + `families` is a list of real and generic family names. An iterable + of `FontEntry` instances is returned. + """ + flat_list = (lambda it: list(itertools.chain.from_iterable(it))) + + # Translate generic families into lists of families + fams = flat_list(preferred_fonts.get(fam, [fam]) for fam in families) + # Then collect all entries for those families + entries = flat_list(self._family_map.get(fam, []) for fam in fams) + if entries: + return entries + + # Return all entries if no families found + # Yes, self._entries is a set. Consumers should only expect an iterable + return self._entries + + def __len__(self): + return len(self._entries) + + @staticmethod + def _build_family_map(entries): + ret = {} + for entry in entries: + ret.setdefault(entry.family, []).append(entry) + + return ret + + @staticmethod + def _build_file_map(entries): + ret = {} + for entry in entries: + ret.setdefault(entry.fname, []).append(entry) + + return ret diff --git a/kiva/fonttools/_scan_parse.py b/kiva/fonttools/_scan_parse.py index 1845cba2c..5f934175b 100644 --- a/kiva/fonttools/_scan_parse.py +++ b/kiva/fonttools/_scan_parse.py @@ -21,6 +21,7 @@ from fontTools.ttLib import TTCollection, TTFont, TTLibError from kiva.fonttools._constants import weight_dict +from kiva.fonttools._database import FontDatabase, FontEntry from kiva.fonttools._util import get_ttf_prop_dict, weight_as_number logger = logging.getLogger(__name__) @@ -28,19 +29,19 @@ _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 +def create_font_database(fontfiles, fontext="ttf"): + """ Creates a :class:`FontDatabase` instance from a list of provided filepaths. - The default is to create a list of TrueType fonts. An AFM font list can - optionally be created. + The default is to locate TrueType fonts. An AFM database 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) + logger.debug("create_font_database %s", fpath) fname = os.path.basename(fpath) if fname in seen: continue @@ -51,34 +52,21 @@ def create_font_list(fontfiles, fontext="ttf"): else: fontlist.extend(_build_ttf_entries(fpath)) - return fontlist + return FontDatabase(fontlist) -class FontEntry(object): - """ A class for storing Font properties. It is used when populating - the font lookup dictionary. +def update_font_database(database, fontfiles, fontext="ttf"): + """ Add additional font entries to an existing :class:`FontDatabase` + instance. """ - def __init__(self, fname="", family="", style="normal", variant="normal", - weight="normal", stretch="normal", size="medium", - face_index=0): - self.fname = fname - self.family = family - 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): - fname = os.path.basename(self.fname) - return ( - f"" - ) + fontlist = [] + for fpath in fontfiles: + if fontext == "afm": + fontlist.extend(_build_afm_entries(fpath)) + else: + fontlist.extend(_build_ttf_entries(fpath)) + + database.add_fonts(fontlist) # ---------------------------------------------------------------------------- diff --git a/kiva/fonttools/font_manager.py b/kiva/fonttools/font_manager.py index 582cea9ba..a65877051 100644 --- a/kiva/fonttools/font_manager.py +++ b/kiva/fonttools/font_manager.py @@ -37,7 +37,9 @@ from traits.etsconfig.api import ETSConfig -from kiva.fonttools._scan_parse import create_font_list +from kiva.fonttools._scan_parse import ( + create_font_database, update_font_database +) from kiva.fonttools._scan_sys import scan_system_fonts, scan_user_fonts from kiva.fonttools._score import ( score_family, score_size, score_stretch, score_style, score_variant, @@ -74,17 +76,18 @@ class FontManager: # 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__ = 9 + __version__ = 10 def __init__(self, size=None, weight="normal"): self._version = self.__version__ self.__default_weight = weight self.default_size = size if size is not None else 12.0 - - paths = [] + self.default_family = "sans-serif" + self.default_font = {} # Create list of font paths + paths = [] for pathname in ["TTFPATH", "AFMPATH"]: if pathname in os.environ: ttfpath = os.environ[pathname] @@ -96,28 +99,26 @@ def __init__(self, size=None, weight="normal"): paths.append(ttfpath) logger.debug("font search path %s", str(paths)) - # Load TrueType fonts and create font dictionary. - self.ttffiles = scan_system_fonts(paths) + scan_system_fonts() - self.defaultFamily = {"ttf": "Bitstream Vera Sans", "afm": "Helvetica"} - self.defaultFont = {} - - for fname in self.ttffiles: + # Load TrueType fonts and create font database. + ttffiles = scan_system_fonts(paths) + scan_system_fonts() + for fname in ttffiles: logger.debug("trying fontname %s", fname) if fname.lower().find("vera.ttf") >= 0: - self.defaultFont["ttf"] = fname + self.default_font["ttf"] = fname break else: # use anything - self.defaultFont["ttf"] = self.ttffiles[0] + self.default_font["ttf"] = ttffiles[0] - self.ttflist = create_font_list(self.ttffiles) + self.ttf_db = create_font_database(ttffiles, fontext="ttf") - self.afmfiles = scan_system_fonts( + # Load AFM fonts and create font database. + afmfiles = scan_system_fonts( paths, fontext="afm" ) + scan_system_fonts(fontext="afm") - self.afmlist = create_font_list(self.afmfiles, fontext="afm") - self.defaultFont["afm"] = None + self.afm_db = create_font_database(afmfiles, fontext="afm") + self.default_font["afm"] = None self.ttf_lookup_cache = {} self.afm_lookup_cache = {} @@ -152,8 +153,8 @@ def update_fonts(self, paths): afm_paths = scan_user_fonts(paths, fontext="afm") ttf_paths = scan_user_fonts(paths, fontext="ttf") - self.afmlist.extend(create_font_list(afm_paths)) - self.ttflist.extend(create_font_list(ttf_paths)) + update_font_database(self.afm_db, afm_paths, fontext="afm") + update_font_database(self.ttf_db, ttf_paths, fontext="ttf") def findfont(self, prop, fontext="ttf", directory=None, fallback_to_default=True, rebuild_if_missing=True): @@ -210,24 +211,32 @@ def __repr__(self): if fontext == "afm": font_cache = self.afm_lookup_cache - fontlist = self.afmlist + font_db = self.afm_db else: font_cache = self.ttf_lookup_cache - fontlist = self.ttflist + font_db = self.ttf_db if directory is None: cached = font_cache.get(hash(prop)) if cached: return cached - best_score = 1e64 + # Narrow the search + if directory is not None: + # Only search the fonts from `directory` + fontlist = font_db.fonts_for_directory(directory) + else: + # Only search the fonts included in the families list of the query. + # This is safe because `score_family` will return 1.0 (no match) if + # none of the listed families match an entry's family. Further, + # both `fonts_for_family` and `score_family` will expand generic + # families ("serif", "monospace") into lists of candidate families, + # which ensures that all possible matching fonts will be scored. + fontlist = font_db.fonts_for_family(prop.get_family()) + + best_score = 20.0 best_font = None - for font in fontlist: - fname = font.fname - if (directory is not None - and os.path.commonprefix([fname, directory]) != directory): - continue # Matching family should have highest priority, so it is multiplied # by 10.0 score = ( @@ -250,10 +259,10 @@ def __repr__(self): if fallback_to_default: warnings.warn( "findfont: Font family %s not found. Falling back to %s" - % (prop.get_family(), self.defaultFamily[fontext]) + % (prop.get_family(), self.default_family) ) default_prop = prop.copy() - default_prop.set_family(self.defaultFamily[fontext]) + default_prop.set_family(self.default_family) return self.findfont( default_prop, fontext, directory, fallback_to_default=False, @@ -263,11 +272,11 @@ def __repr__(self): # so just return the vera.ttf warnings.warn( "findfont: Could not match %s. Returning %s" - % (prop, self.defaultFont[fontext]), + % (prop, self.default_font[fontext]), UserWarning, ) # Assume this is never a .ttc font, so 0 is ok for face index. - result = FontSpec(self.defaultFont[fontext]) + result = FontSpec(self.default_font[fontext]) else: logger.debug( "findfont: Matching %s to %s (%s[%d]) with score of %f", diff --git a/kiva/fonttools/tests/test_font.py b/kiva/fonttools/tests/test_font.py index e020ef51f..476701806 100644 --- a/kiva/fonttools/tests/test_font.py +++ b/kiva/fonttools/tests/test_font.py @@ -12,7 +12,7 @@ import os import unittest -from kiva.api import BOLD, ITALIC, Font, MODERN, ROMAN +from kiva.api import BOLD, Font, ITALIC, MODERN, ROMAN from kiva.fonttools import str_to_font from kiva.fonttools.tests._testing import patch_global_font_manager @@ -33,7 +33,6 @@ def test_find_font_empty_name(self): font = Font(face_name="") spec = font.findfont() self.assertTrue(os.path.exists(spec.filename)) - self.assertEqual(spec.face_index, 0) def test_find_font_some_face_name(self): font = Font(face_name="ProbablyNotFound") @@ -43,7 +42,6 @@ def test_find_font_some_face_name(self): with self.assertWarns(UserWarning): spec = font.findfont() self.assertTrue(os.path.exists(spec.filename)) - self.assertEqual(spec.face_index, 0) def test_find_font_name(self): font = Font(face_name="ProbablyNotFound") diff --git a/kiva/fonttools/tests/test_font_manager.py b/kiva/fonttools/tests/test_font_manager.py index 5b3563f32..cc64adada 100644 --- a/kiva/fonttools/tests/test_font_manager.py +++ b/kiva/fonttools/tests/test_font_manager.py @@ -21,54 +21,18 @@ 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") -class TestCreateFontList(unittest.TestCase): - def setUp(self): - self.ttc_fontpath = os.path.join(data_dir, "TestTTC.ttc") - - 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._scan_parse._ttf_font_property", - side_effect=ValueError) - def test_ttc_exception_on_ttfFontProperty(self, m_ttfFontProperty): - # When - with self.assertLogs("kiva"): - fontlist = create_font_list([self.ttc_fontpath]) - - # Then - self.assertEqual(len(fontlist), 0) - self.assertEqual(m_ttfFontProperty.call_count, 1) - - @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 = create_font_list([self.ttc_fontpath]) - - # Then - self.assertEqual(len(fontlist), 0) - self.assertEqual(m_TTCollection.call_count, 1) - - class TestFontCache(unittest.TestCase): """ Test internal font cache building mechanism.""" def setUp(self): self.ttf_files = [ - os.path.abspath(os.path.join(data_dir, "TestTTF.ttf")) + os.path.abspath(os.path.join(data_dir, "TestTTF.ttf")), + os.path.abspath(os.path.join(data_dir, "TestTTC.ttc")), ] temp_dir_obj = tempfile.TemporaryDirectory() @@ -81,10 +45,12 @@ def test_load_font_from_cache(self): with patch_font_cache(self.temp_dir, self.ttf_files): default_manager = font_manager_module.default_font_manager() - # For some reasons, there are duplications in the list of files - self.assertEqual( - set(default_manager.ttffiles), set(self.ttf_files) - ) + # Check that all files are in the internal FontDatabase + entries = default_manager.ttf_db.fonts_for_directory(data_dir) + # Remove duplicates, since there may be more fonts than files. + files = sorted(set(ent.fname for ent in entries)) + self.assertListEqual(files, sorted(self.ttf_files)) + # The global singleton is now set. self.assertIsInstance(font_manager_module.fontManager, FontManager) diff --git a/kiva/fonttools/tests/test_scan_parse.py b/kiva/fonttools/tests/test_scan_parse.py index 901f6cdd7..21a4a1440 100644 --- a/kiva/fonttools/tests/test_scan_parse.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -18,7 +18,7 @@ from .._constants import weight_dict from .._scan_parse import ( _afm_font_property, _build_afm_entries, _ttf_font_property, - create_font_list, FontEntry + create_font_database ) data_dir = resource_filename("kiva.fonttools.tests", "data") @@ -29,34 +29,34 @@ 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): + def test_fontdb_duplicates(self): # When three_duplicate_ttfs = [self.ttf_fontpath] * 3 - fontlist = create_font_list(three_duplicate_ttfs) + font_db = create_font_database(three_duplicate_ttfs) # Then - self.assertEqual(len(fontlist), 1) - self.assertIsInstance(fontlist[0], FontEntry) + self.assertEqual(len(font_db), 1) - def test_fontlist_from_ttc(self): + def test_fontdb_from_ttc(self): # When - fontlist = create_font_list([self.ttc_fontpath]) + font_db = create_font_database([self.ttc_fontpath]) # Then - self.assertEqual(len(fontlist), 2) - for idx, fontprop in enumerate(fontlist): - self.assertIsInstance(fontprop, FontEntry) - self.assertEqual(fontprop.face_index, idx) + entries = font_db.fonts_for_directory(data_dir) + self.assertEqual(len(entries), 2) + entries.sort(key=lambda x: x.face_index) + for idx, entry in enumerate(entries): + self.assertEqual(entry.face_index, idx) @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 with self.assertLogs("kiva"): - fontlist = create_font_list([self.ttc_fontpath]) + font_db = create_font_database([self.ttc_fontpath]) # Then - self.assertEqual(len(fontlist), 0) + self.assertEqual(len(font_db), 0) self.assertEqual(m_ttf_font_property.call_count, 1) @mock.patch("kiva.fonttools._scan_parse.TTCollection", @@ -64,10 +64,10 @@ def test_ttc_exception_on__ttf_font_property(self, m_ttf_font_property): def test_ttc_exception_on_TTCollection(self, m_TTCollection): # When with self.assertLogs("kiva"): - fontlist = create_font_list([self.ttc_fontpath]) + font_db = create_font_database([self.ttc_fontpath]) # Then - self.assertEqual(len(fontlist), 0) + self.assertEqual(len(font_db), 0) self.assertEqual(m_TTCollection.call_count, 1)