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