Repository: fonttools/fonttools

Issue:
Weird XML output for ttProgram.Program.fromAssembly()
When a TrueType program is built from assembly, and then immediately dumped to XML, the output format is very weird, one character per line.

```python
from fontTools.misc.xmlWriter import XMLWriter
from fontTools.ttLib.tables.ttProgram import Program
from io import BytesIO


p = Program()
p.fromAssembly("PUSHB[ ]\n0 0\nPOP[ ]\nPOP[ ]")

buf = BytesIO()

p.toXML(XMLWriter(buf), None)
print(buf.getvalue())
```

```xml
b'<?xml version="1.0" encoding="UTF-8"?>\n<assembly>\n  P\n  U\n  S\n  H\n  B\n  [\n   \n  ]\n  \n\n  0\n   \n  0\n  \n\n  P\n  O\n  P\n  [\n   \n  ]\n  \n\n  P\n  O\n  P\n  [\n   \n  ]\n</assembly>\n'
```

When the program is assembled to bytecode, and then read from bytecode with Program.fromBytecode, and then dumped to XML, it looks as seen in ttx dumps.

```python
from fontTools.misc.xmlWriter import XMLWriter
from fontTools.ttLib.tables.ttProgram import Program
from io import BytesIO


p = Program()
p.fromAssembly("PUSHB[ ]\n0 0\nPOP[ ]\nPOP[ ]")
p.fromBytecode(p.getBytecode())

buf = BytesIO()

p.toXML(XMLWriter(buf), None)
print(buf.getvalue())
```

```xml
b'<?xml version="1.0" encoding="UTF-8"?>\n<assembly>\n  PUSHB[ ]\t/* 2 values pushed */\n  0 0\n  POP[ ]\t/* PopTopStack */\n  POP[ ]\t/* PopTopStack */\n</assembly>\n'
```

Functionally, there seems to be no difference in the two notations. But is this expected?


Discussion:
looks like a bug to me
The `toXML` here expects assembly to be a list of instructions...
> The toXML here

"here" where?
> > The toXML here
> 
> "here" where?

`ttProgram.py::toXML`.
What happens is that the assembler accepts both a string and a list of strings. The disassembler always produces list of strings, which is what the toXML expects.
thanks behdad for the clarification. Jens, can you work on a PR maybe? thank u for reporting this
D'oh, of course it works with a list. Thank you, @behdad!

Resolving patch:
diff --git a/Lib/fontTools/ttLib/tables/ttProgram.py b/Lib/fontTools/ttLib/tables/ttProgram.py
--- a/Lib/fontTools/ttLib/tables/ttProgram.py
+++ b/Lib/fontTools/ttLib/tables/ttProgram.py
@@ -1,8 +1,10 @@
 """ttLib.tables.ttProgram.py -- Assembler/disassembler for TrueType bytecode programs."""
+from __future__ import annotations
 
 from fontTools.misc.textTools import num2binary, binary2num, readHex, strjoin
 import array
 from io import StringIO
+from typing import List
 import re
 import logging
 
@@ -1064,30 +1066,35 @@ def _skipWhite(data, pos):
 
 
 class Program(object):
-    def __init__(self):
+    def __init__(self) -> None:
         pass
 
-    def fromBytecode(self, bytecode):
+    def fromBytecode(self, bytecode: bytes) -> None:
         self.bytecode = array.array("B", bytecode)
         if hasattr(self, "assembly"):
             del self.assembly
 
-    def fromAssembly(self, assembly):
-        self.assembly = assembly
+    def fromAssembly(self, assembly: List[str] | str) -> None:
+        if isinstance(assembly, list):
+            self.assembly = assembly
+        elif isinstance(assembly, str):
+            self.assembly = assembly.splitlines()
+        else:
+            raise TypeError(f"expected str or List[str], got {type(assembly).__name__}")
         if hasattr(self, "bytecode"):
             del self.bytecode
 
-    def getBytecode(self):
+    def getBytecode(self) -> bytes:
         if not hasattr(self, "bytecode"):
             self._assemble()
         return self.bytecode.tobytes()
 
-    def getAssembly(self, preserve=True):
+    def getAssembly(self, preserve=True) -> List[str]:
         if not hasattr(self, "assembly"):
             self._disassemble(preserve=preserve)
         return self.assembly
 
-    def toXML(self, writer, ttFont):
+    def toXML(self, writer, ttFont) -> None:
         if (
             not hasattr(ttFont, "disassembleInstructions")
             or ttFont.disassembleInstructions
@@ -1128,7 +1135,7 @@ def toXML(self, writer, ttFont):
                     i = i + 1
                     if m:
                         nValues = int(m.group(1))
-                        line = []
+                        line: List[str] = []
                         j = 0
                         for j in range(nValues):
                             if j and not (j % 25):
@@ -1155,7 +1162,7 @@ def toXML(self, writer, ttFont):
             writer.endtag("bytecode")
             writer.newline()
 
-    def fromXML(self, name, attrs, content, ttFont):
+    def fromXML(self, name, attrs, content, ttFont) -> None:
         if name == "assembly":
             self.fromAssembly(strjoin(content))
             self._assemble()
@@ -1164,11 +1171,9 @@ def fromXML(self, name, attrs, content, ttFont):
             assert name == "bytecode"
             self.fromBytecode(readHex(content))
 
-    def _assemble(self):
-        assembly = getattr(self, "assembly", [])
-        if isinstance(assembly, type([])):
-            assembly = " ".join(assembly)
-        bytecode = []
+    def _assemble(self) -> None:
+        assembly = " ".join(getattr(self, "assembly", []))
+        bytecode: List[int] = []
         push = bytecode.append
         lenAssembly = len(assembly)
         pos = _skipWhite(assembly, 0)
@@ -1311,7 +1316,7 @@ def _assemble(self):
             assert max(bytecode) < 256 and min(bytecode) >= 0
         self.bytecode = array.array("B", bytecode)
 
-    def _disassemble(self, preserve=False):
+    def _disassemble(self, preserve=False) -> None:
         assembly = []
         i = 0
         bytecode = getattr(self, "bytecode", [])
@@ -1376,7 +1381,7 @@ def _disassemble(self, preserve=False):
                 i = i + 1
         self.assembly = assembly
 
-    def __bool__(self):
+    def __bool__(self) -> bool:
         """
         >>> p = Program()
         >>> bool(p)
@@ -1406,12 +1411,12 @@ def __bool__(self):
 
     __nonzero__ = __bool__
 
-    def __eq__(self, other):
+    def __eq__(self, other) -> bool:
         if type(self) != type(other):
             return NotImplemented
         return self.__dict__ == other.__dict__
 
-    def __ne__(self, other):
+    def __ne__(self, other) -> bool:
         result = self.__eq__(other)
         return result if result is NotImplemented else not result
 


Test patch:
diff --git a/Tests/ttLib/tables/ttProgram_test.py b/Tests/ttLib/tables/ttProgram_test.py
--- a/Tests/ttLib/tables/ttProgram_test.py
+++ b/Tests/ttLib/tables/ttProgram_test.py
@@ -12,6 +12,13 @@
 TTPROGRAM_TTX = os.path.join(DATA_DIR, "ttProgram.ttx")
 # TTPROGRAM_BIN = os.path.join(DATA_DIR, "ttProgram.bin")
 
+ASSEMBLY = [
+    "PUSH[ ]",
+    "0 4 3",
+    "INSTCTRL[ ]",
+    "POP[ ]",
+]
+
 BYTECODE = deHexStr(
     "403b3a393837363534333231302f2e2d2c2b2a292827262524232221201f1e1d1c1b1a"
     "191817161514131211100f0e0d0c0b0a090807060504030201002c01b0184358456ab0"
@@ -90,6 +97,18 @@ def test__bool__(self):
         assert p.assembly.pop() == "SVTCA[0]"
         assert not bool(p)
 
+    def test_from_assembly_list(self):
+        p = Program()
+        p.fromAssembly(ASSEMBLY)
+        asm = p.getAssembly()
+        assert ASSEMBLY == asm
+
+    def test_from_assembly_str(self):
+        p = Program()
+        p.fromAssembly("\n".join(ASSEMBLY))
+        asm = p.getAssembly()
+        assert ASSEMBLY == asm
+
     def test_roundtrip(self):
         p = Program()
         p.fromBytecode(BYTECODE)