blob: 2203b4d9c3680721068cc706ef77f5265eef810d [file] [log] [blame]
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"""<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.9.0">
<hmtx>
<mtx name=".notdef" width="300" lsb="0"/>
</hmtx>
</ttFont>
"""
ttxOTF = """<?xml version="1.0" encoding="UTF-8"?>
<ttFont sfntVersion="OTTO" ttLibVersion="4.9.0">
<hmtx>
<mtx name=".notdef" width="300" lsb="0"/>
</hmtx>
</ttFont>
"""
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)