1import io 2import os 3import re 4import random 5import tempfile 6from fontTools.feaLib.builder import addOpenTypeFeaturesFromString 7from fontTools.ttLib import ( 8 TTFont, 9 TTLibError, 10 newTable, 11 registerCustomTableClass, 12 unregisterCustomTableClass, 13) 14from fontTools.ttLib.standardGlyphOrder import standardGlyphOrder 15from fontTools.ttLib.tables.DefaultTable import DefaultTable 16from fontTools.ttLib.tables._c_m_a_p import CmapSubtable 17import pytest 18 19 20DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data") 21 22 23class CustomTableClass(DefaultTable): 24 def decompile(self, data, ttFont): 25 self.numbers = list(data) 26 27 def compile(self, ttFont): 28 return bytes(self.numbers) 29 30 # not testing XML read/write 31 32 33table_C_U_S_T_ = CustomTableClass # alias for testing 34 35 36TABLETAG = "CUST" 37 38 39def normalize_TTX(string): 40 string = re.sub(' ttLibVersion=".*"', "", string) 41 string = re.sub('checkSumAdjustment value=".*"', "", string) 42 string = re.sub('modified value=".*"', "", string) 43 return string 44 45 46def test_registerCustomTableClass(): 47 font = TTFont() 48 font[TABLETAG] = newTable(TABLETAG) 49 font[TABLETAG].data = b"\x00\x01\xff" 50 f = io.BytesIO() 51 font.save(f) 52 f.seek(0) 53 assert font[TABLETAG].data == b"\x00\x01\xff" 54 registerCustomTableClass(TABLETAG, "ttFont_test", "CustomTableClass") 55 try: 56 font = TTFont(f) 57 assert font[TABLETAG].numbers == [0, 1, 255] 58 assert font[TABLETAG].compile(font) == b"\x00\x01\xff" 59 finally: 60 unregisterCustomTableClass(TABLETAG) 61 62 63def test_registerCustomTableClassStandardName(): 64 registerCustomTableClass(TABLETAG, "ttFont_test") 65 try: 66 font = TTFont() 67 font[TABLETAG] = newTable(TABLETAG) 68 font[TABLETAG].numbers = [4, 5, 6] 69 assert font[TABLETAG].compile(font) == b"\x04\x05\x06" 70 finally: 71 unregisterCustomTableClass(TABLETAG) 72 73 74ttxTTF = r"""<?xml version="1.0" encoding="UTF-8"?> 75<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.9.0"> 76 <hmtx> 77 <mtx name=".notdef" width="300" lsb="0"/> 78 </hmtx> 79</ttFont> 80""" 81 82 83ttxOTF = """<?xml version="1.0" encoding="UTF-8"?> 84<ttFont sfntVersion="OTTO" ttLibVersion="4.9.0"> 85 <hmtx> 86 <mtx name=".notdef" width="300" lsb="0"/> 87 </hmtx> 88</ttFont> 89""" 90 91 92def test_sfntVersionFromTTX(): 93 # https://github.com/fonttools/fonttools/issues/2370 94 font = TTFont() 95 assert font.sfntVersion == "\x00\x01\x00\x00" 96 ttx = io.StringIO(ttxOTF) 97 # Font is "empty", TTX file will determine sfntVersion 98 font.importXML(ttx) 99 assert font.sfntVersion == "OTTO" 100 ttx = io.StringIO(ttxTTF) 101 # Font is not "empty", sfntVersion in TTX file will be ignored 102 font.importXML(ttx) 103 assert font.sfntVersion == "OTTO" 104 105 106def test_virtualGlyphId(): 107 otfpath = os.path.join(DATA_DIR, "TestVGID-Regular.otf") 108 ttxpath = os.path.join(DATA_DIR, "TestVGID-Regular.ttx") 109 110 otf = TTFont(otfpath) 111 112 ttx = TTFont() 113 ttx.importXML(ttxpath) 114 115 with open(ttxpath, encoding="utf-8") as fp: 116 xml = normalize_TTX(fp.read()).splitlines() 117 118 for font in (otf, ttx): 119 GSUB = font["GSUB"].table 120 assert GSUB.LookupList.LookupCount == 37 121 lookup = GSUB.LookupList.Lookup[32] 122 assert lookup.LookupType == 8 123 subtable = lookup.SubTable[0] 124 assert subtable.LookAheadGlyphCount == 1 125 lookahead = subtable.LookAheadCoverage[0] 126 assert len(lookahead.glyphs) == 46 127 assert "glyph00453" in lookahead.glyphs 128 129 out = io.StringIO() 130 font.saveXML(out) 131 outxml = normalize_TTX(out.getvalue()).splitlines() 132 assert xml == outxml 133 134 135def test_setGlyphOrder_also_updates_glyf_glyphOrder(): 136 # https://github.com/fonttools/fonttools/issues/2060#issuecomment-1063932428 137 font = TTFont() 138 font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) 139 current_order = font.getGlyphOrder() 140 141 assert current_order == font["glyf"].glyphOrder 142 143 new_order = list(current_order) 144 while new_order == current_order: 145 random.shuffle(new_order) 146 147 font.setGlyphOrder(new_order) 148 149 assert font.getGlyphOrder() == new_order 150 assert font["glyf"].glyphOrder == new_order 151 152 153def test_getGlyphOrder_not_true_post_format_1(caplog): 154 # https://github.com/fonttools/fonttools/issues/2736 155 caplog.set_level("WARNING") 156 font = TTFont(os.path.join(DATA_DIR, "bogus_post_format_1.ttf")) 157 hmtx = font["hmtx"] 158 assert len(hmtx.metrics) > len(standardGlyphOrder) 159 log_rec = caplog.records[-1] 160 assert log_rec.levelname == "WARNING" 161 assert "Not enough names found in the 'post' table" in log_rec.message 162 163 164@pytest.mark.parametrize("lazy", [None, True, False]) 165def test_ensureDecompiled(lazy): 166 # test that no matter the lazy value, ensureDecompiled decompiles all tables 167 font = TTFont() 168 font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) 169 # test font has no OTL so we add some, as an example of otData-driven tables 170 addOpenTypeFeaturesFromString( 171 font, 172 """ 173 feature calt { 174 sub period' period' period' space by ellipsis; 175 } calt; 176 177 feature dist { 178 pos period period -30; 179 } dist; 180 """, 181 ) 182 # also add an additional cmap subtable that will be lazily-loaded 183 cm = CmapSubtable.newSubtable(14) 184 cm.platformID = 0 185 cm.platEncID = 5 186 cm.language = 0 187 cm.cmap = {} 188 cm.uvsDict = {0xFE00: [(0x002E, None)]} 189 font["cmap"].tables.append(cm) 190 191 # save and reload, potentially lazily 192 buf = io.BytesIO() 193 font.save(buf) 194 buf.seek(0) 195 font = TTFont(buf, lazy=lazy) 196 197 # check no table is loaded until/unless requested, no matter the laziness 198 for tag in font.keys(): 199 assert not font.isLoaded(tag) 200 201 if lazy is not False: 202 # additional cmap doesn't get decompiled automatically unless lazy=False; 203 # can't use hasattr or else cmap's maginc __getattr__ kicks in... 204 cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) 205 assert cm.data is not None 206 assert "uvsDict" not in cm.__dict__ 207 # glyf glyphs are not expanded unless lazy=False 208 assert font["glyf"].glyphs["period"].data is not None 209 assert not hasattr(font["glyf"].glyphs["period"], "coordinates") 210 211 if lazy is True: 212 # OTL tables hold a 'reader' to lazily load when lazy=True 213 assert "reader" in font["GSUB"].table.LookupList.__dict__ 214 assert "reader" in font["GPOS"].table.LookupList.__dict__ 215 216 font.ensureDecompiled() 217 218 # all tables are decompiled now 219 for tag in font.keys(): 220 assert font.isLoaded(tag) 221 # including the additional cmap 222 cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) 223 assert cm.data is None 224 assert "uvsDict" in cm.__dict__ 225 # expanded glyf glyphs lost the 'data' attribute 226 assert not hasattr(font["glyf"].glyphs["period"], "data") 227 assert hasattr(font["glyf"].glyphs["period"], "coordinates") 228 # and OTL tables have read their 'reader' 229 assert "reader" not in font["GSUB"].table.LookupList.__dict__ 230 assert "Lookup" in font["GSUB"].table.LookupList.__dict__ 231 assert "reader" not in font["GPOS"].table.LookupList.__dict__ 232 assert "Lookup" in font["GPOS"].table.LookupList.__dict__ 233 234 235@pytest.fixture 236def testFont_fvar_avar(): 237 ttxpath = os.path.join(DATA_DIR, "TestTTF_normalizeLocation.ttx") 238 ttf = TTFont() 239 ttf.importXML(ttxpath) 240 return ttf 241 242 243@pytest.mark.parametrize( 244 "userLocation, expectedNormalizedLocation", 245 [ 246 ({}, {"wght": 0.0}), 247 ({"wght": 100}, {"wght": -1.0}), 248 ({"wght": 250}, {"wght": -0.75}), 249 ({"wght": 400}, {"wght": 0.0}), 250 ({"wght": 550}, {"wght": 0.75}), 251 ({"wght": 625}, {"wght": 0.875}), 252 ({"wght": 700}, {"wght": 1.0}), 253 ], 254) 255def test_font_normalizeLocation( 256 testFont_fvar_avar, userLocation, expectedNormalizedLocation 257): 258 normalizedLocation = testFont_fvar_avar.normalizeLocation(userLocation) 259 assert expectedNormalizedLocation == normalizedLocation 260 261 262def test_font_normalizeLocation_no_VF(): 263 ttf = TTFont() 264 with pytest.raises(TTLibError, match="Not a variable font"): 265 ttf.normalizeLocation({}) 266 267 268def test_getGlyphID(): 269 font = TTFont() 270 font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) 271 272 assert font.getGlyphID("space") == 3 273 assert font.getGlyphID("glyph12345") == 12345 # virtual glyph 274 with pytest.raises(KeyError): 275 font.getGlyphID("non_existent") 276 with pytest.raises(KeyError): 277 font.getGlyphID("glyph_prefix_but_invalid_id") 278 279 280def test_spooled_tempfile_may_not_have_attribute_seekable(): 281 # SpooledTemporaryFile only got a seekable attribute on Python 3.11 282 # https://github.com/fonttools/fonttools/issues/3052 283 font = TTFont() 284 font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) 285 tmp = tempfile.SpooledTemporaryFile() 286 font.save(tmp) 287 # this should not fail 288 _ = TTFont(tmp) 289 290 291def test_unseekable_file_lazy_loading_fails(): 292 class NonSeekableFile: 293 def __init__(self): 294 self.file = io.BytesIO() 295 296 def read(self, size): 297 return self.file.read(size) 298 299 def seekable(self): 300 return False 301 302 f = NonSeekableFile() 303 with pytest.raises(TTLibError, match="Input file must be seekable when lazy=True"): 304 TTFont(f, lazy=True) 305 306 307def test_unsupported_seek_operation_lazy_loading_fails(): 308 class UnsupportedSeekFile: 309 def __init__(self): 310 self.file = io.BytesIO() 311 312 def read(self, size): 313 return self.file.read(size) 314 315 def seek(self, offset): 316 raise io.UnsupportedOperation("Unsupported seek operation") 317 318 f = UnsupportedSeekFile() 319 with pytest.raises(TTLibError, match="Input file must be seekable when lazy=True"): 320 TTFont(f, lazy=True) 321