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
119 changes: 119 additions & 0 deletions kiva/fonttools/_database.py
Original file line number Diff line number Diff line change
@@ -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"<FontEntry '{self.family}' ({fname}[{self.face_index}]) "
f"{self.style} {self.variant} {self.weight} {self.stretch}>"
)


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
Comment thread
jwiggins marked this conversation as resolved.

# 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
48 changes: 18 additions & 30 deletions kiva/fonttools/_scan_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,27 @@
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__)
# 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
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
Expand All @@ -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"<FontEntry '{self.family}' ({fname}[{self.face_index}]) "
f"{self.style} {self.variant} {self.weight} {self.stretch}>"
)
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)


# ----------------------------------------------------------------------------
Expand Down
69 changes: 39 additions & 30 deletions kiva/fonttools/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is basically equivalent to the previous value. Bitstream Vera and Helvetica are both sans-serif families. But since not every system may have them, we can use the generic family name for the default and get good results thanks to the preferred_fonts dictionary listing several options for 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]
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Comment thread
jwiggins marked this conversation as resolved.
best_font = None

for font in fontlist:
fname = font.fname
if (directory is not None
and os.path.commonprefix([fname, directory]) != directory):
continue
Comment thread
jwiggins marked this conversation as resolved.
# Matching family should have highest priority, so it is multiplied
# by 10.0
score = (
Expand All @@ -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,
Expand All @@ -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",
Expand Down
4 changes: 1 addition & 3 deletions kiva/fonttools/tests/test_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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")
Expand Down
Loading