Repository: fonttools/fonttools

Issue:
table_O_S_2f_2.getCodePageRanges() can raise an AttributeError with fonttools=4.44.1
Hi!

I spotted this in https://github.com/py-pdf/fpdf2/issues/1021.

We have a couple of cases in our test suite where this error is raised with `fonttools=4.44.1`:
`AttributeError: 'table_O_S_2f_2' object has no attribute 'ulCodePageRange1'`

Could this be caused by a backward compatible change in `fonttools`,
or is it now expected that user code handle those errors?


Discussion:
I think we need to add OS/2 table version checks to the code from #3328, since not all versions of the table has these two fields.
I'm getting also the same error `AttributeError: 'table_O_S_2f_2' object has no attribute 'ulCodePageRange1'` after upgrading to `v4.44.1`. Do I have to do something to fix this issue? Or just downgrade to `v4.44.0` and wait for the fix by another version?
> I think we need to add OS/2 table version checks

@khaledhosny can you please take care of this? Thanks

>  just downgrade to v4.44.0 and wait for the fix by another version?

yes please
In the meantime I have just "yanked" 4.44.1 from PyPI so pip should no longer install that (unless one specifically requests it).
The 4.44.2 deployment failed for unrelated reasons so it has not been published to PyPI (despite being shown in the Github releases). I will push a bugfix 4.44.3 release once Khaled pushes the fix. Or I can do it later today myself, just le me know.

Resolving patch:
diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py
--- a/Lib/fontTools/subset/__init__.py
+++ b/Lib/fontTools/subset/__init__.py
@@ -3455,7 +3455,9 @@ def _prune_post_subset(self, font):
                         log.info(
                             "%s Unicode ranges pruned: %s", tag, sorted(new_uniranges)
                         )
-                if self.options.prune_codepage_ranges:
+                if self.options.prune_codepage_ranges and font[tag].version >= 1:
+                    # codepage range fields were added with OS/2 format 1
+                    # https://learn.microsoft.com/en-us/typography/opentype/spec/os2#version-1
                     old_codepages = font[tag].getCodePageRanges()
                     new_codepages = font[tag].recalcCodePageRanges(font, pruneOnly=True)
                     if old_codepages != new_codepages:
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
@@ -343,6 +343,8 @@ def recalcUnicodeRanges(self, ttFont, pruneOnly=False):
     def getCodePageRanges(self):
         """Return the set of 'ulCodePageRange*' bits currently enabled."""
         bits = set()
+        if self.version < 1:
+            return bits
         ul1, ul2 = self.ulCodePageRange1, self.ulCodePageRange2
         for i in range(32):
             if ul1 & (1 << i):
@@ -361,6 +363,8 @@ def setCodePageRanges(self, bits):
                 ul2 |= 1 << (bit - 32)
             else:
                 raise ValueError(f"expected 0 <= int <= 63, found: {bit:r}")
+        if self.version < 1:
+            self.version = 1
         self.ulCodePageRange1, self.ulCodePageRange2 = ul1, ul2
 
     def recalcCodePageRanges(self, ttFont, pruneOnly=False):


Test patch:
diff --git a/Tests/ttLib/tables/O_S_2f_2_test.py b/Tests/ttLib/tables/O_S_2f_2_test.py
--- a/Tests/ttLib/tables/O_S_2f_2_test.py
+++ b/Tests/ttLib/tables/O_S_2f_2_test.py
@@ -8,6 +8,7 @@ class OS2TableTest(unittest.TestCase):
     def makeOS2_cmap(mapping):
         font = TTFont()
         font["OS/2"] = os2 = newTable("OS/2")
+        os2.version = 4
         font["cmap"] = cmap = newTable("cmap")
         st = getTableModule("cmap").CmapSubtable.newSubtable(4)
         st.platformID, st.platEncID, st.language = 3, 1, 0
@@ -64,6 +65,11 @@ def test_intersectUnicodeRanges(self):
 
     def test_getCodePageRanges(self):
         table = table_O_S_2f_2()
+        # version 0 doesn't define these fields so by definition defines no cp ranges
+        table.version = 0
+        self.assertEqual(table.getCodePageRanges(), set())
+        # version 1 and above do contain ulCodePageRange1 and 2 fields
+        table.version = 1
         table.ulCodePageRange1 = 0xFFFFFFFF
         table.ulCodePageRange2 = 0xFFFFFFFF
         bits = table.getCodePageRanges()
@@ -72,6 +78,7 @@ def test_getCodePageRanges(self):
 
     def test_setCodePageRanges(self):
         table = table_O_S_2f_2()
+        table.version = 4
         table.ulCodePageRange1 = 0
         table.ulCodePageRange2 = 0
         bits = set(range(64))
@@ -84,6 +91,16 @@ def test_setCodePageRanges(self):
         with self.assertRaises(ValueError):
             table.setCodePageRanges([255])
 
+    def test_setCodePageRanges_bump_version(self):
+        # Setting codepage ranges on a OS/2 table version 0 automatically makes it
+        # a version 1 table
+        table = table_O_S_2f_2()
+        table.version = 0
+        self.assertEqual(table.getCodePageRanges(), set())
+        table.setCodePageRanges({0, 1, 2})
+        self.assertEqual(table.getCodePageRanges(), {0, 1, 2})
+        self.assertEqual(table.version, 1)
+
     def test_recalcCodePageRanges(self):
         font, os2, cmap = self.makeOS2_cmap(
             {ord("A"): "A", ord("Ά"): "Alphatonos", ord("Б"): "Be"}