1"""Helpers for writing unit tests.""" 2 3from collections.abc import Iterable 4from io import BytesIO 5import os 6import re 7import shutil 8import sys 9import tempfile 10from unittest import TestCase as _TestCase 11from fontTools.config import Config 12from fontTools.misc.textTools import tobytes 13from fontTools.misc.xmlWriter import XMLWriter 14 15 16def parseXML(xmlSnippet): 17 """Parses a snippet of XML. 18 19 Input can be either a single string (unicode or UTF-8 bytes), or a 20 a sequence of strings. 21 22 The result is in the same format that would be returned by 23 XMLReader, but the parser imposes no constraints on the root 24 element so it can be called on small snippets of TTX files. 25 """ 26 # To support snippets with multiple elements, we add a fake root. 27 reader = TestXMLReader_() 28 xml = b"<root>" 29 if isinstance(xmlSnippet, bytes): 30 xml += xmlSnippet 31 elif isinstance(xmlSnippet, str): 32 xml += tobytes(xmlSnippet, 'utf-8') 33 elif isinstance(xmlSnippet, Iterable): 34 xml += b"".join(tobytes(s, 'utf-8') for s in xmlSnippet) 35 else: 36 raise TypeError("expected string or sequence of strings; found %r" 37 % type(xmlSnippet).__name__) 38 xml += b"</root>" 39 reader.parser.Parse(xml, 0) 40 return reader.root[2] 41 42 43def parseXmlInto(font, parseInto, xmlSnippet): 44 parsed_xml = [e for e in parseXML(xmlSnippet.strip()) if not isinstance(e, str)] 45 for name, attrs, content in parsed_xml: 46 parseInto.fromXML(name, attrs, content, font) 47 parseInto.populateDefaults() 48 return parseInto 49 50 51class FakeFont: 52 def __init__(self, glyphs): 53 self.glyphOrder_ = glyphs 54 self.reverseGlyphOrderDict_ = {g: i for i, g in enumerate(glyphs)} 55 self.lazy = False 56 self.tables = {} 57 self.cfg = Config() 58 59 def __getitem__(self, tag): 60 return self.tables[tag] 61 62 def __setitem__(self, tag, table): 63 self.tables[tag] = table 64 65 def get(self, tag, default=None): 66 return self.tables.get(tag, default) 67 68 def getGlyphID(self, name): 69 return self.reverseGlyphOrderDict_[name] 70 71 def getGlyphIDMany(self, lst): 72 return [self.getGlyphID(gid) for gid in lst] 73 74 def getGlyphName(self, glyphID): 75 if glyphID < len(self.glyphOrder_): 76 return self.glyphOrder_[glyphID] 77 else: 78 return "glyph%.5d" % glyphID 79 def getGlyphNameMany(self, lst): 80 return [self.getGlyphName(gid) for gid in lst] 81 82 def getGlyphOrder(self): 83 return self.glyphOrder_ 84 85 def getReverseGlyphMap(self): 86 return self.reverseGlyphOrderDict_ 87 88 def getGlyphNames(self): 89 return sorted(self.getGlyphOrder()) 90 91 92class TestXMLReader_(object): 93 def __init__(self): 94 from xml.parsers.expat import ParserCreate 95 self.parser = ParserCreate() 96 self.parser.StartElementHandler = self.startElement_ 97 self.parser.EndElementHandler = self.endElement_ 98 self.parser.CharacterDataHandler = self.addCharacterData_ 99 self.root = None 100 self.stack = [] 101 102 def startElement_(self, name, attrs): 103 element = (name, attrs, []) 104 if self.stack: 105 self.stack[-1][2].append(element) 106 else: 107 self.root = element 108 self.stack.append(element) 109 110 def endElement_(self, name): 111 self.stack.pop() 112 113 def addCharacterData_(self, data): 114 self.stack[-1][2].append(data) 115 116 117def makeXMLWriter(newlinestr='\n'): 118 # don't write OS-specific new lines 119 writer = XMLWriter(BytesIO(), newlinestr=newlinestr) 120 # erase XML declaration 121 writer.file.seek(0) 122 writer.file.truncate() 123 return writer 124 125 126def getXML(func, ttFont=None): 127 """Call the passed toXML function and return the written content as a 128 list of lines (unicode strings). 129 Result is stripped of XML declaration and OS-specific newline characters. 130 """ 131 writer = makeXMLWriter() 132 func(writer, ttFont) 133 xml = writer.file.getvalue().decode("utf-8") 134 # toXML methods must always end with a writer.newline() 135 assert xml.endswith("\n") 136 return xml.splitlines() 137 138 139def stripVariableItemsFromTTX( 140 string: str, 141 ttLibVersion: bool = True, 142 checkSumAdjustment: bool = True, 143 modified: bool = True, 144 created: bool = True, 145 sfntVersion: bool = False, # opt-in only 146) -> str: 147 """Strip stuff like ttLibVersion, checksums, timestamps, etc. from TTX dumps.""" 148 # ttlib changes with the fontTools version 149 if ttLibVersion: 150 string = re.sub(' ttLibVersion="[^"]+"', "", string) 151 # sometimes (e.g. some subsetter tests) we don't care whether it's OTF or TTF 152 if sfntVersion: 153 string = re.sub(' sfntVersion="[^"]+"', "", string) 154 # head table checksum and creation and mod date changes with each save. 155 if checkSumAdjustment: 156 string = re.sub('<checkSumAdjustment value="[^"]+"/>', "", string) 157 if modified: 158 string = re.sub('<modified value="[^"]+"/>', "", string) 159 if created: 160 string = re.sub('<created value="[^"]+"/>', "", string) 161 return string 162 163 164class MockFont(object): 165 """A font-like object that automatically adds any looked up glyphname 166 to its glyphOrder.""" 167 168 def __init__(self): 169 self._glyphOrder = ['.notdef'] 170 171 class AllocatingDict(dict): 172 def __missing__(reverseDict, key): 173 self._glyphOrder.append(key) 174 gid = len(reverseDict) 175 reverseDict[key] = gid 176 return gid 177 self._reverseGlyphOrder = AllocatingDict({'.notdef': 0}) 178 self.lazy = False 179 180 def getGlyphID(self, glyph): 181 gid = self._reverseGlyphOrder[glyph] 182 return gid 183 184 def getReverseGlyphMap(self): 185 return self._reverseGlyphOrder 186 187 def getGlyphName(self, gid): 188 return self._glyphOrder[gid] 189 190 def getGlyphOrder(self): 191 return self._glyphOrder 192 193 194class TestCase(_TestCase): 195 196 def __init__(self, methodName): 197 _TestCase.__init__(self, methodName) 198 # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, 199 # and fires deprecation warnings if a program uses the old name. 200 if not hasattr(self, "assertRaisesRegex"): 201 self.assertRaisesRegex = self.assertRaisesRegexp 202 203 204class DataFilesHandler(TestCase): 205 206 def setUp(self): 207 self.tempdir = None 208 self.num_tempfiles = 0 209 210 def tearDown(self): 211 if self.tempdir: 212 shutil.rmtree(self.tempdir) 213 214 def getpath(self, testfile): 215 folder = os.path.dirname(sys.modules[self.__module__].__file__) 216 return os.path.join(folder, "data", testfile) 217 218 def temp_dir(self): 219 if not self.tempdir: 220 self.tempdir = tempfile.mkdtemp() 221 222 def temp_font(self, font_path, file_name): 223 self.temp_dir() 224 temppath = os.path.join(self.tempdir, file_name) 225 shutil.copy2(font_path, temppath) 226 return temppath 227