From ee1662e57e794b3dae1cc0ea31a4062a9f11a20b Mon Sep 17 00:00:00 2001 From: Sascha Brawer Date: Tue, 5 Sep 2017 18:32:05 +0200 Subject: [PATCH] [AAT] Support `morx` tables with contextual substitution --- Lib/fontTools/ttLib/tables/otConverters.py | 89 +++++++- Lib/fontTools/ttLib/tables/otData.py | 5 +- Lib/fontTools/ttLib/tables/otTables.py | 103 +++++++-- Tests/ttLib/tables/_m_o_r_x_test.py | 254 ++++++++++++++++++++- Tests/ttLib/tables/otTables_test.py | 34 ++- 5 files changed, 456 insertions(+), 29 deletions(-) diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 94f18bc55..5b04417a3 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -6,7 +6,8 @@ from fontTools.misc.fixedTools import ( from fontTools.misc.textTools import pad, safeEval from fontTools.ttLib import getSearchRange from .otBase import ValueRecordFactory, CountReference, OTTableWriter -from .otTables import AATStateTable, AATState +from .otTables import (AATStateTable, AATState, AATAction, + ContextualMorphAction) from functools import partial import struct import logging @@ -880,8 +881,13 @@ class AATLookupWithDataOffset(BaseConverter): class STXHeader(BaseConverter): def __init__(self, name, repeat, aux, tableClass): BaseConverter.__init__(self, name, repeat, aux, tableClass) - assert tableClass is not None + assert issubclass(self.tableClass, AATAction) self.classLookup = AATLookup("GlyphClasses", None, None, UShort) + if issubclass(self.tableClass, ContextualMorphAction): + self.perGlyphLookup = AATLookup("PerGlyphLookup", + None, None, GlyphID) + else: + self.perGlyphLookup = None def read(self, reader, font, tableDict): table = AATStateTable() @@ -893,6 +899,9 @@ class STXHeader(BaseConverter): classTableReader.seek(pos + reader.readULong()) stateArrayReader.seek(pos + reader.readULong()) entryTableReader.seek(pos + reader.readULong()) + if self.perGlyphLookup is not None: + perGlyphTableReader = reader.getSubReader(0) + perGlyphTableReader.seek(pos + reader.readULong()) table.GlyphClasses = self.classLookup.read(classTableReader, font, tableDict) numStates = int((entryTableReader.pos - stateArrayReader.pos) @@ -905,6 +914,9 @@ class STXHeader(BaseConverter): state.Transitions[glyphClass] = \ self._readTransition(entryTableReader, entryIndex, font) + if self.perGlyphLookup is not None: + table.PerGlyphLookups = self._readPerGlyphLookups( + table, perGlyphTableReader, font) return table def _readTransition(self, reader, entryIndex, font): @@ -914,6 +926,35 @@ class STXHeader(BaseConverter): transition.decompile(entryReader, font) return transition + def _countPerGlyphLookups(self, table): + # Somewhat annoyingly, the morx table does not encode + # the size of the per-glyph table. So we need to find + # the maximum value that MorphActions use as index + # into this table. + numLookups = 0 + for state in table.States: + for t in state.Transitions.values(): + if isinstance(t, ContextualMorphAction): + if t.MarkIndex != 0xFFFF: + numLookups = max( + numLookups, + t.MarkIndex + 1) + if t.CurrentIndex != 0xFFFF: + numLookups = max( + numLookups, + t.CurrentIndex + 1) + return numLookups + + def _readPerGlyphLookups(self, table, reader, font): + pos = reader.pos + lookups = [] + for _ in range(self._countPerGlyphLookups(table)): + lookupReader = reader.getSubReader(0) + lookupReader.seek(pos + reader.readULong()) + lookups.append( + self.perGlyphLookup.read(lookupReader, font, {})) + return lookups + def write(self, writer, font, tableDict, value, repeatIndex=None): glyphClassWriter = OTTableWriter() self.classLookup.write(glyphClassWriter, font, tableDict, @@ -921,6 +962,8 @@ class STXHeader(BaseConverter): glyphClassData = pad(glyphClassWriter.getAllData(), 4) glyphClassCount = max(value.GlyphClasses.values()) + 1 glyphClassTableOffset = 16 # size of STXHeader + if self.perGlyphLookup is not None: + glyphClassTableOffset += 4 stateArrayWriter = OTTableWriter() entries, entryIDs = [], {} for state in value.States: @@ -942,16 +985,39 @@ class STXHeader(BaseConverter): entries.append(entryData) stateArrayWriter.writeUShort(entryIndex) stateArrayOffset = glyphClassTableOffset + len(glyphClassData) - stateArrayData = stateArrayWriter.getAllData() + stateArrayData = pad(stateArrayWriter.getAllData(), 4) entryTableOffset = stateArrayOffset + len(stateArrayData) + entryTableData = pad(bytesjoin(entries), 4) + perGlyphOffset = entryTableOffset + len(entryTableData) + perGlyphData = \ + pad(self._compilePerGlyphLookups(value, font), 4) writer.writeULong(glyphClassCount) writer.writeULong(glyphClassTableOffset) writer.writeULong(stateArrayOffset) writer.writeULong(entryTableOffset) + if self.perGlyphLookup is not None: + writer.writeULong(perGlyphOffset) writer.writeData(glyphClassData) writer.writeData(stateArrayData) - for entry in entries: - writer.writeData(entry) + writer.writeData(entryTableData) + writer.writeData(perGlyphData) + + def _compilePerGlyphLookups(self, table, font): + if self.perGlyphLookup is None: + return b"" + numLookups = self._countPerGlyphLookups(table) + assert len(table.PerGlyphLookups) == numLookups, ( + "len(AATStateTable.PerGlyphLookups) is %d, " + "but the actions inside the table refer to %d" % + (len(table.PerGlyphLookups), numLookups)) + writer = OTTableWriter() + for lookup in table.PerGlyphLookups: + lookupWriter = writer.getSubWriter() + lookupWriter.longOffset = True + self.perGlyphLookup.write(lookupWriter, font, + {}, lookup, None) + writer.writeSubTable(lookupWriter) + return writer.getAllData() def xmlWrite(self, xmlWriter, font, value, name, attrs): xmlWriter.begintag(name, attrs) @@ -970,6 +1036,15 @@ class STXHeader(BaseConverter): name="Transition") xmlWriter.endtag("State") xmlWriter.newline() + for i, lookup in enumerate(value.PerGlyphLookups): + xmlWriter.begintag("PerGlyphLookup", index=i) + xmlWriter.newline() + for glyph, val in sorted(lookup.items()): + xmlWriter.simpletag("Lookup", glyph=glyph, + value=val) + xmlWriter.newline() + xmlWriter.endtag("PerGlyphLookup") + xmlWriter.newline() xmlWriter.endtag(name) xmlWriter.newline() @@ -983,6 +1058,10 @@ class STXHeader(BaseConverter): elif eltName == "State": state = self._xmlReadState(eltAttrs, eltContent, font) table.States.append(state) + elif eltName == "PerGlyphLookup": + lookup = self.perGlyphLookup.xmlRead( + eltAttrs, eltContent, font) + table.PerGlyphLookups.append(lookup) table.GlyphClassCount = max(table.GlyphClasses.values()) + 1 return table diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index f1381c12f..c1615ad11 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1387,12 +1387,11 @@ otData = [ ]), ('RearrangementMorph', [ - ('STXHeader(AATRearrangement)', 'StateTable', None, None, 'Finite-state transducer table.'), + ('STXHeader(RearrangementMorphAction)', 'StateTable', None, None, 'Finite-state transducer table for indic rearrangement.'), ]), ('ContextualMorph', [ - ('struct', 'StateHeader', None, None, 'Header.'), - # TODO: Add missing parts. + ('STXHeader(ContextualMorphAction)', 'StateTable', None, None, 'Finite-state transducer for contextual glyph substitution.'), ]), ('LigatureMorph', [ diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index cc3353464..95b0cc302 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -18,16 +18,38 @@ log = logging.getLogger(__name__) class AATStateTable(object): def __init__(self): - self.GlyphClasses = {} # GlyphName --> GlyphClass + self.GlyphClasses = {} # GlyphID --> GlyphClass self.States = [] # List of AATState, indexed by state number + self.PerGlyphLookups = [] # [{GlyphID:GlyphID}, ...] + class AATState(object): def __init__(self): - self.Transitions = {} # GlyphClass --> {AATRearrangement, ...} + self.Transitions = {} # GlyphClass --> AATAction -class AATRearrangement(object): +class AATAction(object): + _FLAGS = None + + def _writeFlagsToXML(self, xmlWriter): + flags = [f for f in self._FLAGS if self.__dict__[f]] + if flags: + xmlWriter.simpletag("Flags", value=",".join(flags)) + xmlWriter.newline() + if self.ReservedFlags != 0: + xmlWriter.simpletag( + "ReservedFlags", + value='0x%04X' % self.ReservedFlags) + xmlWriter.newline() + + def _setFlag(self, flag): + assert flag in self._FLAGS, "unsupported flag %s" % flag + self.__dict__[flag] = True + + +class RearrangementMorphAction(AATAction): staticSize = 4 + _FLAGS = ["MarkFirst", "DontAdvance", "MarkLast"] _VERBS = { 0: "no change", @@ -79,15 +101,7 @@ class AATRearrangement(object): xmlWriter.newline() xmlWriter.simpletag("NewState", value=self.NewState) xmlWriter.newline() - flags = [f for f in ("MarkFirst", "DontAdvance", "MarkLast") - if self.__dict__[f]] - if flags: - xmlWriter.simpletag("Flags", value=",".join(flags)) - xmlWriter.newline() - if self.ReservedFlags != 0: - xmlWriter.simpletag("ReservedFlags", - value='0x%04X' % self.ReservedFlags) - xmlWriter.newline() + self._writeFlagsToXML(xmlWriter) xmlWriter.simpletag("Verb", value=self.Verb) verbComment = self._VERBS.get(self.Verb) if verbComment is not None: @@ -111,11 +125,66 @@ class AATRearrangement(object): for flag in eltAttrs["value"].split(","): self._setFlag(flag.strip()) - def _setFlag(self, flag): - assert flag in {"MarkFirst", "DontAdvance", "MarkLast"}, \ - "unsupported flag %s" % flag - self.__dict__[flag] = True +class ContextualMorphAction(AATAction): + staticSize = 8 + _FLAGS = ["SetMark", "DontAdvance"] + + def __init__(self): + self.NewState = 0 + self.SetMark, self.DontAdvance = False, False + self.ReservedFlags = 0 + self.MarkIndex, self.CurrentIndex = 0xFFFF, 0xFFFF + + def compile(self, writer, font): + writer.writeUShort(self.NewState) + flags = self.ReservedFlags + if self.SetMark: flags |= 0x8000 + if self.DontAdvance: flags |= 0x4000 + writer.writeUShort(flags) + writer.writeUShort(self.MarkIndex) + writer.writeUShort(self.CurrentIndex) + + def decompile(self, reader, font): + self.NewState = reader.readUShort() + flags = reader.readUShort() + self.SetMark = bool(flags & 0x8000) + self.DontAdvance = bool(flags & 0x4000) + self.ReservedFlags = flags & 0x3FFF + self.MarkIndex = reader.readUShort() + self.CurrentIndex = reader.readUShort() + + def toXML(self, xmlWriter, font, attrs, name): + xmlWriter.begintag(name, **attrs) + xmlWriter.newline() + xmlWriter.simpletag("NewState", value=self.NewState) + xmlWriter.newline() + self._writeFlagsToXML(xmlWriter) + xmlWriter.simpletag("MarkIndex", value=self.MarkIndex) + xmlWriter.newline() + xmlWriter.simpletag("CurrentIndex", + value=self.CurrentIndex) + xmlWriter.newline() + xmlWriter.endtag(name) + xmlWriter.newline() + + def fromXML(self, name, attrs, content, font): + self.NewState = self.ReservedFlags = 0 + self.SetMark = self.DontAdvance = False + self.MarkIndex, self.CurrentIndex = 0xFFFF, 0xFFFF + content = [t for t in content if isinstance(t, tuple)] + for eltName, eltAttrs, eltContent in content: + if eltName == "NewState": + self.NewState = safeEval(eltAttrs["value"]) + elif eltName == "Flags": + for flag in eltAttrs["value"].split(","): + self._setFlag(flag.strip()) + elif eltName == "ReservedFlags": + self.ReservedFlags = safeEval(eltAttrs["value"]) + elif eltName == "MarkIndex": + self.MarkIndex = safeEval(eltAttrs["value"]) + elif eltName == "CurrentIndex": + self.CurrentIndex = safeEval(eltAttrs["value"]) class FeatureParams(BaseTable): @@ -1097,7 +1166,7 @@ def _buildClasses(): }, 'morx': { 0: RearrangementMorph, - # 1: ContextualMorph, + 1: ContextualMorph, # 2: LigatureMorph, # 3: Reserved, 4: NoncontextualMorph, diff --git a/Tests/ttLib/tables/_m_o_r_x_test.py b/Tests/ttLib/tables/_m_o_r_x_test.py index 9206e0b5e..2c38b93f8 100644 --- a/Tests/ttLib/tables/_m_o_r_x_test.py +++ b/Tests/ttLib/tables/_m_o_r_x_test.py @@ -90,7 +90,7 @@ MORX_REARRANGEMENT_DATA = deHexStr( '0002 0000 ' # 0: Version=2, Reserved=0 '0000 0001 ' # 4: MorphChainCount=1 '0000 0001 ' # 8: DefaultFlags=1 - '0000 0078 ' # 12: StructLength=120 + '0000 0078 ' # 12: StructLength=120 (+8=128) '0000 0000 ' # 16: MorphFeatureCount=0 '0000 0001 ' # 20: MorphSubtableCount=1 '0000 0068 ' # 24: Subtable[0].StructLength=104 (+24=128) @@ -233,6 +233,233 @@ MORX_REARRANGEMENT_XML = [ ] +# Taken from “Example 1: A contextal substituation table” in +# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6morx.html +# as retrieved on 2017-09-05. +# +# Compared to the example table in Apple’s specification, we’ve +# made the following changes: +# +# * at offsets 0..35, we’ve prepended 36 bytes of boilerplate +# to make the data a structurally valid ‘morx’ table; +# +# * at offset 36 (offset 0 in Apple’s document), we’ve changed +# the number of glyph classes from 5 to 6 because the encoded +# finite-state machine has transitions for six different glyph +# classes (0..5); +# +# * at offset 52 (offset 16 in Apple’s document), we’ve replaced +# the presumably leftover ‘XXX’ mark by an actual data offset; +# +# * at offset 72 (offset 36 in Apple’s document), we’ve changed +# the input GlyphID from 51 to 52. With the original value of 51, +# the glyph class lookup table can be encoded with equally many +# bytes in either format 2 or 6; after changing the GlyphID to 52, +# the most compact encoding is lookup format 6, as used in Apple’s +# example; +# +# * at offset 90 (offset 54 in Apple’s document), we’ve changed +# the value for the lookup end-of-table marker from 1 to 0. +# Fonttools always uses zero for this value, whereas Apple’s +# spec examples are inconsistently using one of {0, 1, 0xFFFF} +# for this filler value; +# +# * at offset 172 (offset 136 in Apple’s document), we’ve again changed +# the input GlyphID from 51 to 52, for the same reason as above. +# +# TODO: Ask Apple to fix “Example 1” in the ‘morx’ specification. +MORX_CONTEXTUAL_DATA = deHexStr( + '0002 0000 ' # 0: Version=2, Reserved=0 + '0000 0001 ' # 4: MorphChainCount=1 + '0000 0001 ' # 8: DefaultFlags=1 + '0000 00B4 ' # 12: StructLength=180 (+8=188) + '0000 0000 ' # 16: MorphFeatureCount=0 + '0000 0001 ' # 20: MorphSubtableCount=1 + '0000 00A4 ' # 24: Subtable[0].StructLength=164 (+24=188) + '80 ' # 28: Subtable[0].CoverageFlags=0x80 + '00 00 ' # 29: Subtable[0].Reserved=0 + '01 ' # 31: Subtable[0].MorphType=1/ContextualMorph + '0000 0001 ' # 32: Subtable[0].SubFeatureFlags=0x1 + '0000 0006 ' # 36: STXHeader.ClassCount=6 + '0000 0014 ' # 40: STXHeader.ClassTableOffset=20 (+36=56) + '0000 0038 ' # 44: STXHeader.StateArrayOffset=56 (+36=92) + '0000 005C ' # 48: STXHeader.EntryTableOffset=92 (+36=128) + '0000 0074 ' # 52: STXHeader.PerGlyphTableOffset=116 (+36=152) + + # Glyph class table. + '0006 0004 ' # 56: ClassTable.LookupFormat=6, .UnitSize=4 + '0005 0010 ' # 60: .NUnits=5, .SearchRange=16 + '0002 0004 ' # 64: .EntrySelector=2, .RangeShift=4 + '0032 0004 ' # 68: Glyph=50; Class=4 + '0034 0004 ' # 72: Glyph=52; Class=4 + '0050 0005 ' # 76: Glyph=80; Class=5 + '00C9 0004 ' # 80: Glyph=201; Class=4 + '00CA 0004 ' # 84: Glyph=202; Class=4 + 'FFFF 0000 ' # 88: Glyph=; Value= + + # State array. + '0000 0000 0000 0000 0000 0001 ' # 92: State[0][0..5] + '0000 0000 0000 0000 0000 0001 ' # 104: State[1][0..5] + '0000 0000 0000 0000 0002 0001 ' # 116: State[2][0..5] + + # Entry table. + '0000 0000 ' # 128: Entries[0].NewState=0, .Flags=0 + 'FFFF FFFF ' # 132: Entries[0].MarkSubst=None, .CurSubst=None + '0002 0000 ' # 136: Entries[1].NewState=2, .Flags=0 + 'FFFF FFFF ' # 140: Entries[1].MarkSubst=None, .CurSubst=None + '0000 0000 ' # 144: Entries[2].NewState=0, .Flags=0 + 'FFFF 0000 ' # 148: Entries[2].MarkSubst=None, .CurSubst=PerGlyph #0 + # 152: + + # Per-glyph lookup tables. + '0000 0004 ' # 152: Offset from this point to per-glyph lookup #0. + + # Per-glyph lookup #0. + '0006 0004 ' # 156: ClassTable.LookupFormat=6, .UnitSize=4 + '0004 0010 ' # 160: .NUnits=4, .SearchRange=16 + '0002 0000 ' # 164: .EntrySelector=2, .RangeShift=0 + '0032 0258 ' # 168: Glyph=50; ReplacementGlyph=600 + '0034 0259 ' # 172: Glyph=52; ReplacementGlyph=601 + '00C9 025A ' # 176: Glyph=201; ReplacementGlyph=602 + '00CA 0384 ' # 180: Glyph=202; ReplacementGlyph=900 + 'FFFF 0000 ' # 184: Glyph=; Value= + +) # 188: +assert len(MORX_CONTEXTUAL_DATA) == 188, len(MORX_CONTEXTUAL_DATA) + + +MORX_CONTEXTUAL_XML = [ + '', + '', + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '', +] + + class MORXNoncontextualGlyphSubstitutionTest(unittest.TestCase): @classmethod @@ -276,6 +503,31 @@ class MORXRearrangementTest(unittest.TestCase): hexStr(MORX_REARRANGEMENT_DATA)) +class MORXContextualSubstitutionTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.maxDiff = None + g = ['.notdef'] + ['g.%d' % i for i in range (1, 910)] + g[80] = 'C' + g[50], g[52], g[201], g[202] = 'A', 'B', 'X', 'Y' + g[600], g[601], g[602], g[900] = ( + 'A.swash', 'B.swash', 'X.swash', 'Y.swash') + cls.font = FakeFont(g) + + def test_decompile_toXML(self): + table = newTable('morx') + table.decompile(MORX_CONTEXTUAL_DATA, self.font) + self.assertEqual(getXML(table.toXML), MORX_CONTEXTUAL_XML) + + def test_compile_fromXML(self): + table = newTable('morx') + for name, attrs, content in parseXML(MORX_CONTEXTUAL_XML): + table.fromXML(name, attrs, content, font=self.font) + self.assertEqual(hexStr(table.compile(self.font)), + hexStr(MORX_CONTEXTUAL_DATA)) + + if __name__ == '__main__': import sys sys.exit(unittest.main()) diff --git a/Tests/ttLib/tables/otTables_test.py b/Tests/ttLib/tables/otTables_test.py index c85c6d3c9..4ea0d57eb 100644 --- a/Tests/ttLib/tables/otTables_test.py +++ b/Tests/ttLib/tables/otTables_test.py @@ -370,12 +370,12 @@ class AlternateSubstTest(unittest.TestCase): }) -class AATRearrangementTest(unittest.TestCase): +class RearrangementMorphActionTest(unittest.TestCase): def setUp(self): self.font = FakeFont(['.notdef', 'A', 'B', 'C']) def testCompile(self): - r = otTables.AATRearrangement() + r = otTables.RearrangementMorphAction() r.NewState = 0x1234 r.MarkFirst = r.DontAdvance = r.MarkLast = True r.ReservedFlags, r.Verb = 0x1FF0, 0xD @@ -384,7 +384,7 @@ class AATRearrangementTest(unittest.TestCase): self.assertEqual(hexStr(writer.getAllData()), "1234fffd") def testDecompileToXML(self): - r = otTables.AATRearrangement() + r = otTables.RearrangementMorphAction() r.decompile(OTTableReader(deHexStr("1234fffd")), self.font) toXML = lambda w, f: r.toXML(w, f, {"Test": "Foo"}, "Transition") self.assertEqual(getXML(toXML, self.font), [ @@ -397,6 +397,34 @@ class AATRearrangementTest(unittest.TestCase): ]) +class ContextualMorphActionTest(unittest.TestCase): + def setUp(self): + self.font = FakeFont(['.notdef', 'A', 'B', 'C']) + + def testCompile(self): + a = otTables.ContextualMorphAction() + a.NewState = 0x1234 + a.SetMark, a.DontAdvance, a.ReservedFlags = True, True, 0x3117 + a.MarkIndex, a.CurrentIndex = 0xDEAD, 0xBEEF + writer = OTTableWriter() + a.compile(writer, self.font) + self.assertEqual(hexStr(writer.getAllData()), "1234f117deadbeef") + + def testDecompileToXML(self): + a = otTables.ContextualMorphAction() + a.decompile(OTTableReader(deHexStr("1234f117deadbeef")), self.font) + toXML = lambda w, f: a.toXML(w, f, {"Test": "Foo"}, "Transition") + self.assertEqual(getXML(toXML, self.font), [ + '', + ' ', # 0x1234 = 4660 + ' ', + ' ', + ' ', # 0xDEAD = 57005 + ' ', # 0xBEEF = 48879 + '', + ]) + + if __name__ == "__main__": import sys sys.exit(unittest.main())