diff --git a/kiva/fonttools/_constants.py b/kiva/fonttools/_constants.py index f744cb430..161f8819b 100644 --- a/kiva/fonttools/_constants.py +++ b/kiva/fonttools/_constants.py @@ -8,6 +8,31 @@ # # Thanks for using Enthought open source! +font_scalings = { + "xx-small": 0.579, + "x-small": 0.694, + "small": 0.833, + "medium": 1.0, + "large": 1.200, + "x-large": 1.440, + "xx-large": 1.728, + "larger": 1.2, + "smaller": 0.833, + None: 1.0, +} + +stretch_dict = { + "ultra-condensed": 100, + "extra-condensed": 200, + "condensed": 300, + "semi-condensed": 400, + "normal": 500, + "semi-expanded": 600, + "expanded": 700, + "extra-expanded": 800, + "ultra-expanded": 900, +} + weight_dict = { "ultralight": 100, "light": 200, diff --git a/kiva/fonttools/_font_properties.py b/kiva/fonttools/_font_properties.py new file mode 100644 index 000000000..44b9a5c6e --- /dev/null +++ b/kiva/fonttools/_font_properties.py @@ -0,0 +1,262 @@ +# (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! +from fontTools.afmLib import AFM +from fontTools.ttLib import TTFont + +from kiva.fonttools._constants import font_scalings, stretch_dict, weight_dict +from kiva.fonttools._util import get_ttf_prop_dict +from kiva.fonttools.font_manager import default_font_manager + + +class FontProperties(object): + """ A class for storing and manipulating font properties. + + The font properties are those described in the `W3C Cascading + Style Sheet, Level 1 + `_ font + specification. The six properties are: + + - family: A list of font names in decreasing order of priority. + The items may include a generic font family name, either + 'serif', 'sans-serif', 'cursive', 'fantasy', or 'monospace'. + + - style: Either 'normal', 'italic' or 'oblique'. + + - variant: Either 'normal' or 'small-caps'. + + - stretch: A numeric value in the range 0-1000 or one of + 'ultra-condensed', 'extra-condensed', 'condensed', + 'semi-condensed', 'normal', 'semi-expanded', 'expanded', + 'extra-expanded' or 'ultra-expanded' + + - weight: A numeric value in the range 0-1000 or one of + 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', + 'roman', 'semibold', 'demibold', 'demi', 'bold', 'heavy', + 'extra bold', 'black' + + - size: Either an relative value of 'xx-small', 'x-small', + 'small', 'medium', 'large', 'x-large', 'xx-large' or an + absolute font size, e.g. 12 + + Alternatively, a font may be specified using an absolute path to a + .ttf file, by using the *fname* kwarg. + + The preferred usage of font sizes is to use the relative values, + e.g. 'large', instead of absolute font sizes, e.g. 12. This + approach allows all text sizes to be made larger or smaller based + on the font manager's default font size. + """ + def __init__(self, family=None, style=None, variant=None, weight=None, + stretch=None, size=None, fname=None, _init=None): + # if fname is set, it's a hardcoded filename to use + # _init is used only by copy() + + self._family = None + self._slant = None + self._variant = None + self._weight = None + self._stretch = None + self._size = None + self._file = None + + # This is used only by copy() + if _init is not None: + self.__dict__.update(_init.__dict__) + return + + self.set_family(family) + self.set_style(style) + self.set_variant(variant) + self.set_weight(weight) + self.set_stretch(stretch) + self.set_file(fname) + self.set_size(size) + + def __hash__(self): + lst = [(k, getattr(self, "get" + k)()) for k in sorted(self.__dict__)] + return hash(repr(lst)) + + def __str__(self): + attrs = ( + self._family, self._slant, self._variant, self._weight, + self._stretch, self._size, + ) + return str(attrs) + + def get_family(self): + """ Return a list of font names that comprise the font family. + """ + return self._family + + def get_name(self): + """ Return the name of the font that best matches the font properties. + """ + spec = default_font_manager().findfont(self) + if spec.filename.endswith(".afm"): + return AFM().FamilyName + + prop_dict = get_ttf_prop_dict( + TTFont(spec.filename, fontNumber=spec.face_index) + ) + return prop_dict["family"] + + def get_style(self): + """ Return the font style. + + Values are: 'normal', 'italic' or 'oblique'. + """ + return self._slant + + get_slant = get_style + + def get_variant(self): + """ Return the font variant. + + Values are: 'normal' or 'small-caps'. + """ + return self._variant + + def get_weight(self): + """ Set the font weight. + + Options are: A numeric value in the range 0-1000 or one of 'light', + 'normal', 'regular', 'book', 'medium', 'roman', 'semibold', 'demibold', + 'demi', 'bold', 'heavy', 'extra bold', 'black' + """ + return self._weight + + def get_stretch(self): + """ Return the font stretch or width. + + Options are: 'ultra-condensed', 'extra-condensed', 'condensed', + 'semi-condensed', 'normal', 'semi-expanded', 'expanded', + 'extra-expanded', 'ultra-expanded'. + """ + return self._stretch + + def get_size(self): + """ Return the font size. + """ + return self._size + + def get_size_in_points(self): + if self._size is not None: + try: + return float(self._size) + except ValueError: + pass + default_size = default_font_manager().get_default_size() + return default_size * font_scalings.get(self._size) + + def get_file(self): + """ Return the filename of the associated font. + """ + return self._file + + def set_family(self, family): + """ Change the font family. + + May be either an alias (generic name is CSS parlance), such as: + 'serif', 'sans-serif', 'cursive', 'fantasy', or 'monospace', or + a real font name. + """ + if family is None: + self._family = None + else: + if isinstance(family, bytes): + family = [family.decode("utf8")] + elif isinstance(family, str): + family = [family] + self._family = family + + set_name = set_family + + def set_style(self, style): + """ Set the font style. + + Values are: 'normal', 'italic' or 'oblique'. + """ + if style not in ("normal", "italic", "oblique", None): + raise ValueError("style must be normal, italic or oblique") + self._slant = style + + set_slant = set_style + + def set_variant(self, variant): + """ Set the font variant. + + Values are: 'normal' or 'small-caps'. + """ + if variant not in ("normal", "small-caps", None): + raise ValueError("variant must be normal or small-caps") + self._variant = variant + + def set_weight(self, weight): + """ Set the font weight. + + May be either a numeric value in the range 0-1000 or one of + 'ultralight', 'light', 'normal', 'regular', 'book', 'medium', 'roman', + 'semibold', 'demibold', 'demi', 'bold', 'heavy', 'extra bold', 'black'. + """ + if weight is not None: + try: + weight = int(weight) + if weight < 0 or weight > 1000: + raise ValueError() + except ValueError: + if weight not in weight_dict: + raise ValueError("weight is invalid") + self._weight = weight + + def set_stretch(self, stretch): + """ Set the font stretch or width. + + Options are: 'ultra-condensed', 'extra-condensed', 'condensed', + 'semi-condensed', 'normal', 'semi-expanded', 'expanded', + 'extra-expanded' or 'ultra-expanded', or a numeric value in the + range 0-1000. + """ + if stretch is not None: + try: + stretch = int(stretch) + if stretch < 0 or stretch > 1000: + raise ValueError() + except ValueError: + if stretch not in stretch_dict: + raise ValueError("stretch is invalid") + else: + stretch = 500 + self._stretch = stretch + + def set_size(self, size): + """ Set the font size. + + Either an relative value of 'xx-small', 'x-small', 'small', 'medium', + 'large', 'x-large', 'xx-large' or an absolute font size, e.g. 12. + """ + if size is not None: + try: + size = float(size) + except ValueError: + if size is not None and size not in font_scalings: + raise ValueError("size is invalid") + self._size = size + + def set_file(self, file): + """ Set the filename of the fontfile to use. + + In this case, all other properties will be ignored. + """ + self._file = file + + def copy(self): + """ Return a deep copy of self + """ + return FontProperties(_init=self) diff --git a/kiva/fonttools/tests/test_font_properties.py b/kiva/fonttools/tests/test_font_properties.py new file mode 100644 index 000000000..bca1294a0 --- /dev/null +++ b/kiva/fonttools/tests/test_font_properties.py @@ -0,0 +1,117 @@ +# (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 unittest + +from kiva.fonttools._font_properties import FontProperties + + +class TestFontProperties(unittest.TestCase): + def setUp(self): + self.fp = FontProperties( + family=["serif"], + style="italic", + variant="small-caps", + weight="bold", + stretch="ultra-condensed", + size=18, + ) + + def test_copying(self): + fp_copy = self.fp.copy() + + for attr in ("_family", "_slant", "_variant", "_weight", + "_stretch", "_size", "_file"): + self.assertEqual(getattr(self.fp, attr), getattr(fp_copy, attr)) + + # Compare the strings too + self.assertEqual(str(self.fp), str(fp_copy)) + # And the hashes + self.assertEqual(hash(self.fp), hash(fp_copy)) + + def test_getters(self): + self.assertListEqual(self.fp.get_family(), ["serif"]) + self.assertIsNone(self.fp.get_file()) + self.assertEqual(self.fp.get_size(), 18) + self.assertEqual(self.fp.get_slant(), "italic") + self.assertEqual(self.fp.get_stretch(), "ultra-condensed") + self.assertEqual(self.fp.get_style(), "italic") + self.assertEqual(self.fp.get_variant(), "small-caps") + self.assertEqual(self.fp.get_weight(), "bold") + + def test_setters(self): + # Family is always converted to a list + self.fp.set_family("cursive") + self.assertListEqual(self.fp.get_family(), ["cursive"]) + # Family can be unset + self.fp.set_family(None) + self.assertIsNone(self.fp.get_family()) + # Family can be a bytestring + self.fp.set_family("Arial".encode("utf8")) + self.assertListEqual(self.fp.get_family(), ["Arial"]) + + filename = "not a real file.ttf" + self.fp.set_file(filename) + self.assertEqual(self.fp.get_file(), filename) + + # set_name is a synonym for set_family + self.fp.set_name("Verdana") + self.assertListEqual(self.fp.get_family(), ["Verdana"]) + + # set_style has requirements + with self.assertRaises(ValueError): + self.fp.set_style("post-modern") + + self.fp.set_style("oblique") + self.assertEqual(self.fp.get_style(), "oblique") + + # set_variant has requirements + with self.assertRaises(ValueError): + self.fp.set_variant("rad") + + self.fp.set_variant("normal") + self.assertEqual(self.fp.get_variant(), "normal") + + # set_weight takes many input types + with self.assertRaises(ValueError): + self.fp.set_weight("superduperdark") + with self.assertRaises(ValueError): + self.fp.set_weight(-42) + with self.assertRaises(ValueError): + self.fp.set_weight(3000) + + self.fp.set_weight(500) + self.assertEqual(self.fp.get_weight(), 500) + self.fp.set_weight("bold") + self.assertEqual(self.fp.get_weight(), "bold") + + # set_stretch has requirements + with self.assertRaises(ValueError): + self.fp.set_stretch("condensed-matter") + with self.assertRaises(ValueError): + self.fp.set_stretch(-42) + with self.assertRaises(ValueError): + self.fp.set_stretch(3000) + + self.fp.set_stretch("semi-condensed") + self.assertEqual(self.fp.get_stretch(), "semi-condensed") + self.fp.set_stretch(300) + self.assertEqual(self.fp.get_stretch(), 300) + # default + self.fp.set_stretch(None) + self.assertEqual(self.fp.get_stretch(), 500) + + # set_size has requirements + with self.assertRaises(ValueError): + self.fp.set_size("itsy-bitsy") + + self.fp.set_size("medium") + self.assertEqual(self.fp.get_size(), "medium") + self.fp.set_size(36) + self.assertEqual(self.fp.get_size(), 36.0)