Repository: fonttools/fonttools

Issue:
[woff2] encoder should allow to compress font without transforming glyf/loca
in order to preserve the OVERLAP_SIMPLE glyf flags

Also see https://github.com/google/woff2/issues/123

and https://github.com/fonttools/fonttools/issues/1281


Discussion:
similarly, the fonttools woff2 decoder should support decompressing WOFF2 fonts where the glyf and loca tables are not transformed. Currently it fails with an error `bits 6-7 are reserved and must be 0`.

But those bits are no longer reserved, the spec was updated since then and now 

> Bits 6 and 7 indicate the preprocessing transformation version number (0-3) that was applied to each table.

Resolving patch:
diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py
--- a/Lib/fontTools/ttLib/woff2.py
+++ b/Lib/fontTools/ttLib/woff2.py
@@ -16,7 +16,7 @@
 import logging
 
 
-log = logging.getLogger(__name__)
+log = logging.getLogger("fontTools.ttLib.woff2")
 
 haveBrotli = False
 try:
@@ -82,7 +82,7 @@ def __getitem__(self, tag):
 		"""Fetch the raw table data. Reconstruct transformed tables."""
 		entry = self.tables[Tag(tag)]
 		if not hasattr(entry, 'data'):
-			if tag in woff2TransformedTableTags:
+			if entry.transformed:
 				entry.data = self.reconstructTable(tag)
 			else:
 				entry.data = entry.loadData(self.transformBuffer)
@@ -90,8 +90,6 @@ def __getitem__(self, tag):
 
 	def reconstructTable(self, tag):
 		"""Reconstruct table named 'tag' from transformed data."""
-		if tag not in woff2TransformedTableTags:
-			raise TTLibError("transform for table '%s' is unknown" % tag)
 		entry = self.tables[Tag(tag)]
 		rawData = entry.loadData(self.transformBuffer)
 		if tag == 'glyf':
@@ -100,8 +98,10 @@ def reconstructTable(self, tag):
 			data = self._reconstructGlyf(rawData, padding)
 		elif tag == 'loca':
 			data = self._reconstructLoca()
+		elif tag == 'hmtx':
+			data = self._reconstructHmtx(rawData)
 		else:
-			raise NotImplementedError
+			raise TTLibError("transform for table '%s' is unknown" % tag)
 		return data
 
 	def _reconstructGlyf(self, data, padding=None):
@@ -130,6 +130,34 @@ def _reconstructLoca(self):
 				% (self.tables['loca'].origLength, len(data)))
 		return data
 
+	def _reconstructHmtx(self, data):
+		""" Return reconstructed hmtx table data. """
+		# Before reconstructing 'hmtx' table we need to parse other tables:
+		# 'glyf' is required for reconstructing the sidebearings from the glyphs'
+		# bounding box; 'hhea' is needed for the numberOfHMetrics field.
+		if "glyf" in self.flavorData.transformedTables:
+			# transformed 'glyf' table is self-contained, thus 'loca' not needed
+			tableDependencies = ("maxp", "hhea", "glyf")
+		else:
+			# decompiling untransformed 'glyf' requires 'loca', which requires 'head'
+			tableDependencies = ("maxp", "head", "hhea", "loca", "glyf")
+		for tag in tableDependencies:
+			self._decompileTable(tag)
+		hmtxTable = self.ttFont["hmtx"] = WOFF2HmtxTable()
+		hmtxTable.reconstruct(data, self.ttFont)
+		data = hmtxTable.compile(self.ttFont)
+		return data
+
+	def _decompileTable(self, tag):
+		"""Decompile table data and store it inside self.ttFont."""
+		data = self[tag]
+		if self.ttFont.isLoaded(tag):
+			return self.ttFont[tag]
+		tableClass = getTableClass(tag)
+		table = tableClass(tag)
+		self.ttFont.tables[tag] = table
+		table.decompile(data, self.ttFont)
+
 
 class WOFF2Writer(SFNTWriter):
 
@@ -199,7 +227,7 @@ def close(self):
 		# See:
 		# https://github.com/khaledhosny/ots/issues/60
 		# https://github.com/google/woff2/issues/15
-		if isTrueType:
+		if isTrueType and "glyf" in self.flavorData.transformedTables:
 			self._normaliseGlyfAndLoca(padding=4)
 		self._setHeadTransformFlag()
 
@@ -234,13 +262,7 @@ def _normaliseGlyfAndLoca(self, padding=4):
 		if self.sfntVersion == "OTTO":
 			return
 
-		# make up glyph names required to decompile glyf table
-		self._decompileTable('maxp')
-		numGlyphs = self.ttFont['maxp'].numGlyphs
-		glyphOrder = ['.notdef'] + ["glyph%.5d" % i for i in range(1, numGlyphs)]
-		self.ttFont.setGlyphOrder(glyphOrder)
-
-		for tag in ('head', 'loca', 'glyf'):
+		for tag in ('maxp', 'head', 'loca', 'glyf'):
 			self._decompileTable(tag)
 		self.ttFont['glyf'].padding = padding
 		for tag in ('glyf', 'loca'):
@@ -265,6 +287,8 @@ def _decompileTable(self, tag):
 			tableClass = WOFF2LocaTable
 		elif tag == 'glyf':
 			tableClass = WOFF2GlyfTable
+		elif tag == 'hmtx':
+			tableClass = WOFF2HmtxTable
 		else:
 			tableClass = getTableClass(tag)
 		table = tableClass(tag)
@@ -293,11 +317,17 @@ def _calcSFNTChecksumsLengthsAndOffsets(self):
 
 	def _transformTables(self):
 		"""Return transformed font data."""
+		transformedTables = self.flavorData.transformedTables
 		for tag, entry in self.tables.items():
-			if tag in woff2TransformedTableTags:
+			data = None
+			if tag in transformedTables:
 				data = self.transformTable(tag)
-			else:
+				if data is not None:
+					entry.transformed = True
+			if data is None:
+				# pass-through the table data without transformation
 				data = entry.data
+				entry.transformed = False
 			entry.offset = self.nextTableOffset
 			entry.saveData(self.transformBuffer, data)
 			self.nextTableOffset += entry.length
@@ -306,9 +336,9 @@ def _transformTables(self):
 		return fontData
 
 	def transformTable(self, tag):
-		"""Return transformed table data."""
-		if tag not in woff2TransformedTableTags:
-			raise TTLibError("Transform for table '%s' is unknown" % tag)
+		"""Return transformed table data, or None if some pre-conditions aren't
+		met -- in which case, the non-transformed table data will be used.
+		"""
 		if tag == "loca":
 			data = b""
 		elif tag == "glyf":
@@ -316,8 +346,15 @@ def transformTable(self, tag):
 				self._decompileTable(tag)
 			glyfTable = self.ttFont['glyf']
 			data = glyfTable.transform(self.ttFont)
+		elif tag == "hmtx":
+			if "glyf" not in self.tables:
+				return
+			for tag in ("maxp", "head", "hhea", "loca", "glyf", "hmtx"):
+				self._decompileTable(tag)
+			hmtxTable = self.ttFont["hmtx"]
+			data = hmtxTable.transform(self.ttFont)  # can be None
 		else:
-			raise NotImplementedError
+			raise TTLibError("Transform for table '%s' is unknown" % tag)
 		return data
 
 	def _calcMasterChecksum(self):
@@ -533,11 +570,9 @@ def fromString(self, data):
 			# otherwise, tag is derived from a fixed 'Known Tags' table
 			self.tag = woff2KnownTags[self.flags & 0x3F]
 		self.tag = Tag(self.tag)
-		if self.flags & 0xC0 != 0:
-			raise TTLibError('bits 6-7 are reserved and must be 0')
 		self.origLength, data = unpackBase128(data)
 		self.length = self.origLength
-		if self.tag in woff2TransformedTableTags:
+		if self.transformed:
 			self.length, data = unpackBase128(data)
 			if self.tag == 'loca' and self.length != 0:
 				raise TTLibError(
@@ -550,10 +585,44 @@ def toString(self):
 		if (self.flags & 0x3F) == 0x3F:
 			data += struct.pack('>4s', self.tag.tobytes())
 		data += packBase128(self.origLength)
-		if self.tag in woff2TransformedTableTags:
+		if self.transformed:
 			data += packBase128(self.length)
 		return data
 
+	@property
+	def transformVersion(self):
+		"""Return bits 6-7 of table entry's flags, which indicate the preprocessing
+		transformation version number (between 0 and 3).
+		"""
+		return self.flags >> 6
+
+	@transformVersion.setter
+	def transformVersion(self, value):
+		assert 0 <= value <= 3
+		self.flags |= value << 6
+
+	@property
+	def transformed(self):
+		"""Return True if the table has any transformation, else return False."""
+		# For all tables in a font, except for 'glyf' and 'loca', the transformation
+		# version 0 indicates the null transform (where the original table data is
+		# passed directly to the Brotli compressor). For 'glyf' and 'loca' tables,
+		# transformation version 3 indicates the null transform
+		if self.tag in {"glyf", "loca"}:
+			return self.transformVersion != 3
+		else:
+			return self.transformVersion != 0
+
+	@transformed.setter
+	def transformed(self, booleanValue):
+		# here we assume that a non-null transform means version 0 for 'glyf' and
+		# 'loca' and 1 for every other table (e.g. hmtx); but that may change as
+		# new transformation formats are introduced in the future (if ever).
+		if self.tag in {"glyf", "loca"}:
+			self.transformVersion = 3 if not booleanValue else 0
+		else:
+			self.transformVersion = int(booleanValue)
+
 
 class WOFF2LocaTable(getTableClass('loca')):
 	"""Same as parent class. The only difference is that it attempts to preserve
@@ -652,19 +721,7 @@ def reconstruct(self, data, ttFont):
 	def transform(self, ttFont):
 		""" Return transformed 'glyf' data """
 		self.numGlyphs = len(self.glyphs)
-		if not hasattr(self, "glyphOrder"):
-			try:
-				self.glyphOrder = ttFont.getGlyphOrder()
-			except:
-				self.glyphOrder = None
-			if self.glyphOrder is None:
-				self.glyphOrder = [".notdef"]
-				self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)])
-		if len(self.glyphOrder) != self.numGlyphs:
-			raise TTLibError(
-				"incorrect glyphOrder: expected %d glyphs, found %d" %
-				(len(self.glyphOrder), self.numGlyphs))
-
+		assert len(self.glyphOrder) == self.numGlyphs
 		if 'maxp' in ttFont:
 			ttFont['maxp'].numGlyphs = self.numGlyphs
 		self.indexFormat = ttFont['head'].indexToLocFormat
@@ -909,13 +966,193 @@ def _encodeTriplets(self, glyph):
 		self.glyphStream += triplets.tostring()
 
 
+class WOFF2HmtxTable(getTableClass("hmtx")):
+
+	def __init__(self, tag=None):
+		self.tableTag = Tag(tag or 'hmtx')
+
+	def reconstruct(self, data, ttFont):
+		flags, = struct.unpack(">B", data[:1])
+		data = data[1:]
+		if flags & 0b11111100 != 0:
+			raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag)
+
+		# When bit 0 is _not_ set, the lsb[] array is present
+		hasLsbArray = flags & 1 == 0
+		# When bit 1 is _not_ set, the leftSideBearing[] array is present
+		hasLeftSideBearingArray = flags & 2 == 0
+		if hasLsbArray and hasLeftSideBearingArray:
+			raise TTLibError(
+				"either bits 0 or 1 (or both) must set in transformed '%s' flags"
+				% self.tableTag
+			)
+
+		glyfTable = ttFont["glyf"]
+		headerTable = ttFont["hhea"]
+		glyphOrder = glyfTable.glyphOrder
+		numGlyphs = len(glyphOrder)
+		numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs)
+
+		assert len(data) >= 2 * numberOfHMetrics
+		advanceWidthArray = array.array("H", data[:2 * numberOfHMetrics])
+		if sys.byteorder != "big":
+			advanceWidthArray.byteswap()
+		data = data[2 * numberOfHMetrics:]
+
+		if hasLsbArray:
+			assert len(data) >= 2 * numberOfHMetrics
+			lsbArray = array.array("h", data[:2 * numberOfHMetrics])
+			if sys.byteorder != "big":
+				lsbArray.byteswap()
+			data = data[2 * numberOfHMetrics:]
+		else:
+			# compute (proportional) glyphs' lsb from their xMin
+			lsbArray = array.array("h")
+			for i, glyphName in enumerate(glyphOrder):
+				if i >= numberOfHMetrics:
+					break
+				glyph = glyfTable[glyphName]
+				xMin = getattr(glyph, "xMin", 0)
+				lsbArray.append(xMin)
+
+		numberOfSideBearings = numGlyphs - numberOfHMetrics
+		if hasLeftSideBearingArray:
+			assert len(data) >= 2 * numberOfSideBearings
+			leftSideBearingArray = array.array("h", data[:2 * numberOfSideBearings])
+			if sys.byteorder != "big":
+				leftSideBearingArray.byteswap()
+			data = data[2 * numberOfSideBearings:]
+		else:
+			# compute (monospaced) glyphs' leftSideBearing from their xMin
+			leftSideBearingArray = array.array("h")
+			for i, glyphName in enumerate(glyphOrder):
+				if i < numberOfHMetrics:
+					continue
+				glyph = glyfTable[glyphName]
+				xMin = getattr(glyph, "xMin", 0)
+				leftSideBearingArray.append(xMin)
+
+		if data:
+			raise TTLibError("too much '%s' table data" % self.tableTag)
+
+		self.metrics = {}
+		for i in range(numberOfHMetrics):
+			glyphName = glyphOrder[i]
+			advanceWidth, lsb = advanceWidthArray[i], lsbArray[i]
+			self.metrics[glyphName] = (advanceWidth, lsb)
+		lastAdvance = advanceWidthArray[-1]
+		for i in range(numberOfSideBearings):
+			glyphName = glyphOrder[i + numberOfHMetrics]
+			self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i])
+
+	def transform(self, ttFont):
+		glyphOrder = ttFont.getGlyphOrder()
+		glyf = ttFont["glyf"]
+		hhea = ttFont["hhea"]
+		numberOfHMetrics = hhea.numberOfHMetrics
+
+		# check if any of the proportional glyphs has left sidebearings that
+		# differ from their xMin bounding box values.
+		hasLsbArray = False
+		for i in range(numberOfHMetrics):
+			glyphName = glyphOrder[i]
+			lsb = self.metrics[glyphName][1]
+			if lsb != getattr(glyf[glyphName], "xMin", 0):
+				hasLsbArray = True
+				break
+
+		# do the same for the monospaced glyphs (if any) at the end of hmtx table
+		hasLeftSideBearingArray = False
+		for i in range(numberOfHMetrics, len(glyphOrder)):
+			glyphName = glyphOrder[i]
+			lsb = self.metrics[glyphName][1]
+			if lsb != getattr(glyf[glyphName], "xMin", 0):
+				hasLeftSideBearingArray = True
+				break
+
+		# if we need to encode both sidebearings arrays, then no transformation is
+		# applicable, and we must use the untransformed hmtx data
+		if hasLsbArray and hasLeftSideBearingArray:
+			return
+
+		# set bit 0 and 1 when the respective arrays are _not_ present
+		flags = 0
+		if not hasLsbArray:
+			flags |= 1 << 0
+		if not hasLeftSideBearingArray:
+			flags |= 1 << 1
+
+		data = struct.pack(">B", flags)
+
+		advanceWidthArray = array.array(
+			"H",
+			[
+				self.metrics[glyphName][0]
+				for i, glyphName in enumerate(glyphOrder)
+				if i < numberOfHMetrics
+			]
+		)
+		if sys.byteorder != "big":
+			advanceWidthArray.byteswap()
+		data += advanceWidthArray.tostring()
+
+		if hasLsbArray:
+			lsbArray = array.array(
+				"h",
+				[
+					self.metrics[glyphName][1]
+					for i, glyphName in enumerate(glyphOrder)
+					if i < numberOfHMetrics
+				]
+			)
+			if sys.byteorder != "big":
+				lsbArray.byteswap()
+			data += lsbArray.tostring()
+
+		if hasLeftSideBearingArray:
+			leftSideBearingArray = array.array(
+				"h",
+				[
+					self.metrics[glyphOrder[i]][1]
+					for i in range(numberOfHMetrics, len(glyphOrder))
+				]
+			)
+			if sys.byteorder != "big":
+				leftSideBearingArray.byteswap()
+			data += leftSideBearingArray.tostring()
+
+		return data
+
+
 class WOFF2FlavorData(WOFFFlavorData):
 
 	Flavor = 'woff2'
 
-	def __init__(self, reader=None):
+	def __init__(self, reader=None, transformedTables=None):
+		"""Data class that holds the WOFF2 header major/minor version, any
+		metadata or private data (as bytes strings), and the set of
+		table tags that have transformations applied (if reader is not None),
+		or will have once the WOFF2 font is compiled.
+		"""
 		if not haveBrotli:
 			raise ImportError("No module named brotli")
+
+		if reader is not None and transformedTables is not None:
+			raise TypeError(
+				"'reader' and 'transformedTables' arguments are mutually exclusive"
+			)
+
+		if transformedTables is None:
+			transformedTables = woff2TransformedTableTags
+		else:
+			if (
+				"glyf" in transformedTables and "loca" not in transformedTables
+				or "loca" in transformedTables and "glyf" not in transformedTables
+			):
+				raise ValueError(
+					"'glyf' and 'loca' must be transformed (or not) together"
+				)
+
 		self.majorVersion = None
 		self.minorVersion = None
 		self.metaData = None
@@ -935,6 +1172,13 @@ def __init__(self, reader=None):
 				data = reader.file.read(reader.privLength)
 				assert len(data) == reader.privLength
 				self.privData = data
+			transformedTables = [
+				tag
+				for tag, entry in reader.tables.items()
+				if entry.transformed
+			]
+
+		self.transformedTables = set(transformedTables)
 
 
 def unpackBase128(data):
@@ -1091,6 +1335,164 @@ def pack255UShort(value):
 		return struct.pack(">BH", 253, value)
 
 
+def compress(input_file, output_file, transform_tables=None):
+	"""Compress OpenType font to WOFF2.
+
+	Args:
+		input_file: a file path, file or file-like object (open in binary mode)
+			containing an OpenType font (either CFF- or TrueType-flavored).
+		output_file: a file path, file or file-like object where to save the
+			compressed WOFF2 font.
+		transform_tables: Optional[Iterable[str]]: a set of table tags for which
+			to enable preprocessing transformations. By default, only 'glyf'
+			and 'loca' tables are transformed. An empty set means disable all
+			transformations.
+	"""
+	log.info("Processing %s => %s" % (input_file, output_file))
+
+	font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
+	font.flavor = "woff2"
+
+	if transform_tables is not None:
+		font.flavorData = WOFF2FlavorData(transformedTables=transform_tables)
+
+	font.save(output_file, reorderTables=False)
+
+
+def decompress(input_file, output_file):
+	"""Decompress WOFF2 font to OpenType font.
+
+	Args:
+		input_file: a file path, file or file-like object (open in binary mode)
+			containing a compressed WOFF2 font.
+		output_file: a file path, file or file-like object where to save the
+			decompressed OpenType font.
+	"""
+	log.info("Processing %s => %s" % (input_file, output_file))
+
+	font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
+	font.flavor = None
+	font.flavorData = None
+	font.save(output_file, reorderTables=True)
+
+
+def main(args=None):
+	import argparse
+	from fontTools import configLogger
+	from fontTools.ttx import makeOutputFileName
+
+	class _NoGlyfTransformAction(argparse.Action):
+		def __call__(self, parser, namespace, values, option_string=None):
+			namespace.transform_tables.difference_update({"glyf", "loca"})
+
+	class _HmtxTransformAction(argparse.Action):
+		def __call__(self, parser, namespace, values, option_string=None):
+			namespace.transform_tables.add("hmtx")
+
+	parser = argparse.ArgumentParser(
+		prog="fonttools ttLib.woff2",
+		description="Compress and decompress WOFF2 fonts",
+	)
+
+	parser_group = parser.add_subparsers(title="sub-commands")
+	parser_compress = parser_group.add_parser("compress")
+	parser_decompress = parser_group.add_parser("decompress")
+
+	for subparser in (parser_compress, parser_decompress):
+		group = subparser.add_mutually_exclusive_group(required=False)
+		group.add_argument(
+			"-v",
+			"--verbose",
+			action="store_true",
+			help="print more messages to console",
+		)
+		group.add_argument(
+			"-q",
+			"--quiet",
+			action="store_true",
+			help="do not print messages to console",
+		)
+
+	parser_compress.add_argument(
+		"input_file",
+		metavar="INPUT",
+		help="the input OpenType font (.ttf or .otf)",
+	)
+	parser_decompress.add_argument(
+		"input_file",
+		metavar="INPUT",
+		help="the input WOFF2 font",
+	)
+
+	parser_compress.add_argument(
+		"-o",
+		"--output-file",
+		metavar="OUTPUT",
+		help="the output WOFF2 font",
+	)
+	parser_decompress.add_argument(
+		"-o",
+		"--output-file",
+		metavar="OUTPUT",
+		help="the output OpenType font",
+	)
+
+	transform_group = parser_compress.add_argument_group()
+	transform_group.add_argument(
+		"--no-glyf-transform",
+		dest="transform_tables",
+		nargs=0,
+		action=_NoGlyfTransformAction,
+		help="Do not transform glyf (and loca) tables",
+	)
+	transform_group.add_argument(
+		"--hmtx-transform",
+		dest="transform_tables",
+		nargs=0,
+		action=_HmtxTransformAction,
+		help="Enable optional transformation for 'hmtx' table",
+	)
+
+	parser_compress.set_defaults(
+		subcommand=compress,
+		transform_tables={"glyf", "loca"},
+	)
+	parser_decompress.set_defaults(subcommand=decompress)
+
+	options = vars(parser.parse_args(args))
+
+	subcommand = options.pop("subcommand", None)
+	if not subcommand:
+		parser.print_help()
+		return
+
+	quiet = options.pop("quiet")
+	verbose = options.pop("verbose")
+	configLogger(
+		level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"),
+	)
+
+	if not options["output_file"]:
+		if subcommand is compress:
+			extension = ".woff2"
+		elif subcommand is decompress:
+			# choose .ttf/.otf file extension depending on sfntVersion
+			with open(options["input_file"], "rb") as f:
+				f.seek(4)  # skip 'wOF2' signature
+				sfntVersion = f.read(4)
+			assert len(sfntVersion) == 4, "not enough data"
+			extension = ".otf" if sfntVersion == b"OTTO" else ".ttf"
+		else:
+			raise AssertionError(subcommand)
+		options["output_file"] = makeOutputFileName(
+			options["input_file"], outputDir=None, extension=extension
+		)
+
+	try:
+		subcommand(**options)
+	except TTLibError as e:
+		parser.error(e)
+
+
 if __name__ == "__main__":
-	import doctest
-	sys.exit(doctest.testmod().failed)
+	sys.exit(main())
diff --git a/Snippets/woff2_compress.py b/Snippets/woff2_compress.py
deleted file mode 100755
--- a/Snippets/woff2_compress.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env python
-
-from __future__ import print_function, division, absolute_import
-from fontTools.misc.py23 import *
-from fontTools.ttLib import TTFont
-from fontTools.ttx import makeOutputFileName
-import sys
-import os
-
-
-def main(args=None):
-    if args is None:
-        args = sys.argv[1:]
-    if len(args) < 1:
-        print("One argument, the input filename, must be provided.", file=sys.stderr)
-        return 1
-
-    filename = args[0]
-    outfilename = makeOutputFileName(filename, outputDir=None, extension='.woff2')
-
-    print("Processing %s => %s" % (filename, outfilename))
-
-    font = TTFont(filename, recalcBBoxes=False, recalcTimestamp=False)
-    font.flavor = "woff2"
-    font.save(outfilename, reorderTables=False)
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/Snippets/woff2_decompress.py b/Snippets/woff2_decompress.py
deleted file mode 100755
--- a/Snippets/woff2_decompress.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env python
-
-from __future__ import print_function, division, absolute_import
-from fontTools.misc.py23 import *
-from fontTools.ttLib import TTFont
-from fontTools.ttx import makeOutputFileName
-import sys
-import os
-
-
-def make_output_name(filename):
-    with open(filename, "rb") as f:
-        f.seek(4)
-        sfntVersion = f.read(4)
-    assert len(sfntVersion) == 4, "not enough data"
-    ext = '.ttf' if sfntVersion == b"\x00\x01\x00\x00" else ".otf"
-    outfilename = makeOutputFileName(filename, outputDir=None, extension=ext)
-    return outfilename
-
-
-def main(args=None):
-    if args is None:
-        args = sys.argv[1:]
-    if len(args) < 1:
-        print("One argument, the input filename, must be provided.", file=sys.stderr)
-        return 1
-
-    filename = args[0]
-    outfilename = make_output_name(filename)
-
-    print("Processing %s => %s" % (filename, outfilename))
-
-    font = TTFont(filename, recalcBBoxes=False, recalcTimestamp=False)
-    font.flavor = None
-    font.save(outfilename, reorderTables=True)
-
-
-if __name__ == '__main__':
-    sys.exit(main())


Test patch:
diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py
--- a/Tests/ttLib/woff2_test.py
+++ b/Tests/ttLib/woff2_test.py
@@ -1,19 +1,24 @@
 from __future__ import print_function, division, absolute_import, unicode_literals
 from fontTools.misc.py23 import *
 from fontTools import ttLib
+from fontTools.ttLib import woff2
 from fontTools.ttLib.woff2 import (
 	WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat,
 	woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry,
 	getKnownTagIndex, packBase128, base128Size, woff2UnknownTagIndex,
 	WOFF2FlavorData, woff2TransformedTableTags, WOFF2GlyfTable, WOFF2LocaTable,
-	WOFF2Writer, unpackBase128, unpack255UShort, pack255UShort)
+	WOFF2HmtxTable, WOFF2Writer, unpackBase128, unpack255UShort, pack255UShort)
 import unittest
 from fontTools.misc import sstruct
+from fontTools import fontBuilder
+from fontTools.pens.ttGlyphPen import TTGlyphPen
 import struct
 import os
 import random
 import copy
 from collections import OrderedDict
+from functools import partial
+import pytest
 
 haveBrotli = False
 try:
@@ -122,7 +127,7 @@ def test_get_normal_tables(self):
 	def test_reconstruct_unknown(self):
 		reader = WOFF2Reader(self.file)
 		with self.assertRaisesRegex(ttLib.TTLibError, 'transform for table .* unknown'):
-			reader.reconstructTable('ZZZZ')
+			reader.reconstructTable('head')
 
 
 class WOFF2ReaderTTFTest(WOFF2ReaderTest):
@@ -243,10 +248,6 @@ def test_not_enough_data_table_tag(self):
 		with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'tag'"):
 			self.entry.fromString(bytes(incompleteData))
 
-	def test_table_reserved_flags(self):
-		with self.assertRaisesRegex(ttLib.TTLibError, "bits 6-7 are reserved"):
-			self.entry.fromString(bytechr(0xC0))
-
 	def test_loca_zero_transformLength(self):
 		data = bytechr(getKnownTagIndex('loca'))  # flags
 		data += packBase128(random.randint(1, 100))  # origLength
@@ -292,6 +293,35 @@ def test_unknown_toString(self):
 		data = self.entry.toString()
 		self.assertEqual(len(data), expectedSize)
 
+	def test_glyf_loca_transform_flags(self):
+		for tag in ("glyf", "loca"):
+			entry = WOFF2DirectoryEntry()
+			entry.tag = Tag(tag)
+			entry.flags = getKnownTagIndex(entry.tag)
+
+			self.assertEqual(entry.transformVersion, 0)
+			self.assertTrue(entry.transformed)
+
+			entry.transformed = False
+
+			self.assertEqual(entry.transformVersion, 3)
+			self.assertEqual(entry.flags & 0b11000000, (3 << 6))
+			self.assertFalse(entry.transformed)
+
+	def test_other_transform_flags(self):
+		entry = WOFF2DirectoryEntry()
+		entry.tag = Tag('ZZZZ')
+		entry.flags = woff2UnknownTagIndex
+
+		self.assertEqual(entry.transformVersion, 0)
+		self.assertFalse(entry.transformed)
+
+		entry.transformed = True
+
+		self.assertEqual(entry.transformVersion, 1)
+		self.assertEqual(entry.flags & 0b11000000, (1 << 6))
+		self.assertTrue(entry.transformed)
+
 
 class DummyReader(WOFF2Reader):
 
@@ -300,6 +330,7 @@ def __init__(self, file, checkChecksums=1, fontNumber=-1):
 		for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength',
 				'metaOrigLength', 'privLength', 'privOffset'):
 			setattr(self, attr, 0)
+		self.tables = {}
 
 
 class WOFF2FlavorDataTest(unittest.TestCase):
@@ -354,6 +385,24 @@ def test_get_major_minorVersion(self):
 		self.assertEqual(flavorData.majorVersion, 1)
 		self.assertEqual(flavorData.minorVersion, 1)
 
+	def test_mutually_exclusive_args(self):
+		reader = DummyReader(self.file)
+		with self.assertRaisesRegex(TypeError, "arguments are mutually exclusive"):
+			WOFF2FlavorData(reader, transformedTables={"hmtx"})
+
+	def test_transformTables_default(self):
+		flavorData = WOFF2FlavorData()
+		self.assertEqual(flavorData.transformedTables, set(woff2TransformedTableTags))
+
+	def test_transformTables_invalid(self):
+		msg = r"'glyf' and 'loca' must be transformed \(or not\) together"
+
+		with self.assertRaisesRegex(ValueError, msg):
+			WOFF2FlavorData(transformedTables={"glyf"})
+
+		with self.assertRaisesRegex(ValueError, msg):
+			WOFF2FlavorData(transformedTables={"loca"})
+
 
 class WOFF2WriterTest(unittest.TestCase):
 
@@ -512,6 +561,30 @@ def test_getVersion(self):
 		flavorData.majorVersion, flavorData.minorVersion = (10, 11)
 		self.assertEqual((10, 11), self.writer._getVersion())
 
+	def test_hmtx_trasform(self):
+		tableTransforms = {"glyf", "loca", "hmtx"}
+
+		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
+		writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms)
+
+		for tag in self.tags:
+			writer[tag] = self.font.getTableData(tag)
+		writer.close()
+
+		# enabling hmtx transform has no effect when font has no glyf table
+		self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue())
+
+	def test_no_transforms(self):
+		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
+		writer.flavorData = WOFF2FlavorData(transformedTables=())
+
+		for tag in self.tags:
+			writer[tag] = self.font.getTableData(tag)
+		writer.close()
+
+		# transforms settings have no effect when font is CFF-flavored, since
+		# all the current transforms only apply to TrueType-flavored fonts.
+		self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue())
 
 class WOFF2WriterTTFTest(WOFF2WriterTest):
 
@@ -540,6 +613,35 @@ def test_normaliseGlyfAndLoca(self):
 		for tag in normTables:
 			self.assertEqual(self.writer.tables[tag].data, normTables[tag])
 
+	def test_hmtx_trasform(self):
+		tableTransforms = {"glyf", "loca", "hmtx"}
+
+		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
+		writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms)
+
+		for tag in self.tags:
+			writer[tag] = self.font.getTableData(tag)
+		writer.close()
+
+		length = len(writer.file.getvalue())
+
+		# enabling optional hmtx transform shaves off a few bytes
+		self.assertLess(length, len(TT_WOFF2.getvalue()))
+
+	def test_no_transforms(self):
+		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
+		writer.flavorData = WOFF2FlavorData(transformedTables=())
+
+		for tag in self.tags:
+			writer[tag] = self.font.getTableData(tag)
+		writer.close()
+
+		self.assertNotEqual(writer.file.getvalue(), TT_WOFF2.getvalue())
+
+		writer.file.seek(0)
+		reader = WOFF2Reader(writer.file)
+		self.assertEqual(len(reader.flavorData.transformedTables), 0)
+
 
 class WOFF2LocaTableTest(unittest.TestCase):
 
@@ -709,28 +811,6 @@ def test_transform_glyf(self):
 		data = glyfTable.transform(self.font)
 		self.assertEqual(self.transformedGlyfData, data)
 
-	def test_transform_glyf_incorrect_glyphOrder(self):
-		glyfTable = self.font['glyf']
-		badGlyphOrder = self.font.getGlyphOrder()[:-1]
-		del glyfTable.glyphOrder
-		self.font.setGlyphOrder(badGlyphOrder)
-		with self.assertRaisesRegex(ttLib.TTLibError, "incorrect glyphOrder"):
-			glyfTable.transform(self.font)
-		glyfTable.glyphOrder = badGlyphOrder
-		with self.assertRaisesRegex(ttLib.TTLibError, "incorrect glyphOrder"):
-			glyfTable.transform(self.font)
-
-	def test_transform_glyf_missing_glyphOrder(self):
-		glyfTable = self.font['glyf']
-		del glyfTable.glyphOrder
-		del self.font.glyphOrder
-		numGlyphs = self.font['maxp'].numGlyphs
-		del self.font['maxp']
-		glyfTable.transform(self.font)
-		expected = [".notdef"]
-		expected.extend(["glyph%.5d" % i for i in range(1, numGlyphs)])
-		self.assertEqual(expected, glyfTable.glyphOrder)
-
 	def test_roundtrip_glyf_reconstruct_and_transform(self):
 		glyfTable = WOFF2GlyfTable()
 		glyfTable.reconstruct(self.transformedGlyfData, self.font)
@@ -748,6 +828,471 @@ def test_roundtrip_glyf_transform_and_reconstruct(self):
 		self.assertEqual(normGlyfData, reconstructedData)
 
 
+@pytest.fixture(scope="module")
+def fontfile():
+
+	class Glyph(object):
+		def __init__(self, empty=False, **kwargs):
+			if not empty:
+				self.draw = partial(self.drawRect, **kwargs)
+			else:
+				self.draw = lambda pen: None
+
+		@staticmethod
+		def drawRect(pen, xMin, xMax):
+			pen.moveTo((xMin, 0))
+			pen.lineTo((xMin, 1000))
+			pen.lineTo((xMax, 1000))
+			pen.lineTo((xMax, 0))
+			pen.closePath()
+
+	class CompositeGlyph(object):
+		def __init__(self, components):
+			self.components = components
+
+		def draw(self, pen):
+			for baseGlyph, (offsetX, offsetY) in self.components:
+				pen.addComponent(baseGlyph, (1, 0, 0, 1, offsetX, offsetY))
+
+	fb = fontBuilder.FontBuilder(unitsPerEm=1000, isTTF=True)
+	fb.setupGlyphOrder(
+		[".notdef", "space", "A", "acutecomb", "Aacute", "zero", "one", "two"]
+	)
+	fb.setupCharacterMap(
+		{
+			0x20: "space",
+			0x41: "A",
+			0x0301: "acutecomb",
+			0xC1: "Aacute",
+			0x30: "zero",
+			0x31: "one",
+			0x32: "two",
+		}
+	)
+	fb.setupHorizontalMetrics(
+		{
+			".notdef": (500, 50),
+			"space": (600, 0),
+			"A": (550, 40),
+			"acutecomb": (0, -40),
+			"Aacute": (550, 40),
+			"zero": (500, 30),
+			"one": (500, 50),
+			"two": (500, 40),
+		}
+	)
+	fb.setupHorizontalHeader(ascent=1000, descent=-200)
+
+	srcGlyphs = {
+		".notdef": Glyph(xMin=50, xMax=450),
+		"space": Glyph(empty=True),
+		"A": Glyph(xMin=40, xMax=510),
+		"acutecomb": Glyph(xMin=-40, xMax=60),
+		"Aacute": CompositeGlyph([("A", (0, 0)), ("acutecomb", (200, 0))]),
+		"zero": Glyph(xMin=30, xMax=470),
+		"one": Glyph(xMin=50, xMax=450),
+		"two": Glyph(xMin=40, xMax=460),
+	}
+	pen = TTGlyphPen(srcGlyphs)
+	glyphSet = {}
+	for glyphName, glyph in srcGlyphs.items():
+		glyph.draw(pen)
+		glyphSet[glyphName] = pen.glyph()
+	fb.setupGlyf(glyphSet)
+
+	fb.setupNameTable(
+		{
+			"familyName": "TestWOFF2",
+			"styleName": "Regular",
+			"uniqueFontIdentifier": "TestWOFF2 Regular; Version 1.000; ABCD",
+			"fullName": "TestWOFF2 Regular",
+			"version": "Version 1.000",
+			"psName": "TestWOFF2-Regular",
+		}
+	)
+	fb.setupOS2()
+	fb.setupPost()
+
+	buf = BytesIO()
+	fb.save(buf)
+	buf.seek(0)
+
+	assert fb.font["maxp"].numGlyphs == 8
+	assert fb.font["hhea"].numberOfHMetrics == 6
+	for glyphName in fb.font.getGlyphOrder():
+		xMin = getattr(fb.font["glyf"][glyphName], "xMin", 0)
+		assert xMin == fb.font["hmtx"][glyphName][1]
+
+	return buf
+
+
+@pytest.fixture
+def ttFont(fontfile):
+	return ttLib.TTFont(fontfile, recalcBBoxes=False, recalcTimestamp=False)
+
+
+class WOFF2HmtxTableTest(object):
+	def test_transform_no_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+		hmtxTable.metrics = ttFont["hmtx"].metrics
+
+		data = hmtxTable.transform(ttFont)
+
+		assert data == (
+			b"\x03"  # 00000011 | bits 0 and 1 are set (no sidebearings arrays)
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+		)
+
+	def test_transform_proportional_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+		metrics = ttFont["hmtx"].metrics
+		# force one of the proportional glyphs to have its left sidebearing be
+		# different from its xMin (40)
+		metrics["A"] = (550, 39)
+		hmtxTable.metrics = metrics
+
+		assert ttFont["glyf"]["A"].xMin != metrics["A"][1]
+
+		data = hmtxTable.transform(ttFont)
+
+		assert data == (
+			b"\x02"  # 00000010 | bits 0 unset: explicit proportional sidebearings
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+
+			# lsbArray
+			b'\x002'     # .notdef: 50
+			b'\x00\x00'  # space: 0
+			b"\x00'"     # A: 39 (xMin: 40)
+			b'\xff\xd8'  # acutecomb: -40
+			b'\x00('     # Aacute: 40
+			b'\x00\x1e'  # zero: 30
+		)
+
+	def test_transform_monospaced_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+		metrics = ttFont["hmtx"].metrics
+		hmtxTable.metrics = metrics
+
+		# force one of the monospaced glyphs at the end of hmtx table to have
+		# its xMin different from its left sidebearing (50)
+		ttFont["glyf"]["one"].xMin = metrics["one"][1] + 1
+
+		data = hmtxTable.transform(ttFont)
+
+		assert data == (
+			b"\x01"  # 00000001 | bits 1 unset: explicit monospaced sidebearings
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+
+			# leftSideBearingArray
+			b'\x002'     # one: 50 (xMin: 51)
+			b'\x00('     # two: 40
+		)
+
+	def test_transform_not_applicable(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+		metrics = ttFont["hmtx"].metrics
+		# force both a proportional and monospaced glyph to have sidebearings
+		# different from the respective xMin coordinates
+		metrics["A"] = (550, 39)
+		metrics["one"] = (500, 51)
+		hmtxTable.metrics = metrics
+
+		# 'None' signals to fall back using untransformed hmtx table data
+		assert hmtxTable.transform(ttFont) is None
+
+	def test_reconstruct_no_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+
+		data = (
+			b"\x03"  # 00000011 | bits 0 and 1 are set (no sidebearings arrays)
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+		)
+
+		hmtxTable.reconstruct(data, ttFont)
+
+		assert hmtxTable.metrics == {
+			".notdef": (500, 50),
+			"space": (600, 0),
+			"A": (550, 40),
+			"acutecomb": (0, -40),
+			"Aacute": (550, 40),
+			"zero": (500, 30),
+			"one": (500, 50),
+			"two": (500, 40),
+		}
+
+	def test_reconstruct_proportional_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+
+		data = (
+			b"\x02"  # 00000010 | bits 0 unset: explicit proportional sidebearings
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+
+			# lsbArray
+			b'\x002'     # .notdef: 50
+			b'\x00\x00'  # space: 0
+			b"\x00'"     # A: 39 (xMin: 40)
+			b'\xff\xd8'  # acutecomb: -40
+			b'\x00('     # Aacute: 40
+			b'\x00\x1e'  # zero: 30
+		)
+
+		hmtxTable.reconstruct(data, ttFont)
+
+		assert hmtxTable.metrics == {
+			".notdef": (500, 50),
+			"space": (600, 0),
+			"A": (550, 39),
+			"acutecomb": (0, -40),
+			"Aacute": (550, 40),
+			"zero": (500, 30),
+			"one": (500, 50),
+			"two": (500, 40),
+		}
+
+		assert ttFont["glyf"]["A"].xMin == 40
+
+	def test_reconstruct_monospaced_sidebearings(self, ttFont):
+		hmtxTable = WOFF2HmtxTable()
+
+		data = (
+			b"\x01"  # 00000001 | bits 1 unset: explicit monospaced sidebearings
+
+			# advanceWidthArray
+			b'\x01\xf4'  # .notdef: 500
+			b'\x02X'     # space: 600
+			b'\x02&'     # A: 550
+			b'\x00\x00'  # acutecomb: 0
+			b'\x02&'     # Aacute: 550
+			b'\x01\xf4'  # zero: 500
+
+			# leftSideBearingArray
+			b'\x003'     # one: 51 (xMin: 50)
+			b'\x00('     # two: 40
+		)
+
+		hmtxTable.reconstruct(data, ttFont)
+
+		assert hmtxTable.metrics == {
+			".notdef": (500, 50),
+			"space": (600, 0),
+			"A": (550, 40),
+			"acutecomb": (0, -40),
+			"Aacute": (550, 40),
+			"zero": (500, 30),
+			"one": (500, 51),
+			"two": (500, 40),
+		}
+
+		assert ttFont["glyf"]["one"].xMin == 50
+
+	def test_reconstruct_flags_reserved_bits(self):
+		hmtxTable = WOFF2HmtxTable()
+
+		with pytest.raises(
+			ttLib.TTLibError, match="Bits 2-7 of 'hmtx' flags are reserved"
+		):
+			hmtxTable.reconstruct(b"\xFF", ttFont=None)
+
+	def test_reconstruct_flags_required_bits(self):
+		hmtxTable = WOFF2HmtxTable()
+
+		with pytest.raises(ttLib.TTLibError, match="either bits 0 or 1 .* must set"):
+			hmtxTable.reconstruct(b"\x00", ttFont=None)
+
+	def test_reconstruct_too_much_data(self, ttFont):
+		ttFont["hhea"].numberOfHMetrics = 2
+		data = b'\x03\x01\xf4\x02X\x02&'
+		hmtxTable = WOFF2HmtxTable()
+
+		with pytest.raises(ttLib.TTLibError, match="too much 'hmtx' table data"):
+			hmtxTable.reconstruct(data, ttFont)
+
+
+class WOFF2RoundtripTest(object):
+	@staticmethod
+	def roundtrip(infile):
+		infile.seek(0)
+		ttFont = ttLib.TTFont(infile, recalcBBoxes=False, recalcTimestamp=False)
+		outfile = BytesIO()
+		ttFont.save(outfile)
+		return outfile, ttFont
+
+	def test_roundtrip_default_transforms(self, ttFont):
+		ttFont.flavor = "woff2"
+		# ttFont.flavorData = None
+		tmp = BytesIO()
+		ttFont.save(tmp)
+
+		tmp2, ttFont2 = self.roundtrip(tmp)
+
+		assert tmp.getvalue() == tmp2.getvalue()
+		assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca"}
+
+	def test_roundtrip_no_transforms(self, ttFont):
+		ttFont.flavor = "woff2"
+		ttFont.flavorData = WOFF2FlavorData(transformedTables=[])
+		tmp = BytesIO()
+		ttFont.save(tmp)
+
+		tmp2, ttFont2 = self.roundtrip(tmp)
+
+		assert tmp.getvalue() == tmp2.getvalue()
+		assert not ttFont2.reader.flavorData.transformedTables
+
+	def test_roundtrip_all_transforms(self, ttFont):
+		ttFont.flavor = "woff2"
+		ttFont.flavorData = WOFF2FlavorData(transformedTables=["glyf", "loca", "hmtx"])
+		tmp = BytesIO()
+		ttFont.save(tmp)
+
+		tmp2, ttFont2 = self.roundtrip(tmp)
+
+		assert tmp.getvalue() == tmp2.getvalue()
+		assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca", "hmtx"}
+
+	def test_roundtrip_only_hmtx_no_glyf_transform(self, ttFont):
+		ttFont.flavor = "woff2"
+		ttFont.flavorData = WOFF2FlavorData(transformedTables=["hmtx"])
+		tmp = BytesIO()
+		ttFont.save(tmp)
+
+		tmp2, ttFont2 = self.roundtrip(tmp)
+
+		assert tmp.getvalue() == tmp2.getvalue()
+		assert ttFont2.reader.flavorData.transformedTables == {"hmtx"}
+
+
+class MainTest(object):
+
+	@staticmethod
+	def make_ttf(tmpdir):
+		ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
+		ttFont.importXML(TTX)
+		filename = str(tmpdir / "TestTTF-Regular.ttf")
+		ttFont.save(filename)
+		return filename
+
+	def test_compress_ttf(self, tmpdir):
+		input_file = self.make_ttf(tmpdir)
+
+		assert woff2.main(["compress", input_file]) is None
+
+		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
+
+	def test_compress_ttf_no_glyf_transform(self, tmpdir):
+		input_file = self.make_ttf(tmpdir)
+
+		assert woff2.main(["compress", "--no-glyf-transform", input_file]) is None
+
+		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
+
+	def test_compress_ttf_hmtx_transform(self, tmpdir):
+		input_file = self.make_ttf(tmpdir)
+
+		assert woff2.main(["compress", "--hmtx-transform", input_file]) is None
+
+		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
+
+	def test_compress_ttf_no_glyf_transform_hmtx_transform(self, tmpdir):
+		input_file = self.make_ttf(tmpdir)
+
+		assert woff2.main(
+			["compress", "--no-glyf-transform", "--hmtx-transform", input_file]
+		) is None
+
+		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
+
+	def test_compress_output_file(self, tmpdir):
+		input_file = self.make_ttf(tmpdir)
+		output_file = tmpdir / "TestTTF.woff2"
+
+		assert woff2.main(
+			["compress", "-o", str(output_file), str(input_file)]
+		) is None
+
+		assert output_file.check(file=True)
+
+	def test_compress_otf(self, tmpdir):
+		ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
+		ttFont.importXML(OTX)
+		input_file = str(tmpdir / "TestOTF-Regular.otf")
+		ttFont.save(input_file)
+
+		assert woff2.main(["compress", input_file]) is None
+
+		assert (tmpdir / "TestOTF-Regular.woff2").check(file=True)
+
+	def test_decompress_ttf(self, tmpdir):
+		input_file = tmpdir / "TestTTF-Regular.woff2"
+		input_file.write_binary(TT_WOFF2.getvalue())
+
+		assert woff2.main(["decompress", str(input_file)]) is None
+
+		assert (tmpdir / "TestTTF-Regular.ttf").check(file=True)
+
+	def test_decompress_otf(self, tmpdir):
+		input_file = tmpdir / "TestTTF-Regular.woff2"
+		input_file.write_binary(CFF_WOFF2.getvalue())
+
+		assert woff2.main(["decompress", str(input_file)]) is None
+
+		assert (tmpdir / "TestTTF-Regular.otf").check(file=True)
+
+	def test_decompress_output_file(self, tmpdir):
+		input_file = tmpdir / "TestTTF-Regular.woff2"
+		input_file.write_binary(TT_WOFF2.getvalue())
+		output_file = tmpdir / "TestTTF.ttf"
+
+		assert woff2.main(
+			["decompress", "-o", str(output_file), str(input_file)]
+		) is None
+
+		assert output_file.check(file=True)
+
+	def test_no_subcommand_show_help(self, capsys):
+		with pytest.raises(SystemExit):
+			woff2.main(["--help"])
+
+		captured = capsys.readouterr()
+		assert "usage: fonttools ttLib.woff2" in captured.out
+
+
 class Base128Test(unittest.TestCase):
 
 	def test_unpackBase128(self):