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