Repository: fonttools/fonttools

Issue:
[merge] OS/2 table Mixed Versions Crash
Got a crash in _pyftmerge_ when merging several fonts with OS/2 tables that have mixed versions.

If a font with a v5 OS/2 table **follows** font(s) with v4 OS/2 tables, I get:

  File "C:\App\Python37\lib\site-packages\fontTools\misc\loggingTools.py", line 375, in wrapper
    return func(*args, **kwds)
  File "D:\Kurinto\Bin\Bin_CG\kmerge.py", line 1196, in main
    font.save(outfile)
  File "C:\App\Python37\lib\site-packages\fontTools\ttLib\ttFont.py", line 173, in save
    writer_reordersTables = self._save(tmp)
  File "C:\App\Python37\lib\site-packages\fontTools\ttLib\ttFont.py", line 212, in _save
    self._writeTable(tag, writer, done, tableCache)
  File "C:\App\Python37\lib\site-packages\fontTools\ttLib\ttFont.py", line 633, in _writeTable
    tabledata = self.getTableData(tag)
  File "C:\App\Python37\lib\site-packages\fontTools\ttLib\ttFont.py", line 651, in getTableData
    return self.tables[tag].compile(self)
  File "C:\App\Python37\lib\site-packages\fontTools\ttLib\tables\O_S_2f_2.py", line 152, in compile
    d['usLowerOpticalPointSize'] = round(self.usLowerOpticalPointSize * 20)
AttributeError: 'table_O_S_2f_2' object has no attribute 'usLowerOpticalPointSize'

Confession: I have not done any serious debugging here. I am hypothesizing the cause of the failure ... but it does seem reasonable given that, when I hacked my one font with a v5 OS/2 table to v4, the issue went away.


Discussion:
Hit this again trying to merge fonts A and B, with A and B having OS/2 table versions of 1 and 3, respectively.
I see that OS/2 v5 has a `# TODO` in fontTools.merge

https://github.com/fonttools/fonttools/blob/c89c9487f131d16a6b45b5280efdcafd8f842580/Lib/fontTools/merge.py#L298

we just need to handle the new attributes. 
from what I can tell in https://docs.microsoft.com/en-us/typography/opentype/spec/OS2#lps, it should be enough that we take the min() of all the fonts' `usLowerOpticalPointSize` and the max() of the fonts' `usUpperOpticalPointSize`.
> Hit this again trying to merge fonts A and B, with A and B having OS/2 table versions of 1 and 3, respectively.

@ClintGoss that's strange, v1 and v3 are supported and the merger should already take the maximum between the OS/2 versions. Can you provide a test font, the exact command you run and the error you got?
Ah I see.  The merger is not handling attributes like `sxHeight`, `sCapHeight`, `usDefaultChar`, `usBreakChar` that were only added in version 2 and are missing from version 1, so when merging v1 with v > 1 you get the AttributeError. I'll fix this
Glad you see the issue ... because it is actually difficult for me to isolate a demonstration case. I've got merge and pyftsubset running hundreds of times in a rather arcane and massive pipeline / build process.

Resolving patch:
diff --git a/Lib/fontTools/merge.py b/Lib/fontTools/merge.py
--- a/Lib/fontTools/merge.py
+++ b/Lib/fontTools/merge.py
@@ -291,11 +291,18 @@ def mergeOs2FsType(lst):
 	'sTypoLineGap': max,
 	'usWinAscent': max,
 	'usWinDescent': max,
-	# Version 2,3,4
+	# Version 1
 	'ulCodePageRange1': onlyExisting(bitwise_or),
 	'ulCodePageRange2': onlyExisting(bitwise_or),
-	'usMaxContex': onlyExisting(max),
-	# TODO version 5
+	# Version 2, 3, 4
+	'sxHeight': onlyExisting(max),
+	'sCapHeight': onlyExisting(max),
+	'usDefaultChar': onlyExisting(first),
+	'usBreakChar': onlyExisting(first),
+	'usMaxContext': onlyExisting(max),
+	# version 5
+	'usLowerOpticalPointSize': onlyExisting(min),
+	'usUpperOpticalPointSize': onlyExisting(max),
 }
 
 @_add_method(ttLib.getTableClass('OS/2'))


Test patch:
diff --git a/Tests/merge_test.py b/Tests/merge_test.py
--- a/Tests/merge_test.py
+++ b/Tests/merge_test.py
@@ -1,7 +1,12 @@
+import io
+import itertools
 from fontTools.misc.py23 import *
 from fontTools import ttLib
+from fontTools.ttLib.tables._g_l_y_f import Glyph
+from fontTools.fontBuilder import FontBuilder
 from fontTools.merge import *
 import unittest
+import pytest
 
 
 class MergeIntegrationTest(unittest.TestCase):
@@ -113,6 +118,53 @@ def test_cmap_merge_three_dupes(self):
 		self.assertEqual(self.merger.duplicateGlyphsPerFont, [{}, {'space#0': 'space#1'}])
 
 
+def _compile(ttFont):
+	buf = io.BytesIO()
+	ttFont.save(buf)
+	buf.seek(0)
+	return buf
+
+
+def _make_fontfile_with_OS2(*, version, **kwargs):
+	upem = 1000
+	glyphOrder = [".notdef", "a"]
+	cmap = {0x61: "a"}
+	glyphs = {gn: Glyph() for gn in glyphOrder}
+	hmtx = {gn: (500, 0) for gn in glyphOrder}
+	names = {"familyName": "TestOS2", "styleName": "Regular"}
+
+	fb = FontBuilder(unitsPerEm=upem)
+	fb.setupGlyphOrder(glyphOrder)
+	fb.setupCharacterMap(cmap)
+	fb.setupGlyf(glyphs)
+	fb.setupHorizontalMetrics(hmtx)
+	fb.setupHorizontalHeader()
+	fb.setupNameTable(names)
+	fb.setupOS2(version=version, **kwargs)
+
+	return _compile(fb.font)
+
+
+def _merge_and_recompile(fontfiles, options=None):
+	merger = Merger(options)
+	merged = merger.merge(fontfiles)
+	buf = _compile(merged)
+	return ttLib.TTFont(buf)
+
+
+@pytest.mark.parametrize(
+	"v1, v2", list(itertools.permutations(range(5+1), 2))
+)
+def test_merge_OS2_mixed_versions(v1, v2):
+	# https://github.com/fonttools/fonttools/issues/1865
+	fontfiles = [
+		_make_fontfile_with_OS2(version=v1),
+		_make_fontfile_with_OS2(version=v2),
+	]
+	merged = _merge_and_recompile(fontfiles)
+	assert merged["OS/2"].version == max(v1, v2)
+
+
 if __name__ == "__main__":
 	import sys
 	sys.exit(unittest.main())