Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
enable / fonttools / font_manager.py
Size: Mime:
# (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.
####################

A module for finding, managing, and using fonts across platforms.

The design is based on the `W3C Cascading Style Sheet, Level 1 (CSS1)
font specification <http://www.w3.org/TR/1998/REC-CSS2-19980512/>`_.
Future versions may implement the Level 2 or 2.1 specifications.

Authors   : John Hunter <jdhunter@ace.bsd.uchicago.edu>
            Paul Barrett <Barrett@STScI.Edu>
            Michael Droettboom <mdroe@STScI.edu>
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 errno
import logging
import os
import pickle
import tempfile
import warnings

from traits.etsconfig.api import ETSConfig

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,
    score_weight
)

logger = logging.getLogger(__name__)

# Global singleton of FontManager, cached at the module level.
fontManager = None


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


class FontManager:
    """ The :class:`FontManager` singleton instance is created with a list of
    TrueType fonts based on the font properties: name, style, variant, weight,
    stretch, and size. The :meth:`findfont` method does a nearest neighbor
    search to find the font that most closely matches the specification. If no
    good enough match is found, a default font is returned.
    """
    # 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__ = 12

    def __init__(self):
        self._version = self.__version__

        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]
                if ttfpath.find(";") >= 0:  # win32 style
                    paths.extend(ttfpath.split(";"))
                elif ttfpath.find(":") >= 0:  # unix style
                    paths.extend(ttfpath.split(":"))
                else:
                    paths.append(ttfpath)

        logger.debug("font search path %s", str(paths))

        # 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.default_font["ttf"] = fname
                break
        else:
            # use anything
            if len(ttffiles) > 0:
                self.default_font["ttf"] = ttffiles[0]
            else:  # no available fonts, use a simple default
                warnings.warn(
                    "Unable to find any available fonts. Using the very simple"
                    " Montserrat-Regular as a default which may not support"
                    " all desired functionality. If needed please add a font"
                    " with ``kiva.api.add_application_font``",
                    stacklevel=2
                )
                import pkg_resources
                data_dir = pkg_resources.resource_filename(
                    "kiva.fonttools", "data"
                )
                path = os.path.join(data_dir, " Montserrat-Regular.ttf")
                self.default_font["ttf"] = path

        self.ttf_db = create_font_database(ttffiles, fontext="ttf")

        # Load AFM fonts and create font database.
        afmfiles = scan_system_fonts(
            paths, fontext="afm"
        ) + scan_system_fonts(fontext="afm")
        self.afm_db = create_font_database(afmfiles, fontext="afm")
        self.default_font["afm"] = None

        self._ttf_lookup_cache = {}
        self._afm_lookup_cache = {}
        self._ttf_fallback_cache = {}
        self._afm_fallback_cache = {}

    def update_fonts(self, paths):
        """ Update the font lists with new font files.

        The specified ``paths`` will be searched for valid font files and those
        files will have their fonts added to internal collections searched by
        :meth:`findfont`.

        Parameters
        ----------
        filenames : list of str
            A list of font file paths or directory paths.
        """
        afm_paths = scan_user_fonts(paths, fontext="afm")
        ttf_paths = scan_user_fonts(paths, fontext="ttf")

        update_font_database(self.afm_db, afm_paths, fontext="afm")
        update_font_database(self.ttf_db, ttf_paths, fontext="ttf")

    def find_fallback(self, query, language, fontext="ttf"):
        """ Search the font list for a font which most closely matches
        the :class:`FontQuery` *query*, and has support for ``language``.
        """
        if fontext == "afm":
            font_cache = self._afm_fallback_cache
            font_db = self.afm_db
        else:
            font_cache = self._ttf_fallback_cache
            font_db = self.ttf_db

        key = hash(language + str(query))
        cached = font_cache.get(key)
        if cached:
            return cached

        # Narrow the search to a single language
        fontlist = font_db.fonts_for_language(language)

        best_score = 3.0
        best_font = None
        for font in fontlist:
            score = (
                score_family(query.get_family(), font.family)
                + score_style(query.get_style(), font.style)
                + score_weight(query.get_weight(), font.weight)
            )
            # Lowest score wins
            if score < best_score:
                best_score = score
                best_font = font
            # Exact matches stop the search
            if score == 0:
                break

        # If no suitable font is found, return None
        if best_font is None or best_score >= 3.0:
            msg = "find_fallback: Font for {} in language {} not found"
            warnings.warn(msg.format(query, language))
            return None

        result = _FontSpec(
            best_font.fname,
            best_font.family,
            best_font.face_index,
        )
        font_cache[key] = result
        return result

    def findfont(self, query, fontext="ttf", directory=None,
                 fallback_to_default=True, rebuild_if_missing=True):
        """ Search the font list for the font that most closely matches
        the :class:`FontQuery` *query*.

        :meth:`findfont` performs a nearest neighbor search.  Each
        font is given a similarity score to the target font
        properties.  The first font with the highest score is
        returned.  If no matches below a certain threshold are found,
        the default font (usually Vera Sans) is returned.

        `directory`, is specified, will only return fonts from the
        given directory (or subdirectory of that directory).

        The result is cached, so subsequent lookups don't have to
        perform the O(n) nearest neighbor search.

        If `fallback_to_default` is True, will fallback to the default
        font family (usually "Bitstream Vera Sans" or "Helvetica") if
        the first lookup hard-fails.

        See the `W3C Cascading Style Sheet, Level 1
        <http://www.w3.org/TR/1998/REC-CSS2-19980512/>`_ documentation
        for a description of the font finding algorithm.
        """
        from kiva.fonttools._query import FontQuery

        if not isinstance(query, FontQuery):
            query = FontQuery(query)

        fname = query.get_file()
        if fname is not None:
            logger.debug("findfont returning %s", fname)
            # It's not at all clear where a `FontQuery` instance with
            # `fname` already set would come from. Assume face_index == 0.
            return _FontSpec(fname, query.family[0])

        if fontext == "afm":
            font_cache = self._afm_lookup_cache
            font_db = self.afm_db
        else:
            font_cache = self._ttf_lookup_cache
            font_db = self.ttf_db

        if directory is None:
            cached = font_cache.get(hash(query))
            if cached:
                return cached

        # 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(query.get_family())

        best_score = 20.0
        best_font = None
        for font in fontlist:
            # Matching family should have highest priority, so it is multiplied
            # by 10.0
            score = (
                score_family(query.get_family(), font.family) * 10.0
                + score_style(query.get_style(), font.style)
                + score_variant(query.get_variant(), font.variant)
                + score_weight(query.get_weight(), font.weight)
                + score_stretch(query.get_stretch(), font.stretch)
                + score_size(query.get_size(), font.size)
            )
            # Lowest score wins
            if score < best_score:
                best_score = score
                best_font = font
            # Exact matches stop the search
            if score == 0:
                break

        if best_font is None or best_score >= 10.0:
            if fallback_to_default:
                warnings.warn(
                    "findfont: Font family %s not found. Falling back to %s"
                    % (query.get_family(), self.default_family)
                )
                default_query = query.copy()
                default_query.set_family(self.default_family)
                return self.findfont(
                    default_query, fontext, directory,
                    fallback_to_default=False,
                )
            else:
                # This is a hard fail -- we can't find anything reasonable,
                # so just return the vera.ttf
                warnings.warn(
                    "findfont: Could not match %s. Returning %s"
                    % (query, self.default_font[fontext]),
                    UserWarning,
                )
                # Assume this is never a .ttc font, so 0 is ok for face index.
                result = _FontSpec(self.default_font[fontext], "Default")
        else:
            logger.debug(
                "findfont: Matching %s to %s (%s[%d]) with score of %f",
                query,
                best_font.family,
                best_font.fname,
                best_font.face_index,
                best_score,
            )
            result = _FontSpec(
                best_font.fname,
                best_font.family,
                best_font.face_index,
            )

        if not os.path.isfile(result.filename):
            if rebuild_if_missing:
                logger.debug(
                    "findfont: Found a missing font file.  Rebuilding cache."
                )
                _rebuild()
                return default_font_manager().findfont(
                    query, fontext, directory,
                    fallback_to_default=True,
                    rebuild_if_missing=False,
                )
            else:
                raise ValueError("No valid font could be found")

        if directory is None:
            font_cache[hash(query)] = result
        return result


class _FontSpec(object):
    """ An object to represent the return value of findfont() and
    find_fallback().
    """
    def __init__(self, filename, family, face_index=0):
        self.filename = str(filename)
        self.family = family
        self.face_index = face_index

    def __fspath__(self):
        """ Implement the os.PathLike abstract interface.
        """
        return self.filename

    def __repr__(self):
        args = f"{self.filename}, {self.family}, face_index={self.face_index}"
        return f"_FontSpec({args})"


# ---------------------------------------------------------------------------
# Utilities

def _get_config_dir():
    """ Return the string representing the configuration dir.
    """
    path = os.path.join(ETSConfig.application_data, "kiva")
    try:
        os.makedirs(path)
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise

    if not _is_writable_dir(path):
        raise IOError(f"Configuration directory {path} must be writable")

    return path


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_config_dir(), "fontList.cache")


def _is_writable_dir(p):
    """ p is a string pointing to a putative writable dir -- return True p
    is such a string, else False
    """
    if not isinstance(p, str):
        return False

    try:
        with tempfile.TemporaryFile(dir=p) as fp:
            fp.write(b"kiva.test")
        return True
    except OSError:
        pass
    return False


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:
            logger.debug("Using fontManager instance from %s", cache_file)
    except Exception:
        fontManager = _new_font_manager(cache_file)

    return fontManager


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, cache_file)
    logger.debug("generated new fontManager")
    return fontManager


def _pickle_dump(data, filename):
    """
    Equivalent to pickle.dump(data, open(filename, 'wb'))
    but closes the file to prevent filehandle leakage.
    """
    fh = open(filename, "wb")
    try:
        pickle.dump(data, fh)
    finally:
        fh.close()


def _pickle_load(filename):
    """
    Equivalent to pickle.load(open(filename, 'rb'))
    but closes the file to prevent filehandle leakage.
    """
    fh = open(filename, "rb")
    try:
        data = pickle.load(fh)
    finally:
        fh.close()
    return data


def _rebuild():
    """ Rebuild the default font manager and cache its content.
    """
    global fontManager
    fontManager = _new_font_manager(_get_font_cache_path())