This allows dependent projects to catch errors parsing glifs without requiring logic to account for which XML library fonttools is using internally (e.g. for implementing fonttools/ufoLib2#264). This commit also adds tests to ensure that the exception we expose when glifs have invalid syntax remains stable across future releases.
361 lines
13 KiB
Python
361 lines
13 KiB
Python
import logging
|
|
import os
|
|
import tempfile
|
|
import shutil
|
|
import unittest
|
|
from pathlib import Path
|
|
from io import open
|
|
from .testSupport import getDemoFontGlyphSetPath
|
|
from fontTools.ufoLib.glifLib import (
|
|
GlyphSet,
|
|
glyphNameToFileName,
|
|
readGlyphFromString,
|
|
writeGlyphToString,
|
|
)
|
|
from fontTools.ufoLib.errors import (
|
|
GlifLibError,
|
|
UnsupportedGLIFFormat,
|
|
UnsupportedUFOFormat,
|
|
)
|
|
from fontTools.misc.etree import XML_DECLARATION
|
|
from fontTools.pens.recordingPen import RecordingPointPen
|
|
import pytest
|
|
|
|
GLYPHSETDIR = getDemoFontGlyphSetPath()
|
|
|
|
|
|
class GlyphSetTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dstDir = tempfile.mktemp()
|
|
os.mkdir(self.dstDir)
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.dstDir)
|
|
|
|
def testRoundTrip(self):
|
|
import difflib
|
|
|
|
srcDir = GLYPHSETDIR
|
|
dstDir = self.dstDir
|
|
src = GlyphSet(
|
|
srcDir, ufoFormatVersion=2, validateRead=True, validateWrite=True
|
|
)
|
|
dst = GlyphSet(
|
|
dstDir, ufoFormatVersion=2, validateRead=True, validateWrite=True
|
|
)
|
|
for glyphName in src.keys():
|
|
g = src[glyphName]
|
|
g.drawPoints(None) # load attrs
|
|
dst.writeGlyph(glyphName, g, g.drawPoints)
|
|
# compare raw file data:
|
|
for glyphName in sorted(src.keys()):
|
|
fileName = src.contents[glyphName]
|
|
with open(os.path.join(srcDir, fileName), "r") as f:
|
|
org = f.read()
|
|
with open(os.path.join(dstDir, fileName), "r") as f:
|
|
new = f.read()
|
|
added = []
|
|
removed = []
|
|
for line in difflib.unified_diff(org.split("\n"), new.split("\n")):
|
|
if line.startswith("+ "):
|
|
added.append(line[1:])
|
|
elif line.startswith("- "):
|
|
removed.append(line[1:])
|
|
self.assertEqual(
|
|
added, removed, "%s.glif file differs after round tripping" % glyphName
|
|
)
|
|
|
|
def testContentsExist(self):
|
|
with self.assertRaises(GlifLibError):
|
|
GlyphSet(
|
|
self.dstDir,
|
|
ufoFormatVersion=2,
|
|
validateRead=True,
|
|
validateWrite=True,
|
|
expectContentsFile=True,
|
|
)
|
|
|
|
def testRebuildContents(self):
|
|
gset = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
|
|
contents = gset.contents
|
|
gset.rebuildContents()
|
|
self.assertEqual(contents, gset.contents)
|
|
|
|
def testReverseContents(self):
|
|
gset = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
|
|
d = {}
|
|
for k, v in gset.getReverseContents().items():
|
|
d[v] = k
|
|
org = {}
|
|
for k, v in gset.contents.items():
|
|
org[k] = v.lower()
|
|
self.assertEqual(d, org)
|
|
|
|
def testReverseContents2(self):
|
|
src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
|
|
dst = GlyphSet(self.dstDir, validateRead=True, validateWrite=True)
|
|
dstMap = dst.getReverseContents()
|
|
self.assertEqual(dstMap, {})
|
|
for glyphName in src.keys():
|
|
g = src[glyphName]
|
|
g.drawPoints(None) # load attrs
|
|
dst.writeGlyph(glyphName, g, g.drawPoints)
|
|
self.assertNotEqual(dstMap, {})
|
|
srcMap = dict(src.getReverseContents()) # copy
|
|
self.assertEqual(dstMap, srcMap)
|
|
del srcMap["a.glif"]
|
|
dst.deleteGlyph("a")
|
|
self.assertEqual(dstMap, srcMap)
|
|
|
|
def testCustomFileNamingScheme(self):
|
|
def myGlyphNameToFileName(glyphName, glyphSet):
|
|
return "prefix" + glyphNameToFileName(glyphName, glyphSet)
|
|
|
|
src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
|
|
dst = GlyphSet(
|
|
self.dstDir, myGlyphNameToFileName, validateRead=True, validateWrite=True
|
|
)
|
|
for glyphName in src.keys():
|
|
g = src[glyphName]
|
|
g.drawPoints(None) # load attrs
|
|
dst.writeGlyph(glyphName, g, g.drawPoints)
|
|
d = {}
|
|
for k, v in src.contents.items():
|
|
d[k] = "prefix" + v
|
|
self.assertEqual(d, dst.contents)
|
|
|
|
def testGetUnicodes(self):
|
|
src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
|
|
unicodes = src.getUnicodes()
|
|
for glyphName in src.keys():
|
|
g = src[glyphName]
|
|
g.drawPoints(None) # load attrs
|
|
if not hasattr(g, "unicodes"):
|
|
self.assertEqual(unicodes[glyphName], [])
|
|
else:
|
|
self.assertEqual(g.unicodes, unicodes[glyphName])
|
|
|
|
def testReadGlyphInvalidXml(self):
|
|
"""Test that calling readGlyph() to read a .glif with invalid XML raises
|
|
a library error, instead of an exception from the XML dependency that is
|
|
used internally."""
|
|
|
|
# Create a glyph set with three empty glyphs.
|
|
glyph_set = GlyphSet(self.dstDir)
|
|
glyph_set.writeGlyph("a", _Glyph())
|
|
glyph_set.writeGlyph("b", _Glyph())
|
|
glyph_set.writeGlyph("c", _Glyph())
|
|
|
|
# Corrupt the XML of /c.
|
|
invalid_xml = b"<abc></def>"
|
|
Path(self.dstDir, glyph_set.contents["c"]).write_bytes(invalid_xml)
|
|
|
|
# Confirm that reading /c raises an appropriate error.
|
|
glyph_set.readGlyph("a", _Glyph())
|
|
glyph_set.readGlyph("b", _Glyph())
|
|
with pytest.raises(GlifLibError, match="GLIF contains invalid XML"):
|
|
glyph_set.readGlyph("c", _Glyph())
|
|
|
|
|
|
class FileNameTest:
|
|
def test_default_file_name_scheme(self):
|
|
assert glyphNameToFileName("a", None) == "a.glif"
|
|
assert glyphNameToFileName("A", None) == "A_.glif"
|
|
assert glyphNameToFileName("Aring", None) == "A_ring.glif"
|
|
assert glyphNameToFileName("F_A_B", None) == "F__A__B_.glif"
|
|
assert glyphNameToFileName("A.alt", None) == "A_.alt.glif"
|
|
assert glyphNameToFileName("A.Alt", None) == "A_.A_lt.glif"
|
|
assert glyphNameToFileName(".notdef", None) == "_notdef.glif"
|
|
assert glyphNameToFileName("T_H", None) == "T__H_.glif"
|
|
assert glyphNameToFileName("T_h", None) == "T__h.glif"
|
|
assert glyphNameToFileName("t_h", None) == "t_h.glif"
|
|
assert glyphNameToFileName("F_F_I", None) == "F__F__I_.glif"
|
|
assert glyphNameToFileName("f_f_i", None) == "f_f_i.glif"
|
|
assert glyphNameToFileName("AE", None) == "A_E_.glif"
|
|
assert glyphNameToFileName("Ae", None) == "A_e.glif"
|
|
assert glyphNameToFileName("ae", None) == "ae.glif"
|
|
assert glyphNameToFileName("aE", None) == "aE_.glif"
|
|
assert glyphNameToFileName("a.alt", None) == "a.alt.glif"
|
|
assert glyphNameToFileName("A.aLt", None) == "A_.aL_t.glif"
|
|
assert glyphNameToFileName("A.alT", None) == "A_.alT_.glif"
|
|
assert glyphNameToFileName("Aacute_V.swash", None) == "A_acute_V_.swash.glif"
|
|
assert glyphNameToFileName(".notdef", None) == "_notdef.glif"
|
|
assert glyphNameToFileName("con", None) == "_con.glif"
|
|
assert glyphNameToFileName("CON", None) == "C_O_N_.glif"
|
|
assert glyphNameToFileName("con.alt", None) == "_con.alt.glif"
|
|
assert glyphNameToFileName("alt.con", None) == "alt._con.glif"
|
|
|
|
def test_conflicting_case_insensitive_file_names(self, tmp_path):
|
|
src = GlyphSet(GLYPHSETDIR)
|
|
dst = GlyphSet(tmp_path)
|
|
glyph = src["a"]
|
|
|
|
dst.writeGlyph("a", glyph)
|
|
dst.writeGlyph("A", glyph)
|
|
dst.writeGlyph("a_", glyph)
|
|
dst.deleteGlyph("a_")
|
|
dst.writeGlyph("a_", glyph)
|
|
dst.writeGlyph("A_", glyph)
|
|
dst.writeGlyph("i_j", glyph)
|
|
|
|
assert dst.contents == {
|
|
"a": "a.glif",
|
|
"A": "A_.glif",
|
|
"a_": "a_000000000000001.glif",
|
|
"A_": "A__.glif",
|
|
"i_j": "i_j.glif",
|
|
}
|
|
|
|
# make sure filenames are unique even on case-insensitive filesystems
|
|
assert len({fileName.lower() for fileName in dst.contents.values()}) == 5
|
|
|
|
|
|
class _Glyph:
|
|
pass
|
|
|
|
|
|
class ReadWriteFuncTest:
|
|
def test_roundtrip(self):
|
|
glyph = _Glyph()
|
|
glyph.name = "a"
|
|
glyph.unicodes = [0x0061]
|
|
|
|
s1 = writeGlyphToString(glyph.name, glyph)
|
|
|
|
glyph2 = _Glyph()
|
|
readGlyphFromString(s1, glyph2)
|
|
assert glyph.__dict__ == glyph2.__dict__
|
|
|
|
s2 = writeGlyphToString(glyph2.name, glyph2)
|
|
assert s1 == s2
|
|
|
|
def test_xml_declaration(self):
|
|
s = writeGlyphToString("a", _Glyph())
|
|
assert s.startswith(XML_DECLARATION % "UTF-8")
|
|
|
|
def test_parse_xml_remove_comments(self):
|
|
s = b"""<?xml version='1.0' encoding='UTF-8'?>
|
|
<!-- a comment -->
|
|
<glyph name="A" format="2">
|
|
<advance width="1290"/>
|
|
<unicode hex="0041"/>
|
|
<!-- another comment -->
|
|
</glyph>
|
|
"""
|
|
|
|
g = _Glyph()
|
|
readGlyphFromString(s, g)
|
|
|
|
assert g.name == "A"
|
|
assert g.width == 1290
|
|
assert g.unicodes == [0x0041]
|
|
|
|
def test_read_invalid_xml(self):
|
|
"""Test that calling readGlyphFromString() with invalid XML raises a
|
|
library error, instead of an exception from the XML dependency that is
|
|
used internally."""
|
|
|
|
invalid_xml = b"<abc></def>"
|
|
empty_glyph = _Glyph()
|
|
|
|
with pytest.raises(GlifLibError, match="GLIF contains invalid XML"):
|
|
readGlyphFromString(invalid_xml, empty_glyph)
|
|
|
|
def test_read_unsupported_format_version(self, caplog):
|
|
s = """<?xml version='1.0' encoding='utf-8'?>
|
|
<glyph name="A" format="0" formatMinor="0">
|
|
<advance width="500"/>
|
|
<unicode hex="0041"/>
|
|
</glyph>
|
|
"""
|
|
|
|
with pytest.raises(UnsupportedGLIFFormat):
|
|
readGlyphFromString(s, _Glyph()) # validate=True by default
|
|
|
|
with pytest.raises(UnsupportedGLIFFormat):
|
|
readGlyphFromString(s, _Glyph(), validate=True)
|
|
|
|
caplog.clear()
|
|
with caplog.at_level(logging.WARNING, logger="fontTools.ufoLib.glifLib"):
|
|
readGlyphFromString(s, _Glyph(), validate=False)
|
|
|
|
assert len(caplog.records) == 1
|
|
assert "Unsupported GLIF format" in caplog.text
|
|
assert "Assuming the latest supported version" in caplog.text
|
|
|
|
def test_read_allow_format_versions(self):
|
|
s = """<?xml version='1.0' encoding='utf-8'?>
|
|
<glyph name="A" format="2">
|
|
<advance width="500"/>
|
|
<unicode hex="0041"/>
|
|
</glyph>
|
|
"""
|
|
|
|
# these two calls are are equivalent
|
|
readGlyphFromString(s, _Glyph(), formatVersions=[1, 2])
|
|
readGlyphFromString(s, _Glyph(), formatVersions=[(1, 0), (2, 0)])
|
|
|
|
# if at least one supported formatVersion, unsupported ones are ignored
|
|
readGlyphFromString(s, _Glyph(), formatVersions=[(2, 0), (123, 456)])
|
|
|
|
with pytest.raises(
|
|
ValueError, match="None of the requested GLIF formatVersions are supported"
|
|
):
|
|
readGlyphFromString(s, _Glyph(), formatVersions=[0, 2001])
|
|
|
|
with pytest.raises(GlifLibError, match="Forbidden GLIF format version"):
|
|
readGlyphFromString(s, _Glyph(), formatVersions=[1])
|
|
|
|
def test_read_ensure_x_y(self):
|
|
"""Ensure that a proper GlifLibError is raised when point coordinates are
|
|
missing, regardless of validation setting."""
|
|
|
|
s = """<?xml version='1.0' encoding='utf-8'?>
|
|
<glyph name="A" format="2">
|
|
<outline>
|
|
<contour>
|
|
<point x="545" y="0" type="line"/>
|
|
<point x="638" type="line"/>
|
|
</contour>
|
|
</outline>
|
|
</glyph>
|
|
"""
|
|
pen = RecordingPointPen()
|
|
|
|
with pytest.raises(GlifLibError, match="Required y attribute"):
|
|
readGlyphFromString(s, _Glyph(), pen)
|
|
|
|
with pytest.raises(GlifLibError, match="Required y attribute"):
|
|
readGlyphFromString(s, _Glyph(), pen, validate=False)
|
|
|
|
|
|
def test_GlyphSet_unsupported_ufoFormatVersion(tmp_path, caplog):
|
|
with pytest.raises(UnsupportedUFOFormat):
|
|
GlyphSet(tmp_path, ufoFormatVersion=0)
|
|
with pytest.raises(UnsupportedUFOFormat):
|
|
GlyphSet(tmp_path, ufoFormatVersion=(0, 1))
|
|
|
|
|
|
def test_GlyphSet_writeGlyph_formatVersion(tmp_path):
|
|
src = GlyphSet(GLYPHSETDIR)
|
|
dst = GlyphSet(tmp_path, ufoFormatVersion=(2, 0))
|
|
glyph = src["A"]
|
|
|
|
# no explicit formatVersion passed: use the more recent GLIF formatVersion
|
|
# that is supported by given ufoFormatVersion (GLIF 1 for UFO 2)
|
|
dst.writeGlyph("A", glyph)
|
|
glif = dst.getGLIF("A")
|
|
assert b'format="1"' in glif
|
|
assert b"formatMinor" not in glif # omitted when 0
|
|
|
|
# explicit, unknown formatVersion
|
|
with pytest.raises(UnsupportedGLIFFormat):
|
|
dst.writeGlyph("A", glyph, formatVersion=(0, 0))
|
|
|
|
# explicit, known formatVersion but unsupported by given ufoFormatVersion
|
|
with pytest.raises(
|
|
UnsupportedGLIFFormat,
|
|
match="Unsupported GLIF format version .*for UFO format version",
|
|
):
|
|
dst.writeGlyph("A", glyph, formatVersion=(2, 0))
|