From 8c92f03c15a951c34065bffec442e16a53cddae5 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Wed, 10 Mar 2021 16:16:01 +0100 Subject: [PATCH 1/3] Use fonttools.afmLib instead of ad-hoc AFM parser --- kiva/fonttools/LICENSES/LICENSE_fonttools | 21 +++++++++++++ kiva/fonttools/_scan_parse.py | 36 +++++++++++----------- kiva/fonttools/tests/data/TestAFM.afm | 37 +++++++++++++++++++++++ kiva/fonttools/tests/test_scan_parse.py | 34 +++++++++------------ kiva/fonttools/tests/test_scan_sys.py | 10 ++++-- 5 files changed, 98 insertions(+), 40 deletions(-) create mode 100644 kiva/fonttools/LICENSES/LICENSE_fonttools create mode 100644 kiva/fonttools/tests/data/TestAFM.afm diff --git a/kiva/fonttools/LICENSES/LICENSE_fonttools b/kiva/fonttools/LICENSES/LICENSE_fonttools new file mode 100644 index 000000000..cc633905d --- /dev/null +++ b/kiva/fonttools/LICENSES/LICENSE_fonttools @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Just van Rossum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/kiva/fonttools/_scan_parse.py b/kiva/fonttools/_scan_parse.py index 6f2bb004e..1845cba2c 100644 --- a/kiva/fonttools/_scan_parse.py +++ b/kiva/fonttools/_scan_parse.py @@ -17,9 +17,9 @@ import logging import os +from fontTools.afmLib import AFM from fontTools.ttLib import TTCollection, TTFont, TTLibError -from kiva.fonttools import afm from kiva.fonttools._constants import weight_dict from kiva.fonttools._util import get_ttf_prop_dict, weight_as_number @@ -89,18 +89,10 @@ def _build_afm_entries(fpath): instance or an empty list if there was an error. """ try: - fh = open(fpath, "r") - except OSError: - logger.error(f"Could not open font file {fpath}", exc_info=True) - return [] - - try: - font = afm.AFM(fh) - except RuntimeError: + font = AFM(fpath) + except Exception: logger.error(f"Could not parse font file {fpath}", exc_info=True) return [] - finally: - fh.close() try: return [_afm_font_property(fpath, font)] @@ -140,17 +132,17 @@ def _build_ttf_entries(fpath): return entries -def _afm_font_property(fontpath, font): +def _afm_font_property(fpath, font): """ A function for populating a :class:`FontEntry` instance by extracting information from the AFM font file. *font* is a class:`AFM` instance. """ - family = font.get_familyname() - fontname = font.get_fontname().lower() + family = font.FamilyName + fontname = font.FullName.lower() # Styles are: italic, oblique, and normal (default) - if font.get_angle() != 0 or family.lower().find("italic") >= 0: + if float(font.ItalicAngle) != 0.0 or family.lower().find("italic") >= 0: style = "italic" elif family.lower().find("oblique") >= 0: style = "oblique" @@ -160,7 +152,7 @@ def _afm_font_property(fontpath, font): # Variants are: small-caps and normal (default) # NOTE: Not sure how many fonts actually have these strings in their family variant = "normal" - for value in ("capitals", "small-caps"): + for value in ("capitals", "small-caps", "smallcaps"): if value in family.lower(): variant = "small-caps" break @@ -168,7 +160,7 @@ def _afm_font_property(fontpath, font): # Weights are: 100, 200, 300, 400 (normal: default), 500 (medium), # 600 (semibold, demibold), 700 (bold), 800 (heavy), 900 (black) # lighter and bolder are also allowed. - weight = weight_as_number(font.get_weight().lower()) + weight = weight_as_number(font.Weight.lower()) # Stretch can be absolute and relative # Absolute stretches are: ultra-condensed, extra-condensed, condensed, @@ -196,7 +188,15 @@ def _afm_font_property(fontpath, font): # All AFM fonts are apparently scalable. size = "scalable" - return FontEntry(fontpath, family, style, variant, weight, stretch, size) + return FontEntry( + fname=fpath, + family=family, + style=style, + variant=variant, + weight=weight, + stretch=stretch, + size=size, + ) def _ttf_font_property(fpath, font, face_index=0): diff --git a/kiva/fonttools/tests/data/TestAFM.afm b/kiva/fonttools/tests/data/TestAFM.afm new file mode 100644 index 000000000..634c66a3f --- /dev/null +++ b/kiva/fonttools/tests/data/TestAFM.afm @@ -0,0 +1,37 @@ +StartFontMetrics 2.0 +Comment UniqueID 2123703 +Comment Panose 2 0 6 3 3 0 0 2 0 4 +FontName TestFont-Regular +FullName TestFont-Regular +FamilyName TestFont +Weight Regular +ItalicAngle 0.00 +IsFixedPitch false +FontBBox -94 -317 1316 1009 +UnderlinePosition -296 +UnderlineThickness 111 +Version 001.000 +Notice [c] Copyright 2017. All Rights Reserved. +EncodingScheme FontSpecific +CapHeight 700 +XHeight 500 +Ascender 750 +Descender -250 +StdHW 181 +StdVW 194 +StartCharMetrics 4 +C 32 ; WX 200 ; N space ; B 0 0 0 0 ; +C 65 ; WX 668 ; N A ; B 8 -25 660 666 ; +C 66 ; WX 543 ; N B ; B 36 0 522 666 ; +C 67 ; WX 582 ; N C ; B 24 -21 564 687 ; +EndCharMetrics +StartKernData +StartKernPairs 5 +KPX T c 30 +KPX T comma -100 +KPX T period -100 +KPX V A -60 +KPX V d 30 +EndKernPairs +EndKernData +EndFontMetrics diff --git a/kiva/fonttools/tests/test_scan_parse.py b/kiva/fonttools/tests/test_scan_parse.py index b932ccf24..70e3f3e6b 100644 --- a/kiva/fonttools/tests/test_scan_parse.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -76,34 +76,28 @@ def test_afm_font_failure(self): entries = _build_afm_entries("nonexistant.path") self.assertListEqual([], entries) - # XXX: Once AFM code has been converted to expect bytestrings: # Add a test which passes an existing file (non-afm) to - # _build_afm_entries. + ttf_fontpath = os.path.join(data_dir, "TestTTF.ttf") + entries = _build_afm_entries(ttf_fontpath) + self.assertListEqual([], entries) + + # Add a test which passes an existing file (non-afm) to + afm_fontpath = os.path.join(data_dir, "TestAFM.afm") + entries = _build_afm_entries(afm_fontpath) + self.assertEqual(len(entries), 1) def test_property_branches(self): - fake_path = os.path.join(data_dir, "TestAFM.afm") + fake_path = os.path.join(data_dir, "FakeAFM.afm") class FakeAFM: def __init__(self, name, family, angle, weight): - self.name = name - self.family = family - self.angle = angle - self.weight = weight - - def get_angle(self): - return self.angle - - def get_familyname(self): - return self.family - - def get_fontname(self): - return self.name - - def get_weight(self): - return self.weight + self.FullName = name + self.FamilyName = family + self.ItalicAngle = angle + self.Weight = weight # Given - fake_font = FakeAFM("TestyFont", "Testy", 0, "Bold") + fake_font = FakeAFM("TestyFont", "Testy", "0.0", "Bold") exp_family = "Testy" exp_style = "normal" exp_variant = "normal" diff --git a/kiva/fonttools/tests/test_scan_sys.py b/kiva/fonttools/tests/test_scan_sys.py index a43a78234..2cd9e229e 100644 --- a/kiva/fonttools/tests/test_scan_sys.py +++ b/kiva/fonttools/tests/test_scan_sys.py @@ -26,18 +26,24 @@ def test_directory_scanning(self): expected = [ os.path.join(data_dir, fname) for fname in os.listdir(data_dir) + if os.path.splitext(fname)[-1] in (".ttf", ".ttc") ] fonts = scan_system_fonts(data_dir, fontext="ttf") self.assertListEqual(sorted(expected), sorted(fonts)) - # There are no AFM fonts in the test data + expected = [ + os.path.join(data_dir, fname) + for fname in os.listdir(data_dir) + if os.path.splitext(fname)[-1] == ".afm" + ] fonts = scan_system_fonts(data_dir, fontext="afm") - self.assertListEqual([], fonts) + self.assertListEqual(sorted(expected), sorted(fonts)) def test_directories_scanning(self): expected = sorted([ os.path.join(data_dir, fname) for fname in os.listdir(data_dir) + if os.path.splitext(fname)[-1] in (".ttf", ".ttc") ]) # Pass a list of directories instead of a single path string fonts = scan_system_fonts([data_dir], fontext="ttf") From 4984a722bc049a4fe41cb7990ace817cc5d322d7 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Wed, 10 Mar 2021 16:26:52 +0100 Subject: [PATCH 2/3] PR feedback --- kiva/fonttools/tests/test_scan_parse.py | 33 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/kiva/fonttools/tests/test_scan_parse.py b/kiva/fonttools/tests/test_scan_parse.py index 70e3f3e6b..1cff4c450 100644 --- a/kiva/fonttools/tests/test_scan_parse.py +++ b/kiva/fonttools/tests/test_scan_parse.py @@ -11,6 +11,7 @@ import unittest from unittest import mock +from fontTools.afmLib import AFM from fontTools.ttLib import TTFont from pkg_resources import resource_filename @@ -76,16 +77,38 @@ def test_afm_font_failure(self): entries = _build_afm_entries("nonexistant.path") self.assertListEqual([], entries) - # Add a test which passes an existing file (non-afm) to + # Add a test which passes an existing file (non-afm) ttf_fontpath = os.path.join(data_dir, "TestTTF.ttf") - entries = _build_afm_entries(ttf_fontpath) + with self.assertLogs("kiva"): + entries = _build_afm_entries(ttf_fontpath) self.assertListEqual([], entries) - # Add a test which passes an existing file (non-afm) to + def test_afm_font_success(self): afm_fontpath = os.path.join(data_dir, "TestAFM.afm") entries = _build_afm_entries(afm_fontpath) self.assertEqual(len(entries), 1) + def test_afm_font_parse(self): + # Given + test_font = os.path.join(data_dir, "TestAFM.afm") + exp_family = "TestFont" + exp_style = "normal" + exp_variant = "normal" + exp_weight = 400 + exp_stretch = "normal" + exp_size = "scalable" + + # When + entry = _afm_font_property(test_font, AFM(test_font)) + + # Then + self.assertEqual(entry.family, exp_family) + self.assertEqual(entry.style, exp_style) + self.assertEqual(entry.variant, exp_variant) + self.assertEqual(entry.weight, exp_weight) + self.assertEqual(entry.stretch, exp_stretch) + self.assertEqual(entry.size, exp_size) + def test_property_branches(self): fake_path = os.path.join(data_dir, "FakeAFM.afm") @@ -93,11 +116,11 @@ class FakeAFM: def __init__(self, name, family, angle, weight): self.FullName = name self.FamilyName = family - self.ItalicAngle = angle + self.ItalicAngle = str(angle) self.Weight = weight # Given - fake_font = FakeAFM("TestyFont", "Testy", "0.0", "Bold") + fake_font = FakeAFM("TestyFont", "Testy", 0, "Bold") exp_family = "Testy" exp_style = "normal" exp_variant = "normal" From e9d0f79b60ead1872bad054d5e6ca6b374dbcc16 Mon Sep 17 00:00:00 2001 From: John Wiggins Date: Wed, 10 Mar 2021 16:46:34 +0100 Subject: [PATCH 3/3] Make sure all of our test data gets packaged --- MANIFEST.in | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 2a5417398..ec452f481 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -16,7 +16,7 @@ recursive-include kiva/agg *.py *.i *.cpp *.h *.c recursive-include kiva/agg/agg-24 * recursive-include kiva/agg/freetype2 * recursive-include kiva/agg/LICENSES * -recursive-include kiva/fonttools/tests/data *.ttc *.ttf +recursive-include kiva/fonttools/tests/data *.ttc *.ttf *.afm recursive-include kiva/fonttools/LICENSES * recursive-include kiva/gl *.h *.cpp *.i LICENSE_* recursive-include kiva/quartz *.pyx *.pxi *.pxd mac_context*.* diff --git a/setup.py b/setup.py index 619bf4585..455d9d92e 100644 --- a/setup.py +++ b/setup.py @@ -495,6 +495,7 @@ def macos_extensions(): 'demo/*/*/*/*/*'], 'enable.savage.trait_defs.ui.wx': ['data/*.svg'], 'kiva': ['tests/agg/doubleprom_soho_full.jpg', + 'fonttools/tests/data/*.afm', 'fonttools/tests/data/*.ttc', 'fonttools/tests/data/*.ttf', 'fonttools/tests/data/*.txt'],