xref: /aosp_15_r20/external/fonttools/Tests/ufoLib/glifLib_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1import logging
2import os
3import tempfile
4import shutil
5import unittest
6from pathlib import Path
7from io import open
8from .testSupport import getDemoFontGlyphSetPath
9from fontTools.ufoLib.glifLib import (
10    GlyphSet,
11    glyphNameToFileName,
12    readGlyphFromString,
13    writeGlyphToString,
14)
15from fontTools.ufoLib.errors import (
16    GlifLibError,
17    UnsupportedGLIFFormat,
18    UnsupportedUFOFormat,
19)
20from fontTools.misc.etree import XML_DECLARATION
21from fontTools.pens.recordingPen import RecordingPointPen
22import pytest
23
24GLYPHSETDIR = getDemoFontGlyphSetPath()
25
26
27class GlyphSetTests(unittest.TestCase):
28    def setUp(self):
29        self.dstDir = tempfile.mktemp()
30        os.mkdir(self.dstDir)
31
32    def tearDown(self):
33        shutil.rmtree(self.dstDir)
34
35    def testRoundTrip(self):
36        import difflib
37
38        srcDir = GLYPHSETDIR
39        dstDir = self.dstDir
40        src = GlyphSet(
41            srcDir, ufoFormatVersion=2, validateRead=True, validateWrite=True
42        )
43        dst = GlyphSet(
44            dstDir, ufoFormatVersion=2, validateRead=True, validateWrite=True
45        )
46        for glyphName in src.keys():
47            g = src[glyphName]
48            g.drawPoints(None)  # load attrs
49            dst.writeGlyph(glyphName, g, g.drawPoints)
50        # compare raw file data:
51        for glyphName in sorted(src.keys()):
52            fileName = src.contents[glyphName]
53            with open(os.path.join(srcDir, fileName), "r") as f:
54                org = f.read()
55            with open(os.path.join(dstDir, fileName), "r") as f:
56                new = f.read()
57            added = []
58            removed = []
59            for line in difflib.unified_diff(org.split("\n"), new.split("\n")):
60                if line.startswith("+ "):
61                    added.append(line[1:])
62                elif line.startswith("- "):
63                    removed.append(line[1:])
64            self.assertEqual(
65                added, removed, "%s.glif file differs after round tripping" % glyphName
66            )
67
68    def testContentsExist(self):
69        with self.assertRaises(GlifLibError):
70            GlyphSet(
71                self.dstDir,
72                ufoFormatVersion=2,
73                validateRead=True,
74                validateWrite=True,
75                expectContentsFile=True,
76            )
77
78    def testRebuildContents(self):
79        gset = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
80        contents = gset.contents
81        gset.rebuildContents()
82        self.assertEqual(contents, gset.contents)
83
84    def testReverseContents(self):
85        gset = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
86        d = {}
87        for k, v in gset.getReverseContents().items():
88            d[v] = k
89        org = {}
90        for k, v in gset.contents.items():
91            org[k] = v.lower()
92        self.assertEqual(d, org)
93
94    def testReverseContents2(self):
95        src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
96        dst = GlyphSet(self.dstDir, validateRead=True, validateWrite=True)
97        dstMap = dst.getReverseContents()
98        self.assertEqual(dstMap, {})
99        for glyphName in src.keys():
100            g = src[glyphName]
101            g.drawPoints(None)  # load attrs
102            dst.writeGlyph(glyphName, g, g.drawPoints)
103        self.assertNotEqual(dstMap, {})
104        srcMap = dict(src.getReverseContents())  # copy
105        self.assertEqual(dstMap, srcMap)
106        del srcMap["a.glif"]
107        dst.deleteGlyph("a")
108        self.assertEqual(dstMap, srcMap)
109
110    def testCustomFileNamingScheme(self):
111        def myGlyphNameToFileName(glyphName, glyphSet):
112            return "prefix" + glyphNameToFileName(glyphName, glyphSet)
113
114        src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
115        dst = GlyphSet(
116            self.dstDir, myGlyphNameToFileName, validateRead=True, validateWrite=True
117        )
118        for glyphName in src.keys():
119            g = src[glyphName]
120            g.drawPoints(None)  # load attrs
121            dst.writeGlyph(glyphName, g, g.drawPoints)
122        d = {}
123        for k, v in src.contents.items():
124            d[k] = "prefix" + v
125        self.assertEqual(d, dst.contents)
126
127    def testGetUnicodes(self):
128        src = GlyphSet(GLYPHSETDIR, validateRead=True, validateWrite=True)
129        unicodes = src.getUnicodes()
130        for glyphName in src.keys():
131            g = src[glyphName]
132            g.drawPoints(None)  # load attrs
133            if not hasattr(g, "unicodes"):
134                self.assertEqual(unicodes[glyphName], [])
135            else:
136                self.assertEqual(g.unicodes, unicodes[glyphName])
137
138    def testReadGlyphInvalidXml(self):
139        """Test that calling readGlyph() to read a .glif with invalid XML raises
140        a library error, instead of an exception from the XML dependency that is
141        used internally. In addition, check that the raised exception describes
142        the glyph by name and gives the location of the broken .glif file."""
143
144        # Create a glyph set with three empty glyphs.
145        glyph_set = GlyphSet(self.dstDir)
146        glyph_set.writeGlyph("a", _Glyph())
147        glyph_set.writeGlyph("b", _Glyph())
148        glyph_set.writeGlyph("c", _Glyph())
149
150        # Corrupt the XML of /c.
151        invalid_xml = b"<abc></def>"
152        Path(self.dstDir, glyph_set.contents["c"]).write_bytes(invalid_xml)
153
154        # Confirm that reading /a and /b is fine...
155        glyph_set.readGlyph("a", _Glyph())
156        glyph_set.readGlyph("b", _Glyph())
157
158        # ...but that reading /c raises a descriptive library error.
159        expected_message = (
160            r"GLIF contains invalid XML\.\n"
161            r"The issue is in glyph 'c', located in '.*c\.glif.*\."
162        )
163        with pytest.raises(GlifLibError, match=expected_message):
164            glyph_set.readGlyph("c", _Glyph())
165
166
167class FileNameTest:
168    def test_default_file_name_scheme(self):
169        assert glyphNameToFileName("a", None) == "a.glif"
170        assert glyphNameToFileName("A", None) == "A_.glif"
171        assert glyphNameToFileName("Aring", None) == "A_ring.glif"
172        assert glyphNameToFileName("F_A_B", None) == "F__A__B_.glif"
173        assert glyphNameToFileName("A.alt", None) == "A_.alt.glif"
174        assert glyphNameToFileName("A.Alt", None) == "A_.A_lt.glif"
175        assert glyphNameToFileName(".notdef", None) == "_notdef.glif"
176        assert glyphNameToFileName("T_H", None) == "T__H_.glif"
177        assert glyphNameToFileName("T_h", None) == "T__h.glif"
178        assert glyphNameToFileName("t_h", None) == "t_h.glif"
179        assert glyphNameToFileName("F_F_I", None) == "F__F__I_.glif"
180        assert glyphNameToFileName("f_f_i", None) == "f_f_i.glif"
181        assert glyphNameToFileName("AE", None) == "A_E_.glif"
182        assert glyphNameToFileName("Ae", None) == "A_e.glif"
183        assert glyphNameToFileName("ae", None) == "ae.glif"
184        assert glyphNameToFileName("aE", None) == "aE_.glif"
185        assert glyphNameToFileName("a.alt", None) == "a.alt.glif"
186        assert glyphNameToFileName("A.aLt", None) == "A_.aL_t.glif"
187        assert glyphNameToFileName("A.alT", None) == "A_.alT_.glif"
188        assert glyphNameToFileName("Aacute_V.swash", None) == "A_acute_V_.swash.glif"
189        assert glyphNameToFileName(".notdef", None) == "_notdef.glif"
190        assert glyphNameToFileName("con", None) == "_con.glif"
191        assert glyphNameToFileName("CON", None) == "C_O_N_.glif"
192        assert glyphNameToFileName("con.alt", None) == "_con.alt.glif"
193        assert glyphNameToFileName("alt.con", None) == "alt._con.glif"
194
195    def test_conflicting_case_insensitive_file_names(self, tmp_path):
196        src = GlyphSet(GLYPHSETDIR)
197        dst = GlyphSet(tmp_path)
198        glyph = src["a"]
199
200        dst.writeGlyph("a", glyph)
201        dst.writeGlyph("A", glyph)
202        dst.writeGlyph("a_", glyph)
203        dst.deleteGlyph("a_")
204        dst.writeGlyph("a_", glyph)
205        dst.writeGlyph("A_", glyph)
206        dst.writeGlyph("i_j", glyph)
207
208        assert dst.contents == {
209            "a": "a.glif",
210            "A": "A_.glif",
211            "a_": "a_000000000000001.glif",
212            "A_": "A__.glif",
213            "i_j": "i_j.glif",
214        }
215
216        # make sure filenames are unique even on case-insensitive filesystems
217        assert len({fileName.lower() for fileName in dst.contents.values()}) == 5
218
219
220class _Glyph:
221    pass
222
223
224class ReadWriteFuncTest:
225    def test_roundtrip(self):
226        glyph = _Glyph()
227        glyph.name = "a"
228        glyph.unicodes = [0x0061]
229
230        s1 = writeGlyphToString(glyph.name, glyph)
231
232        glyph2 = _Glyph()
233        readGlyphFromString(s1, glyph2)
234        assert glyph.__dict__ == glyph2.__dict__
235
236        s2 = writeGlyphToString(glyph2.name, glyph2)
237        assert s1 == s2
238
239    def test_xml_declaration(self):
240        s = writeGlyphToString("a", _Glyph())
241        assert s.startswith(XML_DECLARATION % "UTF-8")
242
243    def test_parse_xml_remove_comments(self):
244        s = b"""<?xml version='1.0' encoding='UTF-8'?>
245		<!-- a comment -->
246		<glyph name="A" format="2">
247			<advance width="1290"/>
248			<unicode hex="0041"/>
249			<!-- another comment -->
250		</glyph>
251		"""
252
253        g = _Glyph()
254        readGlyphFromString(s, g)
255
256        assert g.name == "A"
257        assert g.width == 1290
258        assert g.unicodes == [0x0041]
259
260    def test_read_invalid_xml(self):
261        """Test that calling readGlyphFromString() with invalid XML raises a
262        library error, instead of an exception from the XML dependency that is
263        used internally."""
264
265        invalid_xml = b"<abc></def>"
266        empty_glyph = _Glyph()
267
268        with pytest.raises(GlifLibError, match="GLIF contains invalid XML"):
269            readGlyphFromString(invalid_xml, empty_glyph)
270
271    def test_read_unsupported_format_version(self, caplog):
272        s = """<?xml version='1.0' encoding='utf-8'?>
273		<glyph name="A" format="0" formatMinor="0">
274			<advance width="500"/>
275			<unicode hex="0041"/>
276		</glyph>
277		"""
278
279        with pytest.raises(UnsupportedGLIFFormat):
280            readGlyphFromString(s, _Glyph())  # validate=True by default
281
282        with pytest.raises(UnsupportedGLIFFormat):
283            readGlyphFromString(s, _Glyph(), validate=True)
284
285        caplog.clear()
286        with caplog.at_level(logging.WARNING, logger="fontTools.ufoLib.glifLib"):
287            readGlyphFromString(s, _Glyph(), validate=False)
288
289        assert len(caplog.records) == 1
290        assert "Unsupported GLIF format" in caplog.text
291        assert "Assuming the latest supported version" in caplog.text
292
293    def test_read_allow_format_versions(self):
294        s = """<?xml version='1.0' encoding='utf-8'?>
295		<glyph name="A" format="2">
296			<advance width="500"/>
297			<unicode hex="0041"/>
298		</glyph>
299		"""
300
301        # these two calls are are equivalent
302        readGlyphFromString(s, _Glyph(), formatVersions=[1, 2])
303        readGlyphFromString(s, _Glyph(), formatVersions=[(1, 0), (2, 0)])
304
305        # if at least one supported formatVersion, unsupported ones are ignored
306        readGlyphFromString(s, _Glyph(), formatVersions=[(2, 0), (123, 456)])
307
308        with pytest.raises(
309            ValueError, match="None of the requested GLIF formatVersions are supported"
310        ):
311            readGlyphFromString(s, _Glyph(), formatVersions=[0, 2001])
312
313        with pytest.raises(GlifLibError, match="Forbidden GLIF format version"):
314            readGlyphFromString(s, _Glyph(), formatVersions=[1])
315
316    def test_read_ensure_x_y(self):
317        """Ensure that a proper GlifLibError is raised when point coordinates are
318        missing, regardless of validation setting."""
319
320        s = """<?xml version='1.0' encoding='utf-8'?>
321		<glyph name="A" format="2">
322			<outline>
323				<contour>
324					<point x="545" y="0" type="line"/>
325					<point x="638" type="line"/>
326				</contour>
327			</outline>
328		</glyph>
329		"""
330        pen = RecordingPointPen()
331
332        with pytest.raises(GlifLibError, match="Required y attribute"):
333            readGlyphFromString(s, _Glyph(), pen)
334
335        with pytest.raises(GlifLibError, match="Required y attribute"):
336            readGlyphFromString(s, _Glyph(), pen, validate=False)
337
338
339def test_GlyphSet_unsupported_ufoFormatVersion(tmp_path, caplog):
340    with pytest.raises(UnsupportedUFOFormat):
341        GlyphSet(tmp_path, ufoFormatVersion=0)
342    with pytest.raises(UnsupportedUFOFormat):
343        GlyphSet(tmp_path, ufoFormatVersion=(0, 1))
344
345
346def test_GlyphSet_writeGlyph_formatVersion(tmp_path):
347    src = GlyphSet(GLYPHSETDIR)
348    dst = GlyphSet(tmp_path, ufoFormatVersion=(2, 0))
349    glyph = src["A"]
350
351    # no explicit formatVersion passed: use the more recent GLIF formatVersion
352    # that is supported by given ufoFormatVersion (GLIF 1 for UFO 2)
353    dst.writeGlyph("A", glyph)
354    glif = dst.getGLIF("A")
355    assert b'format="1"' in glif
356    assert b"formatMinor" not in glif  # omitted when 0
357
358    # explicit, unknown formatVersion
359    with pytest.raises(UnsupportedGLIFFormat):
360        dst.writeGlyph("A", glyph, formatVersion=(0, 0))
361
362    # explicit, known formatVersion but unsupported by given ufoFormatVersion
363    with pytest.raises(
364        UnsupportedGLIFFormat,
365        match="Unsupported GLIF format version .*for UFO format version",
366    ):
367        dst.writeGlyph("A", glyph, formatVersion=(2, 0))
368