Merge pull request #1639 from anthrotype/woff2-untransformed
[woff2] support hmtx transform + glyf/loca without transformation
diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py
index c0c0e70..a872250 100644
--- 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 @@
"""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 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 @@
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 @@
% (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 @@
# 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 @@
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 @@
tableClass = WOFF2LocaTable
elif tag == 'glyf':
tableClass = WOFF2GlyfTable
+ elif tag == 'hmtx':
+ tableClass = WOFF2HmtxTable
else:
tableClass = getTableClass(tag)
table = tableClass(tag)
@@ -293,11 +317,17 @@
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 @@
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 @@
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 @@
# 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 @@
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 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 @@
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 @@
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 @@
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
index 689ebdc..0000000
--- 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
index e7c1bea..0000000
--- 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())
diff --git a/Tests/ttLib/woff2_test.py b/Tests/ttLib/woff2_test.py
index c270295..e4c8bb2 100644
--- 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_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 @@
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 @@
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 @@
for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength',
'metaOrigLength', 'privLength', 'privOffset'):
setattr(self, attr, 0)
+ self.tables = {}
class WOFF2FlavorDataTest(unittest.TestCase):
@@ -354,6 +385,24 @@
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 @@
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 @@
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 @@
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 @@
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):