Merge pull request #1639 from anthrotype/woff2-untransformed

[woff2] support hmtx transform + glyf/loca without transformation
This commit is contained in:
Cosimo Lupo 2019-06-17 16:09:02 +01:00 committed by GitHub
commit 00e054336f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 1014 additions and 135 deletions

View File

@ -16,7 +16,7 @@ from fontTools.ttLib.tables import ttProgram
import logging
log = logging.getLogger(__name__)
log = logging.getLogger("fontTools.ttLib.woff2")
haveBrotli = False
try:
@ -82,7 +82,7 @@ class WOFF2Reader(SFNTReader):
"""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 @@ class WOFF2Reader(SFNTReader):
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 @@ class WOFF2Reader(SFNTReader):
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 @@ class WOFF2Reader(SFNTReader):
% (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 @@ class WOFF2Writer(SFNTWriter):
# 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 @@ class WOFF2Writer(SFNTWriter):
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 @@ class WOFF2Writer(SFNTWriter):
tableClass = WOFF2LocaTable
elif tag == 'glyf':
tableClass = WOFF2GlyfTable
elif tag == 'hmtx':
tableClass = WOFF2HmtxTable
else:
tableClass = getTableClass(tag)
table = tableClass(tag)
@ -293,11 +317,17 @@ class WOFF2Writer(SFNTWriter):
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 @@ class WOFF2Writer(SFNTWriter):
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 @@ class WOFF2Writer(SFNTWriter):
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 @@ class WOFF2DirectoryEntry(DirectoryEntry):
# 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 @@ class WOFF2DirectoryEntry(DirectoryEntry):
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 @@ class WOFF2GlyfTable(getTableClass('glyf')):
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 @@ class WOFF2GlyfTable(getTableClass('glyf')):
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 @@ class WOFF2FlavorData(WOFFFlavorData):
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())

View File

@ -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())

View File

@ -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())

View File

@ -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 @@ class WOFF2ReaderTest(unittest.TestCase):
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 @@ class WOFF2DirectoryEntryTest(unittest.TestCase):
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 @@ class WOFF2DirectoryEntryTest(unittest.TestCase):
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 @@ class DummyReader(WOFF2Reader):
for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength',
'metaOrigLength', 'privLength', 'privOffset'):
setattr(self, attr, 0)
self.tables = {}
class WOFF2FlavorDataTest(unittest.TestCase):
@ -354,6 +385,24 @@ class WOFF2FlavorDataTest(unittest.TestCase):
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 @@ class WOFF2WriterTest(unittest.TestCase):
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 @@ class WOFF2WriterTTFTest(WOFF2WriterTest):
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 @@ class WOFF2GlyfTableTest(unittest.TestCase):
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 @@ class WOFF2GlyfTableTest(unittest.TestCase):
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):