Repository: fonttools/fonttools

Issue:
[fontBuilder] Default panose value is set to a dict
When using fontBuilder to build a font, the default panose is set as a dictionary and not a object namespace causing issues downstream.

### Discussed in https://github.com/fonttools/fonttools/discussions/3196

<div type='discussions-op-text'>

<sup>Originally posted by **NightFurySL2001** July  2, 2023</sup>
3. `extractor` raises two error: `AttributeError: 'dict' object has no attribute 'bFamilyType'` ...
I can't catch the bug for the former. Maybe it's due to `fontbuilder` doesn't build panose?
</div>

<div type='discussions-op-text'>
<sup>Originally posted by @anthrotype in https://github.com/robotools/extractor/issues/58#issuecomment-1617773977</sup>

looks to me like a bug in the fontBuilder, because it leaves the OS/2 table's `panose` attribute as a dictionary (as in `_panoseDefaults`), whereas it should set it to a `Panose` object like the OS/2 table's decompile method does. The reason it works when saving from the fontBuilder is that the fonttools' `sstruct.pack` method accepts either an object or a dictionary and so compile works regardless.
We should fix it in fonttools. @NightFurySL2001 do you mind filing an issue or working on a PR even?
As a workaround, you could temporarily save the fontBuilder's font (to a tempfile or BytesIO stream), reload it and then try to extractor it. I believe it will work then, for the panose attribute will be an object and not a dict upon decompiling.
</div> 



Discussion:
After thinking a bit more, maybe fontBuilder should provide a method to normalise the TTFont object in it? There are multiple ways that the TTFont object might be messed up during the building process, including passing in an integer value as the version number as shown in robotools/extractor/issues/59.
Hm no i think that would be out of scope for fontBuilder
The bugfix to set an object instead of a plain dict as panose attribute is legit and we should get it done. Would you send a PR?

Resolving patch:
diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py
--- a/Lib/fontTools/fontBuilder.py
+++ b/Lib/fontTools/fontBuilder.py
@@ -132,6 +132,7 @@ def drawTestGlyph(pen):
 from .ttLib import TTFont, newTable
 from .ttLib.tables._c_m_a_p import cmap_classes
 from .ttLib.tables._g_l_y_f import flagCubic
+from .ttLib.tables.O_S_2f_2 import Panose
 from .misc.timeTools import timestampNow
 import struct
 from collections import OrderedDict
@@ -263,18 +264,7 @@ def drawTestGlyph(pen):
 # to insert in setupNameTable doc string:
 # print("\n".join(("%s (nameID %s)" % (k, v)) for k, v in sorted(_nameIDs.items(), key=lambda x: x[1])))
 
-_panoseDefaults = dict(
-    bFamilyType=0,
-    bSerifStyle=0,
-    bWeight=0,
-    bProportion=0,
-    bContrast=0,
-    bStrokeVariation=0,
-    bArmStyle=0,
-    bLetterForm=0,
-    bMidline=0,
-    bXHeight=0,
-)
+_panoseDefaults = Panose()
 
 _OS2Defaults = dict(
     version=3,
diff --git a/Lib/fontTools/ttLib/tables/O_S_2f_2.py b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
--- a/Lib/fontTools/ttLib/tables/O_S_2f_2.py
+++ b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
@@ -25,6 +25,13 @@
 
 
 class Panose(object):
+    def __init__(self, **kwargs):
+        _, names, _ = sstruct.getformat(panoseFormat)
+        for name in names:
+            setattr(self, name, kwargs.pop(name, 0))
+        for k in kwargs:
+            raise TypeError(f"Panose() got an unexpected keyword argument {k!r}")
+
     def toXML(self, writer, ttFont):
         formatstring, names, fixes = sstruct.getformat(panoseFormat)
         for name in names:


Test patch:
diff --git a/Tests/fontBuilder/fontBuilder_test.py b/Tests/fontBuilder/fontBuilder_test.py
--- a/Tests/fontBuilder/fontBuilder_test.py
+++ b/Tests/fontBuilder/fontBuilder_test.py
@@ -420,3 +420,48 @@ def test_unicodeVariationSequences(tmpdir):
     fb.setupCharacterMap(cmap, uvs)
     fb.save(outPath)
     _verifyOutput(outPath, tables=["cmap"])
+
+
+def test_setupPanose():
+    from fontTools.ttLib.tables.O_S_2f_2 import Panose
+
+    fb, advanceWidths, nameStrings = _setupFontBuilder(True)
+
+    pen = TTGlyphPen(None)
+    drawTestGlyph(pen)
+    glyph = pen.glyph()
+    glyphs = {".notdef": glyph, "A": glyph, "a": glyph, ".null": glyph}
+    fb.setupGlyf(glyphs)
+    metrics = {}
+    glyphTable = fb.font["glyf"]
+    for gn, advanceWidth in advanceWidths.items():
+        metrics[gn] = (advanceWidth, glyphTable[gn].xMin)
+    fb.setupHorizontalMetrics(metrics)
+
+    fb.setupHorizontalHeader(ascent=824, descent=200)
+    fb.setupNameTable(nameStrings)
+    fb.setupOS2()
+    fb.setupPost()
+
+    panoseValues = {  # sample value of Times New Roman from https://www.w3.org/Printing/stevahn.html
+        "bFamilyType": 2,
+        "bSerifStyle": 2,
+        "bWeight": 6,
+        "bProportion": 3,
+        "bContrast": 5,
+        "bStrokeVariation": 4,
+        "bArmStyle": 5,
+        "bLetterForm": 2,
+        "bMidline": 3,
+        "bXHeight": 4,
+    }
+    panoseObj = Panose(**panoseValues)
+
+    for name in panoseValues:
+        assert getattr(fb.font["OS/2"].panose, name) == 0
+
+    fb.setupOS2(panose=panoseObj)
+    fb.setupPost()
+
+    for name, value in panoseValues.items():
+        assert getattr(fb.font["OS/2"].panose, name) == value