xref: /aosp_15_r20/external/fonttools/Tests/ttLib/ttFont_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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