Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions kiva/fonttools/font.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor Author

@kitchoi kitchoi Dec 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This undoes #368 because the import side effect being avoided is now removed. While this move is strictly speaking unnecessary, it reduces cognitive load for developers: Any imports that are not at the top always beg the question "why are they not at the top".


# Various maps used by str_to_font
font_families = {
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
90 changes: 75 additions & 15 deletions kiva/fonttools/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment on lines 1501 to 1503
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't appear to be used anywhere outside of the tests. I checked chaco and two proprietary projects...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #497
(Part of me wonders if this needs a deprecation warning or if we are confident no one uses this enough it can just die.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I noticed some confusion between findfont and findFont in that issue, but the matter is settled here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well spotted. Will fix the spelling on the issue.



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
27 changes: 27 additions & 0 deletions kiva/fonttools/tests/test_font.py
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +20 to +27
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not excited that this is the established behavior but it's good to have it covered with a test before our changes get any more invasive.

178 changes: 177 additions & 1 deletion kiva/fonttools/tests/test_font_manager.py
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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,
)
5 changes: 3 additions & 2 deletions kiva/trait_defs/ui/wx/kiva_font_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


#-------------------------------------------------------------------------------
Expand Down Expand Up @@ -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):
Expand Down
Loading