Merge pull request #1639 from anthrotype/woff2-untransformed
[woff2] support hmtx transform + glyf/loca without transformation
This commit is contained in:
commit
00e054336f
@ -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())
|
||||
|
@ -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())
|
@ -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())
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user