diff --git a/kiva/fonttools/font.py b/kiva/fonttools/font.py index f47de81e5..933865424 100644 --- a/kiva/fonttools/font.py +++ b/kiva/fonttools/font.py @@ -5,6 +5,7 @@ import copy from kiva.constants import (DEFAULT, DECORATIVE, ROMAN, SCRIPT, SWISS, MODERN, TELETYPE, NORMAL, ITALIC, BOLD, BOLD_ITALIC) +from .font_manager import default_font_manager, FontProperties # Various maps used by str_to_font font_families = { @@ -93,10 +94,8 @@ def findfont(self): """ Returns the file name containing the font that most closely matches our font properties. """ - from .font_manager import fontManager - fp = self._make_font_props() - return str(fontManager.findfont(fp)) + return str(default_font_manager().findfont(fp)) def findfontname(self): """ Returns the name of the font that most closely matches our font @@ -109,8 +108,6 @@ def _make_font_props(self): """ Returns a font_manager.FontProperties object that encapsulates our font properties """ - from .font_manager import FontProperties - # XXX: change the weight to a numerical value if self.style == BOLD or self.style == BOLD_ITALIC: weight = "bold" diff --git a/kiva/fonttools/font_manager.py b/kiva/fonttools/font_manager.py index c6c916a27..a54a7449a 100644 --- a/kiva/fonttools/font_manager.py +++ b/kiva/fonttools/font_manager.py @@ -1390,17 +1390,74 @@ def is_opentype_cff_font(filename): return result return False - +# Global singleton of FontManager, cached at the module level. fontManager = None -_fmcache = os.path.join(get_configdir(), 'fontList.cache') + +def _get_font_cache_path(): + """ Return the file path for the font cache to be saved / loaded. + + Returns + ------- + path : str + Path to the font cache file. + """ + return os.path.join(get_configdir(), 'fontList.cache') def _rebuild(): + """ Rebuild the default font manager and cache its content. + """ 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. + + Returns + ------- + font_manager : FontManager + """ fontManager = FontManager() - pickle_dump(fontManager, _fmcache) + pickle_dump(fontManager, cache_file) logger.debug("generated new fontManager") + return fontManager + + +def _load_from_cache_or_rebuild(cache_file): + """ Load the font manager from the cache and verify it is compatible. + If the cache is not compatible, rebuild the cache and return the new + font manager. + + Parameters + ---------- + cache_file : str + Path to the cache to be created. + + Returns + ------- + font_manager : FontManager + """ + + try: + fontManager = pickle_load(cache_file) + if (not hasattr(fontManager, '_version') or + fontManager._version != FontManager.__version__): + fontManager = _new_font_manager(cache_file) + else: + fontManager.default_size = None + logger.debug("Using fontManager instance from %s", cache_file) + except Exception: + fontManager = _new_font_manager(cache_file) + + return fontManager # The experimental fontconfig-based backend. @@ -1440,18 +1497,21 @@ def findfont(prop, fontext='ttf'): return result else: - try: - fontManager = pickle_load(_fmcache) - if (not hasattr(fontManager, '_version') or - fontManager._version != FontManager.__version__): - _rebuild() - else: - fontManager.default_size = None - logger.debug("Using fontManager instance from %s", _fmcache) - except Exception: - _rebuild() def findfont(prop, **kw): - global fontManager - font = fontManager.findfont(prop, **kw) + font = default_font_manager().findfont(prop, **kw) return font + + +def default_font_manager(): + """ Return the default font manager, which is a singleton FontManager + cached in the module. + + Returns + ------- + font_manager : FontManager + """ + global fontManager + if fontManager is None: + fontManager = _load_from_cache_or_rebuild(_get_font_cache_path()) + return fontManager diff --git a/kiva/fonttools/tests/test_font.py b/kiva/fonttools/tests/test_font.py new file mode 100644 index 000000000..234f5cd52 --- /dev/null +++ b/kiva/fonttools/tests/test_font.py @@ -0,0 +1,27 @@ +""" Tests for kiva.fonttools.font +""" +import os +import unittest + +from kiva.fonttools import Font + + +class TestFont(unittest.TestCase): + + def test_find_font_empty_name(self): + # This test relies on the fact there exists some fonts on the system + # that the font manager can load. Ideally we should be able to redirect + # the path from which the font manager loads font files, then this test + # can be less fragile. + font = Font(face_name="") + font_file_path = font.findfont() + self.assertTrue(os.path.exists(font_file_path)) + + def test_find_font_some_face_name(self): + font = Font(face_name="ProbablyNotFound") + + # There will be warnings as there will be no match for the requested + # face name. + with self.assertWarns(UserWarning): + font_file_path = font.findfont() + self.assertTrue(os.path.exists(font_file_path)) diff --git a/kiva/fonttools/tests/test_font_manager.py b/kiva/fonttools/tests/test_font_manager.py index 285ae0917..37f001d52 100644 --- a/kiva/fonttools/tests/test_font_manager.py +++ b/kiva/fonttools/tests/test_font_manager.py @@ -1,11 +1,27 @@ +import contextlib +import importlib import os +import shutil +import sys +import tempfile import unittest from unittest import mock from pkg_resources import resource_filename from fontTools.ttLib import TTFont -from ..font_manager import FontEntry, createFontList, ttfFontProperty, getPropDict +from traits.etsconfig.api import ETSConfig + +from .. import font_manager as font_manager_module +from ..font_manager import ( + createFontList, + default_font_manager, + findfont, + FontEntry, + FontProperties, + FontManager, + ttfFontProperty, +) data_dir = resource_filename('kiva.fonttools.tests', 'data') @@ -93,3 +109,163 @@ def test_font_with_italic_style(self): 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.""" + + def setUp(self): + self.ttf_files = [ + os.path.abspath(os.path.join(data_dir, "TestTTF.ttf")) + ] + + temp_dir_obj = tempfile.TemporaryDirectory() + self.temp_dir = temp_dir_obj.name + self.addCleanup(temp_dir_obj.cleanup) + + def test_load_font_from_cache(self): + # Test loading fonts from cache file. + with patch_global_font_manager(None): + 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) + ) + # The global singleton is now set. + self.assertIsInstance(font_manager_module.fontManager, FontManager) + + def test_build_font_if_no_cache(self): + # Calling default_font_manager will build the font cache + # The temporary directory does not have a font cache file. + with change_ets_app_dir(self.temp_dir) as cache_file: + + with patch_global_font_manager(None), \ + patch_system_fonts(self.ttf_files): # patch for speed + font_manager_module.default_font_manager() + + # The cache file is created + self.assertTrue(os.path.exists(cache_file)) + + def test_no_import_side_effect(self): + # Importing font_manager should have no side effect of creating + # the font cache. Regression test for enthought/enable#362 + module_name = "kiva.fonttools.font_manager" + modules = sys.modules + original_module = modules.pop(module_name) + with change_ets_app_dir(self.temp_dir) as cache_path: + try: + importlib.import_module(module_name) + except Exception: + raise + else: + # A cache is not created + self.assertFalse(os.path.exists(cache_path)) + finally: + modules[module_name] = original_module + + +class TestFontManager(unittest.TestCase): + """ Test API of the font manager module.""" + + def test_default_font_manager(self): + font_manager = default_font_manager() + self.assertIsInstance(font_manager, FontManager) + + def test_findFont(self): + # Warning because there are no families defined. + with self.assertWarns(UserWarning): + font = findfont( + FontProperties( + family=[], + weight=500, + ) + ) + # The returned value is a file path + # This assumes there exists fonts on the system that can be loaded + # by the font manager while the test is run. + self.assertTrue(os.path.exists(font)) + + +@contextlib.contextmanager +def change_ets_app_dir(dirpath): + """ Temporarily change the application data directory in ETSConfig. + + Parameters + ---------- + dirpath : str + Path to be temporarily set to the ETSConfig.application_data + so that it gets used for computing the font cache file path. + + Returns + ------- + font_cache_file_path : str + Path to the font cache in the given data directory. + Returned for convenience. + """ + original_data_dir = ETSConfig.application_data + ETSConfig.application_data = dirpath + try: + yield font_manager_module._get_font_cache_path() + finally: + ETSConfig.application_data = original_data_dir + + +def patch_global_font_manager(new_value): + """ Patch the global FontManager instance at the module level. + + Parameters + ---------- + new_value : FontManager or None + Temporary value to be used as the global font manager. + """ + return mock.patch.object(font_manager_module, "fontManager", new_value) + + +@contextlib.contextmanager +def patch_font_cache(dirpath, ttf_files): + """ Patch the font cache content with the given list of FFT fonts + and application data directory. + + Parameters + ---------- + dirpath : str + Path to be temporarily set to the ETSConfig.application_data + so that it gets used for computing the font cache file path. + ttf_files : list of str + List of file paths to TTF files. + + Returns + ------- + font_cache_file_path : str + Path to the font cache in the given data directory. + Returned for convenience. + """ + with change_ets_app_dir(dirpath) as cache_file: + with patch_system_fonts(ttf_files): + font_manager_module._new_font_manager(cache_file) + yield cache_file + + +def patch_system_fonts(ttf_files): + """ Patch findSystemFonts 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. + + Parameters + ---------- + ttf_files : list of str + List of file paths for TTF fonts + """ + + def fake_find_system_fonts(fontpaths=None, fontext='ttf'): + if fontext == "ttf": + return ttf_files + return [] + + return mock.patch( + "kiva.fonttools.font_manager.findSystemFonts", + fake_find_system_fonts, + ) diff --git a/kiva/trait_defs/ui/wx/kiva_font_editor.py b/kiva/trait_defs/ui/wx/kiva_font_editor.py index 33ec416be..5bd0de7df 100644 --- a/kiva/trait_defs/ui/wx/kiva_font_editor.py +++ b/kiva/trait_defs/ui/wx/kiva_font_editor.py @@ -27,7 +27,7 @@ from traitsui.wx.font_editor \ import ToolkitEditorFactory as EditorFactory -from kiva.fonttools.font_manager import fontManager +from kiva.fonttools.font_manager import default_font_manager #------------------------------------------------------------------------------- @@ -119,7 +119,8 @@ def str_font ( self, font ): def all_facenames(self): """ Returns a list of all available font typeface names. """ - return sorted({f.name for f in fontManager.ttflist}) + font_manager = default_font_manager() + return sorted({f.name for f in font_manager.ttflist}) def KivaFontEditor(*args, **traits): diff --git a/kiva/trait_defs/ui/wx/tests/test_kiva_font_editor.py b/kiva/trait_defs/ui/wx/tests/test_kiva_font_editor.py new file mode 100644 index 000000000..934273560 --- /dev/null +++ b/kiva/trait_defs/ui/wx/tests/test_kiva_font_editor.py @@ -0,0 +1,19 @@ +""" Tests for ui.wx.kiva_font_editor +""" + +import unittest + +from kiva.tests._testing import is_wx, skip_if_not_wx + +if is_wx(): + from kiva.trait_defs.ui.wx import kiva_font_editor + + +@skip_if_not_wx +class TestFacename(unittest.TestCase): + + def test_all_facenames(self): + # Test loading of available face names does not fail. + # The available face names depend on the system + editor_factory = kiva_font_editor.KivaFontEditor() + self.assertGreaterEqual(len(editor_factory.all_facenames()), 0)