Repository: fonttools/fonttools

Issue:
[hmtx] advanceWidth should be USHORT, why does fonttools reads it as SHORT?
It seems intentional, but why?
https://github.com/behdad/fonttools/commit/035bc7da2318f0dcacecba2e1e24c0a4bf3cf80c#diff-f1a37102608ecea22d52a8cb91aba526R21

Also see: https://github.com/googlei18n/ufo2ft/pull/60



Discussion:
I believe it was like that and in the port from numpy to array I decided not to change semantics.  I agree it needs to be fixed.


Resolving patch:
diff --git a/Lib/fontTools/ttLib/tables/_h_m_t_x.py b/Lib/fontTools/ttLib/tables/_h_m_t_x.py
--- a/Lib/fontTools/ttLib/tables/_h_m_t_x.py
+++ b/Lib/fontTools/ttLib/tables/_h_m_t_x.py
@@ -4,6 +4,7 @@
 from fontTools.misc.textTools import safeEval
 from . import DefaultTable
 import sys
+import struct
 import array
 import logging
 
@@ -17,18 +18,22 @@ class table__h_m_t_x(DefaultTable.DefaultTable):
 	advanceName = 'width'
 	sideBearingName = 'lsb'
 	numberOfMetricsName = 'numberOfHMetrics'
+	longMetricFormat = 'Hh'
 
 	def decompile(self, data, ttFont):
 		numGlyphs = ttFont['maxp'].numGlyphs
 		numberOfMetrics = int(getattr(ttFont[self.headerTag], self.numberOfMetricsName))
 		if numberOfMetrics > numGlyphs:
-			numberOfMetrics = numGlyphs # We warn later.
+			log.warning("The %s.%s exceeds the maxp.numGlyphs" % (
+				self.headerTag, self.numberOfMetricsName))
+			numberOfMetrics = numGlyphs
 		if len(data) < 4 * numberOfMetrics:
 			raise ttLib.TTLibError("not enough '%s' table data" % self.tableTag)
-		# Note: advanceWidth is unsigned, but we read/write as signed.
-		metrics = array.array("h", data[:4 * numberOfMetrics])
-		if sys.byteorder != "big":
-			metrics.byteswap()
+		# Note: advanceWidth is unsigned, but some font editors might
+		# read/write as signed. We can't be sure whether it was a mistake
+		# or not, so we read as unsigned but also issue a warning...
+		metricsFmt = ">" + self.longMetricFormat * numberOfMetrics
+		metrics = struct.unpack(metricsFmt, data[:4 * numberOfMetrics])
 		data = data[4 * numberOfMetrics:]
 		numberOfSideBearings = numGlyphs - numberOfMetrics
 		sideBearings = array.array("h", data[:2 * numberOfSideBearings])
@@ -42,16 +47,28 @@ def decompile(self, data, ttFont):
 		glyphOrder = ttFont.getGlyphOrder()
 		for i in range(numberOfMetrics):
 			glyphName = glyphOrder[i]
-			self.metrics[glyphName] = list(metrics[i*2:i*2+2])
+			advanceWidth, lsb = metrics[i*2:i*2+2]
+			if advanceWidth > 32767:
+				log.warning(
+					"Glyph %r has a huge advance %s (%d); is it intentional or "
+					"an (invalid) negative value?", glyphName, self.advanceName,
+					advanceWidth)
+			self.metrics[glyphName] = (advanceWidth, lsb)
 		lastAdvance = metrics[-2]
 		for i in range(numberOfSideBearings):
 			glyphName = glyphOrder[i + numberOfMetrics]
-			self.metrics[glyphName] = [lastAdvance, sideBearings[i]]
+			self.metrics[glyphName] = (lastAdvance, sideBearings[i])
 
 	def compile(self, ttFont):
 		metrics = []
+		hasNegativeAdvances = False
 		for glyphName in ttFont.getGlyphOrder():
-			metrics.append(self.metrics[glyphName])
+			advanceWidth, sideBearing = self.metrics[glyphName]
+			if advanceWidth < 0:
+				log.error("Glyph %r has negative advance %s" % (
+					glyphName, self.advanceName))
+				hasNegativeAdvances = True
+			metrics.append([advanceWidth, sideBearing])
 		lastAdvance = metrics[-1][0]
 		lastIndex = len(metrics)
 		while metrics[lastIndex-2][0] == lastAdvance:
@@ -63,16 +80,22 @@ def compile(self, ttFont):
 		additionalMetrics = metrics[lastIndex:]
 		additionalMetrics = [sb for advance, sb in additionalMetrics]
 		metrics = metrics[:lastIndex]
-		setattr(ttFont[self.headerTag], self.numberOfMetricsName, len(metrics))
+		numberOfMetrics = len(metrics)
+		setattr(ttFont[self.headerTag], self.numberOfMetricsName, numberOfMetrics)
 
 		allMetrics = []
 		for item in metrics:
 			allMetrics.extend(item)
-		allMetrics = array.array("h", allMetrics)
-		if sys.byteorder != "big":
-			allMetrics.byteswap()
-		data = allMetrics.tostring()
-
+		metricsFmt = ">" + self.longMetricFormat * numberOfMetrics
+		try:
+			data = struct.pack(metricsFmt, *allMetrics)
+		except struct.error as e:
+			if "out of range" in str(e) and hasNegativeAdvances:
+				raise ttLib.TTLibError(
+					"'%s' table can't contain negative advance %ss"
+					% (self.tableTag, self.advanceName))
+			else:
+				raise
 		additionalMetrics = array.array("h", additionalMetrics)
 		if sys.byteorder != "big":
 			additionalMetrics.byteswap()
@@ -94,8 +117,8 @@ def fromXML(self, name, attrs, content, ttFont):
 		if not hasattr(self, "metrics"):
 			self.metrics = {}
 		if name == "mtx":
-			self.metrics[attrs["name"]] = [safeEval(attrs[self.advanceName]),
-					safeEval(attrs[self.sideBearingName])]
+			self.metrics[attrs["name"]] = (safeEval(attrs[self.advanceName]),
+					safeEval(attrs[self.sideBearingName]))
 
 	def __delitem__(self, glyphName):
 		del self.metrics[glyphName]


Test patch:
diff --git a/Lib/fontTools/ttLib/tables/_h_m_t_x_test.py b/Lib/fontTools/ttLib/tables/_h_m_t_x_test.py
new file mode 100644
--- /dev/null
+++ b/Lib/fontTools/ttLib/tables/_h_m_t_x_test.py
@@ -0,0 +1,205 @@
+from __future__ import print_function, division, absolute_import
+from __future__ import unicode_literals
+from fontTools.misc.py23 import *
+from fontTools.misc.testTools import parseXML, getXML
+from fontTools.misc.textTools import deHexStr
+from fontTools.ttLib import TTFont, newTable, TTLibError
+from fontTools.misc.loggingTools import CapturingLogHandler
+from fontTools.ttLib.tables._h_m_t_x import table__h_m_t_x, log
+import struct
+import unittest
+
+
+class HmtxTableTest(unittest.TestCase):
+
+    def __init__(self, methodName):
+        unittest.TestCase.__init__(self, methodName)
+        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
+        # and fires deprecation warnings if a program uses the old name.
+        if not hasattr(self, "assertRaisesRegex"):
+            self.assertRaisesRegex = self.assertRaisesRegexp
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tableClass = table__h_m_t_x
+        cls.tag = "hmtx"
+
+    def makeFont(self, numGlyphs, numberOfMetrics):
+        font = TTFont()
+        maxp = font['maxp'] = newTable('maxp')
+        maxp.numGlyphs = numGlyphs
+        # from A to ...
+        font.glyphOrder = [chr(i) for i in range(65, 65+numGlyphs)]
+        headerTag = self.tableClass.headerTag
+        font[headerTag] = newTable(headerTag)
+        numberOfMetricsName = self.tableClass.numberOfMetricsName
+        setattr(font[headerTag], numberOfMetricsName, numberOfMetrics)
+        return font
+
+    def test_decompile(self):
+        font = self.makeFont(numGlyphs=3, numberOfMetrics=3)
+        data = deHexStr("02A2 FFF5 0278 004F 02C6 0036")
+
+        mtxTable = newTable(self.tag)
+        mtxTable.decompile(data, font)
+
+        self.assertEqual(mtxTable['A'], (674, -11))
+        self.assertEqual(mtxTable['B'], (632, 79))
+        self.assertEqual(mtxTable['C'], (710, 54))
+
+    def test_decompile_additional_SB(self):
+        font = self.makeFont(numGlyphs=4, numberOfMetrics=2)
+        metrics = deHexStr("02A2 FFF5 0278 004F")
+        extraSideBearings = deHexStr("0036 FFFC")
+        data = metrics + extraSideBearings
+
+        mtxTable = newTable(self.tag)
+        mtxTable.decompile(data, font)
+
+        self.assertEqual(mtxTable['A'], (674, -11))
+        self.assertEqual(mtxTable['B'], (632, 79))
+        # all following have same width as the previous
+        self.assertEqual(mtxTable['C'], (632, 54))
+        self.assertEqual(mtxTable['D'], (632, -4))
+
+    def test_decompile_not_enough_data(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=1)
+        mtxTable = newTable(self.tag)
+        msg = "not enough '%s' table data" % self.tag
+
+        with self.assertRaisesRegex(TTLibError, msg):
+            mtxTable.decompile(b"\0\0\0", font)
+
+    def test_decompile_too_much_data(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=1)
+        mtxTable = newTable(self.tag)
+        msg = "too much '%s' table data" % self.tag
+
+        with CapturingLogHandler(log, "WARNING") as captor:
+            mtxTable.decompile(b"\0\0\0\0\0", font)
+
+        self.assertTrue(
+            len([r for r in captor.records if msg == r.msg]) == 1)
+
+    def test_decompile_num_metrics_greater_than_glyphs(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=2)
+        mtxTable = newTable(self.tag)
+        msg = "The %s.%s exceeds the maxp.numGlyphs" % (
+            self.tableClass.headerTag, self.tableClass.numberOfMetricsName)
+
+        with CapturingLogHandler(log, "WARNING") as captor:
+            mtxTable.decompile(b"\0\0\0\0", font)
+
+        self.assertTrue(
+            len([r for r in captor.records if msg == r.msg]) == 1)
+
+    def test_decompile_possibly_negative_advance(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=1)
+        # we warn if advance is > 0x7FFF as it might be interpreted as signed
+        # by some authoring tools
+        data = deHexStr("8000 0000")
+        mtxTable = newTable(self.tag)
+
+        with CapturingLogHandler(log, "WARNING") as captor:
+            mtxTable.decompile(data, font)
+
+        self.assertTrue(
+            len([r for r in captor.records
+                if "has a huge advance" in r.msg]) == 1)
+
+    def test_compile(self):
+        # we set the wrong 'numberOfMetrics' to check it gets adjusted
+        font = self.makeFont(numGlyphs=3, numberOfMetrics=4)
+        mtxTable = font[self.tag] = newTable(self.tag)
+        mtxTable.metrics = {
+            'A': (674, -11),
+            'B': (632, 79),
+            'C': (710, 54),
+        }
+
+        data = mtxTable.compile(font)
+
+        self.assertEqual(data, deHexStr("02A2 FFF5 0278 004F 02C6 0036"))
+
+        headerTable = font[self.tableClass.headerTag]
+        self.assertEqual(
+            getattr(headerTable, self.tableClass.numberOfMetricsName), 3)
+
+    def test_compile_additional_SB(self):
+        font = self.makeFont(numGlyphs=4, numberOfMetrics=1)
+        mtxTable = font[self.tag] = newTable(self.tag)
+        mtxTable.metrics = {
+            'A': (632, -11),
+            'B': (632, 79),
+            'C': (632, 54),
+            'D': (632, -4),
+        }
+
+        data = mtxTable.compile(font)
+
+        self.assertEqual(data, deHexStr("0278 FFF5 004F 0036 FFFC"))
+
+    def test_compile_negative_advance(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=1)
+        mtxTable = font[self.tag] = newTable(self.tag)
+        mtxTable.metrics = {'A': [-1, 0]}
+
+        with CapturingLogHandler(log, "ERROR") as captor:
+            with self.assertRaisesRegex(TTLibError, "negative advance"):
+                mtxTable.compile(font)
+
+        self.assertTrue(
+            len([r for r in captor.records
+                if "Glyph 'A' has negative advance" in r.msg]) == 1)
+
+    def test_compile_struct_out_of_range(self):
+        font = self.makeFont(numGlyphs=1, numberOfMetrics=1)
+        mtxTable = font[self.tag] = newTable(self.tag)
+        mtxTable.metrics = {'A': (0xFFFF+1, -0x8001)}
+
+        with self.assertRaises(struct.error):
+            mtxTable.compile(font)
+
+    def test_toXML(self):
+        font = self.makeFont(numGlyphs=2, numberOfMetrics=2)
+        mtxTable = font[self.tag] = newTable(self.tag)
+        mtxTable.metrics = {'B': (632, 79), 'A': (674, -11)}
+
+        self.assertEqual(
+            getXML(mtxTable.toXML),
+            '<mtx name="A" %s="674" %s="-11"/>'
+            '<mtx name="B" %s="632" %s="79"/>' % (
+                (self.tableClass.advanceName,
+                 self.tableClass.sideBearingName) * 2))
+
+    def test_fromXML(self):
+        mtxTable = newTable(self.tag)
+
+        for name, attrs, content in parseXML(
+                '<mtx name="A" %s="674" %s="-11"/>'
+                '<mtx name="B" %s="632" %s="79"/>' % (
+                    (self.tableClass.advanceName,
+                     self.tableClass.sideBearingName) * 2)):
+            mtxTable.fromXML(name, attrs, content, ttFont=None)
+
+        self.assertEqual(
+            mtxTable.metrics, {'A': (674, -11), 'B': (632, 79)})
+
+    def test_delitem(self):
+        mtxTable = newTable(self.tag)
+        mtxTable.metrics = {'A': (0, 0)}
+
+        del mtxTable['A']
+
+        self.assertTrue('A' not in mtxTable.metrics)
+
+    def test_setitem(self):
+        mtxTable = newTable(self.tag)
+        mtxTable.metrics = {'A': (674, -11), 'B': (632, 79)}
+        mtxTable['B'] = [0, 0]  # list is converted to tuple
+
+        self.assertEqual(mtxTable.metrics, {'A': (674, -11), 'B': (0, 0)})
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/Lib/fontTools/ttLib/tables/_v_m_t_x_test.py b/Lib/fontTools/ttLib/tables/_v_m_t_x_test.py
new file mode 100644
--- /dev/null
+++ b/Lib/fontTools/ttLib/tables/_v_m_t_x_test.py
@@ -0,0 +1,18 @@
+from __future__ import print_function, division, absolute_import
+from __future__ import unicode_literals
+from fontTools.misc.py23 import *
+from fontTools.ttLib.tables._v_m_t_x import table__v_m_t_x
+from fontTools.ttLib.tables import _h_m_t_x_test
+import unittest
+
+
+class VmtxTableTest(_h_m_t_x_test.HmtxTableTest):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tableClass = table__v_m_t_x
+        cls.tag = "vmtx"
+
+
+if __name__ == "__main__":
+    unittest.main()