import io import os import re import random import tempfile from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.ttLib import ( TTFont, TTLibError, newTable, registerCustomTableClass, unregisterCustomTableClass, ) from fontTools.ttLib.standardGlyphOrder import standardGlyphOrder from fontTools.ttLib.tables.DefaultTable import DefaultTable from fontTools.ttLib.tables._c_m_a_p import CmapSubtable import pytest DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data") class CustomTableClass(DefaultTable): def decompile(self, data, ttFont): self.numbers = list(data) def compile(self, ttFont): return bytes(self.numbers) # not testing XML read/write table_C_U_S_T_ = CustomTableClass # alias for testing TABLETAG = "CUST" def normalize_TTX(string): string = re.sub(' ttLibVersion=".*"', "", string) string = re.sub('checkSumAdjustment value=".*"', "", string) string = re.sub('modified value=".*"', "", string) return string def test_registerCustomTableClass(): font = TTFont() font[TABLETAG] = newTable(TABLETAG) font[TABLETAG].data = b"\x00\x01\xff" f = io.BytesIO() font.save(f) f.seek(0) assert font[TABLETAG].data == b"\x00\x01\xff" registerCustomTableClass(TABLETAG, "ttFont_test", "CustomTableClass") try: font = TTFont(f) assert font[TABLETAG].numbers == [0, 1, 255] assert font[TABLETAG].compile(font) == b"\x00\x01\xff" finally: unregisterCustomTableClass(TABLETAG) def test_registerCustomTableClassStandardName(): registerCustomTableClass(TABLETAG, "ttFont_test") try: font = TTFont() font[TABLETAG] = newTable(TABLETAG) font[TABLETAG].numbers = [4, 5, 6] assert font[TABLETAG].compile(font) == b"\x04\x05\x06" finally: unregisterCustomTableClass(TABLETAG) ttxTTF = r""" """ ttxOTF = """ """ def test_sfntVersionFromTTX(): # https://github.com/fonttools/fonttools/issues/2370 font = TTFont() assert font.sfntVersion == "\x00\x01\x00\x00" ttx = io.StringIO(ttxOTF) # Font is "empty", TTX file will determine sfntVersion font.importXML(ttx) assert font.sfntVersion == "OTTO" ttx = io.StringIO(ttxTTF) # Font is not "empty", sfntVersion in TTX file will be ignored font.importXML(ttx) assert font.sfntVersion == "OTTO" def test_virtualGlyphId(): otfpath = os.path.join(DATA_DIR, "TestVGID-Regular.otf") ttxpath = os.path.join(DATA_DIR, "TestVGID-Regular.ttx") otf = TTFont(otfpath) ttx = TTFont() ttx.importXML(ttxpath) with open(ttxpath, encoding="utf-8") as fp: xml = normalize_TTX(fp.read()).splitlines() for font in (otf, ttx): GSUB = font["GSUB"].table assert GSUB.LookupList.LookupCount == 37 lookup = GSUB.LookupList.Lookup[32] assert lookup.LookupType == 8 subtable = lookup.SubTable[0] assert subtable.LookAheadGlyphCount == 1 lookahead = subtable.LookAheadCoverage[0] assert len(lookahead.glyphs) == 46 assert "glyph00453" in lookahead.glyphs out = io.StringIO() font.saveXML(out) outxml = normalize_TTX(out.getvalue()).splitlines() assert xml == outxml def test_setGlyphOrder_also_updates_glyf_glyphOrder(): # https://github.com/fonttools/fonttools/issues/2060#issuecomment-1063932428 font = TTFont() font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) current_order = font.getGlyphOrder() assert current_order == font["glyf"].glyphOrder new_order = list(current_order) while new_order == current_order: random.shuffle(new_order) font.setGlyphOrder(new_order) assert font.getGlyphOrder() == new_order assert font["glyf"].glyphOrder == new_order def test_getGlyphOrder_not_true_post_format_1(caplog): # https://github.com/fonttools/fonttools/issues/2736 caplog.set_level("WARNING") font = TTFont(os.path.join(DATA_DIR, "bogus_post_format_1.ttf")) hmtx = font["hmtx"] assert len(hmtx.metrics) > len(standardGlyphOrder) log_rec = caplog.records[-1] assert log_rec.levelname == "WARNING" assert "Not enough names found in the 'post' table" in log_rec.message @pytest.mark.parametrize("lazy", [None, True, False]) def test_ensureDecompiled(lazy): # test that no matter the lazy value, ensureDecompiled decompiles all tables font = TTFont() font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) # test font has no OTL so we add some, as an example of otData-driven tables addOpenTypeFeaturesFromString( font, """ feature calt { sub period' period' period' space by ellipsis; } calt; feature dist { pos period period -30; } dist; """, ) # also add an additional cmap subtable that will be lazily-loaded cm = CmapSubtable.newSubtable(14) cm.platformID = 0 cm.platEncID = 5 cm.language = 0 cm.cmap = {} cm.uvsDict = {0xFE00: [(0x002E, None)]} font["cmap"].tables.append(cm) # save and reload, potentially lazily buf = io.BytesIO() font.save(buf) buf.seek(0) font = TTFont(buf, lazy=lazy) # check no table is loaded until/unless requested, no matter the laziness for tag in font.keys(): assert not font.isLoaded(tag) if lazy is not False: # additional cmap doesn't get decompiled automatically unless lazy=False; # can't use hasattr or else cmap's maginc __getattr__ kicks in... cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) assert cm.data is not None assert "uvsDict" not in cm.__dict__ # glyf glyphs are not expanded unless lazy=False assert font["glyf"].glyphs["period"].data is not None assert not hasattr(font["glyf"].glyphs["period"], "coordinates") if lazy is True: # OTL tables hold a 'reader' to lazily load when lazy=True assert "reader" in font["GSUB"].table.LookupList.__dict__ assert "reader" in font["GPOS"].table.LookupList.__dict__ font.ensureDecompiled() # all tables are decompiled now for tag in font.keys(): assert font.isLoaded(tag) # including the additional cmap cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) assert cm.data is None assert "uvsDict" in cm.__dict__ # expanded glyf glyphs lost the 'data' attribute assert not hasattr(font["glyf"].glyphs["period"], "data") assert hasattr(font["glyf"].glyphs["period"], "coordinates") # and OTL tables have read their 'reader' assert "reader" not in font["GSUB"].table.LookupList.__dict__ assert "Lookup" in font["GSUB"].table.LookupList.__dict__ assert "reader" not in font["GPOS"].table.LookupList.__dict__ assert "Lookup" in font["GPOS"].table.LookupList.__dict__ @pytest.fixture def testFont_fvar_avar(): ttxpath = os.path.join(DATA_DIR, "TestTTF_normalizeLocation.ttx") ttf = TTFont() ttf.importXML(ttxpath) return ttf @pytest.mark.parametrize( "userLocation, expectedNormalizedLocation", [ ({}, {"wght": 0.0}), ({"wght": 100}, {"wght": -1.0}), ({"wght": 250}, {"wght": -0.75}), ({"wght": 400}, {"wght": 0.0}), ({"wght": 550}, {"wght": 0.75}), ({"wght": 625}, {"wght": 0.875}), ({"wght": 700}, {"wght": 1.0}), ], ) def test_font_normalizeLocation( testFont_fvar_avar, userLocation, expectedNormalizedLocation ): normalizedLocation = testFont_fvar_avar.normalizeLocation(userLocation) assert expectedNormalizedLocation == normalizedLocation def test_font_normalizeLocation_no_VF(): ttf = TTFont() with pytest.raises(TTLibError, match="Not a variable font"): ttf.normalizeLocation({}) def test_getGlyphID(): font = TTFont() font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) assert font.getGlyphID("space") == 3 assert font.getGlyphID("glyph12345") == 12345 # virtual glyph with pytest.raises(KeyError): font.getGlyphID("non_existent") with pytest.raises(KeyError): font.getGlyphID("glyph_prefix_but_invalid_id") def test_spooled_tempfile_may_not_have_attribute_seekable(): # SpooledTemporaryFile only got a seekable attribute on Python 3.11 # https://github.com/fonttools/fonttools/issues/3052 font = TTFont() font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) tmp = tempfile.SpooledTemporaryFile() font.save(tmp) # this should not fail _ = TTFont(tmp) def test_unseekable_file_lazy_loading_fails(): class NonSeekableFile: def __init__(self): self.file = io.BytesIO() def read(self, size): return self.file.read(size) def seekable(self): return False f = NonSeekableFile() with pytest.raises(TTLibError, match="Input file must be seekable when lazy=True"): TTFont(f, lazy=True) def test_unsupported_seek_operation_lazy_loading_fails(): class UnsupportedSeekFile: def __init__(self): self.file = io.BytesIO() def read(self, size): return self.file.read(size) def seek(self, offset): raise io.UnsupportedOperation("Unsupported seek operation") f = UnsupportedSeekFile() with pytest.raises(TTLibError, match="Input file must be seekable when lazy=True"): TTFont(f, lazy=True)