diff --git a/kiva/fonttools/_scan_parse.py b/kiva/fonttools/_scan_parse.py index 18c81f1c6..6f2bb004e 100644 --- a/kiva/fonttools/_scan_parse.py +++ b/kiva/fonttools/_scan_parse.py @@ -58,14 +58,16 @@ class FontEntry(object): """ A class for storing Font properties. It is used when populating the font lookup dictionary. """ - def __init__(self, fname="", name="", style="normal", variant="normal", - weight="normal", stretch="normal", size="medium"): + def __init__(self, fname="", family="", style="normal", variant="normal", + weight="normal", stretch="normal", size="medium", + face_index=0): self.fname = fname - self.name = name + 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: @@ -74,8 +76,8 @@ def __init__(self, fname="", name="", style="normal", variant="normal", def __repr__(self): fname = os.path.basename(self.fname) return ( - f"" + f"" ) @@ -120,8 +122,8 @@ def _build_ttf_entries(fpath): if ext.lower() == ".ttc": collection = TTCollection(fp) try: - for font in collection.fonts: - entries.append(_ttf_font_property(fpath, font)) + for idx, font in enumerate(collection.fonts): + entries.append(_ttf_font_property(fpath, font, idx)) except Exception: logger.error(_FONT_ENTRY_ERR_MSG, fpath, exc_info=True) else: @@ -144,22 +146,22 @@ def _afm_font_property(fontpath, font): *font* is a class:`AFM` instance. """ - name = font.get_familyname() + family = font.get_familyname() fontname = font.get_fontname().lower() # Styles are: italic, oblique, and normal (default) - if font.get_angle() != 0 or name.lower().find("italic") >= 0: + if font.get_angle() != 0 or family.lower().find("italic") >= 0: style = "italic" - elif name.lower().find("oblique") >= 0: + elif family.lower().find("oblique") >= 0: style = "oblique" else: style = "normal" # Variants are: small-caps and normal (default) - # NOTE: Not sure how many fonts actually have these strings in their name + # NOTE: Not sure how many fonts actually have these strings in their family variant = "normal" for value in ("capitals", "small-caps"): - if value in name.lower(): + if value in family.lower(): variant = "small-caps" break @@ -194,34 +196,40 @@ def _afm_font_property(fontpath, font): # All AFM fonts are apparently scalable. size = "scalable" - return FontEntry(fontpath, name, style, variant, weight, stretch, size) + return FontEntry(fontpath, family, style, variant, weight, stretch, size) -def _ttf_font_property(fpath, font): +def _ttf_font_property(fpath, font, face_index=0): """ A function for populating the :class:`FontEntry` by extracting information from the TrueType font file. *font* is a :class:`TTFont` instance. """ props = get_ttf_prop_dict(font) - name = props.get("name") - if name is None: - raise KeyError("No name could be found for: {}".format(fpath)) + family = props.get("family") + if family is None: + raise KeyError("No family could be found for: {}".format(fpath)) + + # Some properties + full_name = props.get("full_name", "").lower() + style_prop = props.get("style", "").lower() + if style_prop == "": + # For backwards compatibility with previous parsing behavior + style_prop = full_name # Styles are: italic, oblique, and normal (default) - sfnt4 = props.get("sfnt4", "").lower() - if sfnt4.find("oblique") >= 0: + if style_prop.find("oblique") >= 0: style = "oblique" - elif sfnt4.find("italic") >= 0: + elif style_prop.find("italic") >= 0: style = "italic" else: style = "normal" # Variants are: small-caps and normal (default) - # NOTE: Not sure how many fonts actually have these strings in their name + # NOTE: Not sure how many fonts actually have these strings in their family variant = "normal" - for value in ("capitals", "small-caps"): - if value in name.lower(): + for value in ("capitals", "small-caps", "smallcaps"): + if value in family.lower(): variant = "small-caps" break @@ -230,7 +238,7 @@ def _ttf_font_property(fpath, font): # lighter and bolder are also allowed. weight = None for w in weight_dict.keys(): - if sfnt4.find(w) >= 0: + if style_prop.find(w) >= 0: weight = w break if not weight: @@ -243,13 +251,13 @@ def _ttf_font_property(fpath, font): # and ultra-expanded. # Relative stretches are: wider, narrower # Child value is: inherit - if sfnt4.find("demi cond") >= 0: + if full_name.find("demi cond") >= 0: stretch = "semi-condensed" - elif (sfnt4.find("narrow") >= 0 - or sfnt4.find("condensed") >= 0 - or sfnt4.find("cond") >= 0): + elif (full_name.find("narrow") >= 0 + or full_name.find("condensed") >= 0 + or full_name.find("cond") >= 0): stretch = "condensed" - elif sfnt4.find("wide") >= 0 or sfnt4.find("expanded") >= 0: + elif full_name.find("wide") >= 0 or full_name.find("expanded") >= 0: stretch = "expanded" else: stretch = "normal" @@ -263,4 +271,13 @@ def _ttf_font_property(fpath, font): # !!!! Incomplete size = "scalable" - return FontEntry(fpath, name, style, variant, weight, stretch, size) + return FontEntry( + fname=fpath, + family=family, + style=style, + variant=variant, + weight=weight, + stretch=stretch, + size=size, + face_index=face_index, + ) diff --git a/kiva/fonttools/_util.py b/kiva/fonttools/_util.py index b07e151c2..5b5850a65 100644 --- a/kiva/fonttools/_util.py +++ b/kiva/fonttools/_util.py @@ -9,23 +9,67 @@ # Thanks for using Enthought open source! from kiva.fonttools._constants import weight_dict +# Unicode & Apple +_plat_ids = (0, 1) +_english_id = 0 +# MS +# https://docs.microsoft.com/en-us/typography/opentype/spec/name#windows-language-ids # noqa: E501 +_ms_plat_id = 3 +_ms_english_ids = { + 0x0C09: "Australia", + 0x2809: "Belize", + 0x1009: "Canada", + 0x2409: "Caribbean", + 0x4009: "India", + 0x1809: "Ireland", + 0x2009: "Jamaica", + 0x4409: "Malaysia", + 0x1409: "New Zealand", + 0x3409: "Republic of the Philippines", + 0x4809: "Singapore", + 0x1C09: "South Africa", + 0x2C09: "Trinidad and Tobago", + 0x0809: "United Kingdom", + 0x0409: "United States", + 0x3009: "Zimbabwe", +} +# TrueType 'name' table IDs +# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html # noqa: E501 +_name_ids = { + 0: "copyright", + 1: "family", + 2: "style", + 3: "unique_subfamily_id", + 4: "full_name", + 5: "version", + 6: "postscript_name", +} + def get_ttf_prop_dict(font): - """ Return the property dictionary from a :class:`TTFont` instance. + """ Parse the 'name' table of a :class:`TTFont` instance. """ - n = font["name"] propdict = {} - for prop in n.names: - try: - if "name" in propdict and "sfnt4" in propdict: - break - elif prop.nameID == 1 and "name" not in propdict: - propdict["name"] = _decode_prop(prop.string) - elif prop.nameID == 4 and "sfnt4" not in propdict: - propdict["sfnt4"] = _decode_prop(prop.string) - except UnicodeDecodeError: + table = font["name"] + for rec in table.names: + # We only care about records in English + plat, lang = rec.platformID, rec.langID + if not ((plat in _plat_ids and lang == _english_id) + or (plat == _ms_plat_id and lang in _ms_english_ids)): + continue + # And of those, just the ones we have names for + if rec.nameID not in _name_ids: continue + # Convert the nameID to a nice string + key = _name_ids[rec.nameID] + # Skip duplicate records + if key in propdict: + continue + + # Use the NameRecord's toStr() method instead of ad-hoc decoding + propdict[key] = rec.toStr() + return propdict @@ -45,19 +89,3 @@ def weight_as_number(weight): else: raise ValueError("weight not a valid integer") return weight - - -def _decode_prop(prop): - """ Decode a prop string. - - Parameters - ---------- - prop : bytestring - - Returns - ------- - string - """ - # Adapted from: https://gist.github.com/pklaus/dce37521579513c574d0 - encoding = "utf-16-be" if b"\x00" in prop else "utf-8" - return prop.decode(encoding) diff --git a/kiva/fonttools/tests/test_scan_parse.py b/kiva/fonttools/tests/test_scan_parse.py index 474cbc5c4..b932ccf24 100644 --- a/kiva/fonttools/tests/test_scan_parse.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -42,8 +42,9 @@ def test_fontlist_from_ttc(self): # Then self.assertEqual(len(fontlist), 2) - for fontprop in fontlist: + for idx, fontprop in enumerate(fontlist): self.assertIsInstance(fontprop, FontEntry) + self.assertEqual(fontprop.face_index, idx) @mock.patch("kiva.fonttools._scan_parse._ttf_font_property", side_effect=ValueError) @@ -103,7 +104,7 @@ def get_weight(self): # Given fake_font = FakeAFM("TestyFont", "Testy", 0, "Bold") - exp_name = "Testy" + exp_family = "Testy" exp_style = "normal" exp_variant = "normal" exp_weight = 700 @@ -112,7 +113,7 @@ def get_weight(self): # When entry = _afm_font_property(fake_path, fake_font) # Then - self.assertEqual(entry.name, exp_name) + self.assertEqual(entry.family, exp_family) self.assertEqual(entry.style, exp_style) self.assertEqual(entry.variant, exp_variant) self.assertEqual(entry.weight, exp_weight) @@ -158,7 +159,7 @@ class TestTTFFontEntry(unittest.TestCase): def test_font(self): # Given test_font = os.path.join(data_dir, "TestTTF.ttf") - exp_name = "Test TTF" + exp_family = "Test TTF" exp_style = "normal" exp_variant = "normal" exp_weight = 400 @@ -169,7 +170,7 @@ def test_font(self): entry = _ttf_font_property(test_font, TTFont(test_font)) # Then - self.assertEqual(entry.name, exp_name) + self.assertEqual(entry.family, exp_family) self.assertEqual(entry.style, exp_style) self.assertEqual(entry.variant, exp_variant) self.assertEqual(entry.weight, exp_weight) @@ -182,7 +183,7 @@ def test_font_with_italic_style(self): """ # Given test_font = os.path.join(data_dir, "TestTTF Italic.ttf") - exp_name = "Test TTF" + exp_family = "Test TTF" exp_style = "italic" exp_variant = "normal" exp_weight = 400 @@ -193,7 +194,7 @@ def test_font_with_italic_style(self): entry = _ttf_font_property(test_font, TTFont(test_font)) # Then - self.assertEqual(entry.name, exp_name) + self.assertEqual(entry.family, exp_family) self.assertEqual(entry.style, exp_style) self.assertEqual(entry.variant, exp_variant) self.assertEqual(entry.weight, exp_weight) @@ -212,14 +213,16 @@ def test_nameless_font(self): _ttf_font_property(test_font, None) def test_property_branches(self): + # These tests mock `get_ttf_prop_dict` in order to test the various + # branches of `_ttf_font_property`. target = "kiva.fonttools._scan_parse.get_ttf_prop_dict" test_font = os.path.join(data_dir, "TestTTF.ttf") # Given - exp_name = "TestyFont Bold Capitals" prop_dict = { - "name": exp_name, - "sfnt4": exp_name, + "family": "TestyFont Capitals", + "style": "Bold", + "full_name": "TestyFont Capitals Bold", } # When with mock.patch(target, return_value=prop_dict): @@ -229,10 +232,10 @@ def test_property_branches(self): self.assertEqual(entry.variant, "small-caps") # Given - exp_name = "TestyFont Bold Oblique" prop_dict = { - "name": exp_name, - "sfnt4": exp_name, + "family": "TestyFont", + "style": "Bold Oblique", + "full_name": "TestyFont Bold Oblique", } # When with mock.patch(target, return_value=prop_dict): @@ -251,8 +254,9 @@ def test_property_branches(self): for name, stretch in stretch_options.items(): # Given prop_dict = { - "name": name, - "sfnt4": name, + "family": "TestyFont", + "style": "Regular", + "full_name": name, } # When with mock.patch(target, return_value=prop_dict):