|
from io import BytesIO |
|
import sys |
|
import array |
|
import struct |
|
from collections import OrderedDict |
|
from fontTools.misc import sstruct |
|
from fontTools.misc.arrayTools import calcIntBounds |
|
from fontTools.misc.textTools import Tag, bytechr, byteord, bytesjoin, pad |
|
from fontTools.ttLib import ( |
|
TTFont, |
|
TTLibError, |
|
getTableModule, |
|
getTableClass, |
|
getSearchRange, |
|
) |
|
from fontTools.ttLib.sfnt import ( |
|
SFNTReader, |
|
SFNTWriter, |
|
DirectoryEntry, |
|
WOFFFlavorData, |
|
sfntDirectoryFormat, |
|
sfntDirectorySize, |
|
SFNTDirectoryEntry, |
|
sfntDirectoryEntrySize, |
|
calcChecksum, |
|
) |
|
from fontTools.ttLib.tables import ttProgram, _g_l_y_f |
|
import logging |
|
|
|
|
|
log = logging.getLogger("fontTools.ttLib.woff2") |
|
|
|
haveBrotli = False |
|
try: |
|
try: |
|
import brotlicffi as brotli |
|
except ImportError: |
|
import brotli |
|
haveBrotli = True |
|
except ImportError: |
|
pass |
|
|
|
|
|
class WOFF2Reader(SFNTReader): |
|
flavor = "woff2" |
|
|
|
def __init__(self, file, checkChecksums=0, fontNumber=-1): |
|
if not haveBrotli: |
|
log.error( |
|
"The WOFF2 decoder requires the Brotli Python extension, available at: " |
|
"https://github.com/google/brotli" |
|
) |
|
raise ImportError("No module named brotli") |
|
|
|
self.file = file |
|
|
|
signature = Tag(self.file.read(4)) |
|
if signature != b"wOF2": |
|
raise TTLibError("Not a WOFF2 font (bad signature)") |
|
|
|
self.file.seek(0) |
|
self.DirectoryEntry = WOFF2DirectoryEntry |
|
data = self.file.read(woff2DirectorySize) |
|
if len(data) != woff2DirectorySize: |
|
raise TTLibError("Not a WOFF2 font (not enough data)") |
|
sstruct.unpack(woff2DirectoryFormat, data, self) |
|
|
|
self.tables = OrderedDict() |
|
offset = 0 |
|
for i in range(self.numTables): |
|
entry = self.DirectoryEntry() |
|
entry.fromFile(self.file) |
|
tag = Tag(entry.tag) |
|
self.tables[tag] = entry |
|
entry.offset = offset |
|
offset += entry.length |
|
|
|
totalUncompressedSize = offset |
|
compressedData = self.file.read(self.totalCompressedSize) |
|
decompressedData = brotli.decompress(compressedData) |
|
if len(decompressedData) != totalUncompressedSize: |
|
raise TTLibError( |
|
"unexpected size for decompressed font data: expected %d, found %d" |
|
% (totalUncompressedSize, len(decompressedData)) |
|
) |
|
self.transformBuffer = BytesIO(decompressedData) |
|
|
|
self.file.seek(0, 2) |
|
if self.length != self.file.tell(): |
|
raise TTLibError("reported 'length' doesn't match the actual file size") |
|
|
|
self.flavorData = WOFF2FlavorData(self) |
|
|
|
|
|
self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) |
|
|
|
def __getitem__(self, tag): |
|
"""Fetch the raw table data. Reconstruct transformed tables.""" |
|
entry = self.tables[Tag(tag)] |
|
if not hasattr(entry, "data"): |
|
if entry.transformed: |
|
entry.data = self.reconstructTable(tag) |
|
else: |
|
entry.data = entry.loadData(self.transformBuffer) |
|
return entry.data |
|
|
|
def reconstructTable(self, tag): |
|
"""Reconstruct table named 'tag' from transformed data.""" |
|
entry = self.tables[Tag(tag)] |
|
rawData = entry.loadData(self.transformBuffer) |
|
if tag == "glyf": |
|
|
|
padding = self.padding if hasattr(self, "padding") else None |
|
data = self._reconstructGlyf(rawData, padding) |
|
elif tag == "loca": |
|
data = self._reconstructLoca() |
|
elif tag == "hmtx": |
|
data = self._reconstructHmtx(rawData) |
|
else: |
|
raise TTLibError("transform for table '%s' is unknown" % tag) |
|
return data |
|
|
|
def _reconstructGlyf(self, data, padding=None): |
|
"""Return recostructed glyf table data, and set the corresponding loca's |
|
locations. Optionally pad glyph offsets to the specified number of bytes. |
|
""" |
|
self.ttFont["loca"] = WOFF2LocaTable() |
|
glyfTable = self.ttFont["glyf"] = WOFF2GlyfTable() |
|
glyfTable.reconstruct(data, self.ttFont) |
|
if padding: |
|
glyfTable.padding = padding |
|
data = glyfTable.compile(self.ttFont) |
|
return data |
|
|
|
def _reconstructLoca(self): |
|
"""Return reconstructed loca table data.""" |
|
if "loca" not in self.ttFont: |
|
|
|
self.tables["glyf"].data = self.reconstructTable("glyf") |
|
locaTable = self.ttFont["loca"] |
|
data = locaTable.compile(self.ttFont) |
|
if len(data) != self.tables["loca"].origLength: |
|
raise TTLibError( |
|
"reconstructed 'loca' table doesn't match original size: " |
|
"expected %d, found %d" % (self.tables["loca"].origLength, len(data)) |
|
) |
|
return data |
|
|
|
def _reconstructHmtx(self, data): |
|
"""Return reconstructed hmtx table data.""" |
|
|
|
|
|
|
|
if "glyf" in self.flavorData.transformedTables: |
|
|
|
tableDependencies = ("maxp", "hhea", "glyf") |
|
else: |
|
|
|
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): |
|
flavor = "woff2" |
|
|
|
def __init__( |
|
self, |
|
file, |
|
numTables, |
|
sfntVersion="\000\001\000\000", |
|
flavor=None, |
|
flavorData=None, |
|
): |
|
if not haveBrotli: |
|
log.error( |
|
"The WOFF2 encoder requires the Brotli Python extension, available at: " |
|
"https://github.com/google/brotli" |
|
) |
|
raise ImportError("No module named brotli") |
|
|
|
self.file = file |
|
self.numTables = numTables |
|
self.sfntVersion = Tag(sfntVersion) |
|
self.flavorData = WOFF2FlavorData(data=flavorData) |
|
|
|
self.directoryFormat = woff2DirectoryFormat |
|
self.directorySize = woff2DirectorySize |
|
self.DirectoryEntry = WOFF2DirectoryEntry |
|
|
|
self.signature = Tag("wOF2") |
|
|
|
self.nextTableOffset = 0 |
|
self.transformBuffer = BytesIO() |
|
|
|
self.tables = OrderedDict() |
|
|
|
|
|
self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) |
|
|
|
def __setitem__(self, tag, data): |
|
"""Associate new entry named 'tag' with raw table data.""" |
|
if tag in self.tables: |
|
raise TTLibError("cannot rewrite '%s' table" % tag) |
|
if tag == "DSIG": |
|
|
|
self.numTables -= 1 |
|
return |
|
|
|
entry = self.DirectoryEntry() |
|
entry.tag = Tag(tag) |
|
entry.flags = getKnownTagIndex(entry.tag) |
|
|
|
|
|
entry.data = data |
|
|
|
self.tables[tag] = entry |
|
|
|
def close(self): |
|
"""All tags must have been specified. Now write the table data and directory.""" |
|
if len(self.tables) != self.numTables: |
|
raise TTLibError( |
|
"wrong number of tables; expected %d, found %d" |
|
% (self.numTables, len(self.tables)) |
|
) |
|
|
|
if self.sfntVersion in ("\x00\x01\x00\x00", "true"): |
|
isTrueType = True |
|
elif self.sfntVersion == "OTTO": |
|
isTrueType = False |
|
else: |
|
raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if ( |
|
isTrueType |
|
and "glyf" in self.flavorData.transformedTables |
|
and "glyf" in self.tables |
|
): |
|
self._normaliseGlyfAndLoca(padding=4) |
|
self._setHeadTransformFlag() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.tables = OrderedDict(sorted(self.tables.items())) |
|
|
|
self.totalSfntSize = self._calcSFNTChecksumsLengthsAndOffsets() |
|
|
|
fontData = self._transformTables() |
|
compressedFont = brotli.compress(fontData, mode=brotli.MODE_FONT) |
|
|
|
self.totalCompressedSize = len(compressedFont) |
|
self.length = self._calcTotalSize() |
|
self.majorVersion, self.minorVersion = self._getVersion() |
|
self.reserved = 0 |
|
|
|
directory = self._packTableDirectory() |
|
self.file.seek(0) |
|
self.file.write(pad(directory + compressedFont, size=4)) |
|
self._writeFlavorData() |
|
|
|
def _normaliseGlyfAndLoca(self, padding=4): |
|
"""Recompile glyf and loca tables, aligning glyph offsets to multiples of |
|
'padding' size. Update the head table's 'indexToLocFormat' accordingly while |
|
compiling loca. |
|
""" |
|
if self.sfntVersion == "OTTO": |
|
return |
|
|
|
for tag in ("maxp", "head", "loca", "glyf", "fvar"): |
|
if tag in self.tables: |
|
self._decompileTable(tag) |
|
self.ttFont["glyf"].padding = padding |
|
for tag in ("glyf", "loca"): |
|
self._compileTable(tag) |
|
|
|
def _setHeadTransformFlag(self): |
|
"""Set bit 11 of 'head' table flags to indicate that the font has undergone |
|
a lossless modifying transform. Re-compile head table data.""" |
|
self._decompileTable("head") |
|
self.ttFont["head"].flags |= 1 << 11 |
|
self._compileTable("head") |
|
|
|
def _decompileTable(self, tag): |
|
"""Fetch table data, decompile it, and store it inside self.ttFont.""" |
|
tag = Tag(tag) |
|
if tag not in self.tables: |
|
raise TTLibError("missing required table: %s" % tag) |
|
if self.ttFont.isLoaded(tag): |
|
return |
|
data = self.tables[tag].data |
|
if tag == "loca": |
|
tableClass = WOFF2LocaTable |
|
elif tag == "glyf": |
|
tableClass = WOFF2GlyfTable |
|
elif tag == "hmtx": |
|
tableClass = WOFF2HmtxTable |
|
else: |
|
tableClass = getTableClass(tag) |
|
table = tableClass(tag) |
|
self.ttFont.tables[tag] = table |
|
table.decompile(data, self.ttFont) |
|
|
|
def _compileTable(self, tag): |
|
"""Compile table and store it in its 'data' attribute.""" |
|
self.tables[tag].data = self.ttFont[tag].compile(self.ttFont) |
|
|
|
def _calcSFNTChecksumsLengthsAndOffsets(self): |
|
"""Compute the 'original' SFNT checksums, lengths and offsets for checksum |
|
adjustment calculation. Return the total size of the uncompressed font. |
|
""" |
|
offset = sfntDirectorySize + sfntDirectoryEntrySize * len(self.tables) |
|
for tag, entry in self.tables.items(): |
|
data = entry.data |
|
entry.origOffset = offset |
|
entry.origLength = len(data) |
|
if tag == "head": |
|
entry.checkSum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:]) |
|
else: |
|
entry.checkSum = calcChecksum(data) |
|
offset += (entry.origLength + 3) & ~3 |
|
return offset |
|
|
|
def _transformTables(self): |
|
"""Return transformed font data.""" |
|
transformedTables = self.flavorData.transformedTables |
|
for tag, entry in self.tables.items(): |
|
data = None |
|
if tag in transformedTables: |
|
data = self.transformTable(tag) |
|
if data is not None: |
|
entry.transformed = True |
|
if data is None: |
|
if tag == "glyf": |
|
|
|
|
|
transformedTables.discard("loca") |
|
|
|
data = entry.data |
|
entry.transformed = False |
|
entry.offset = self.nextTableOffset |
|
entry.saveData(self.transformBuffer, data) |
|
self.nextTableOffset += entry.length |
|
self.writeMasterChecksum() |
|
fontData = self.transformBuffer.getvalue() |
|
return fontData |
|
|
|
def transformTable(self, 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": |
|
for tag in ("maxp", "head", "loca", "glyf"): |
|
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) |
|
else: |
|
raise TTLibError("Transform for table '%s' is unknown" % tag) |
|
return data |
|
|
|
def _calcMasterChecksum(self): |
|
"""Calculate checkSumAdjustment.""" |
|
tags = list(self.tables.keys()) |
|
checksums = [] |
|
for i in range(len(tags)): |
|
checksums.append(self.tables[tags[i]].checkSum) |
|
|
|
|
|
self.searchRange, self.entrySelector, self.rangeShift = getSearchRange( |
|
self.numTables, 16 |
|
) |
|
directory = sstruct.pack(sfntDirectoryFormat, self) |
|
tables = sorted(self.tables.items()) |
|
for tag, entry in tables: |
|
sfntEntry = SFNTDirectoryEntry() |
|
sfntEntry.tag = entry.tag |
|
sfntEntry.checkSum = entry.checkSum |
|
sfntEntry.offset = entry.origOffset |
|
sfntEntry.length = entry.origLength |
|
directory = directory + sfntEntry.toString() |
|
|
|
directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize |
|
assert directory_end == len(directory) |
|
|
|
checksums.append(calcChecksum(directory)) |
|
checksum = sum(checksums) & 0xFFFFFFFF |
|
|
|
checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF |
|
return checksumadjustment |
|
|
|
def writeMasterChecksum(self): |
|
"""Write checkSumAdjustment to the transformBuffer.""" |
|
checksumadjustment = self._calcMasterChecksum() |
|
self.transformBuffer.seek(self.tables["head"].offset + 8) |
|
self.transformBuffer.write(struct.pack(">L", checksumadjustment)) |
|
|
|
def _calcTotalSize(self): |
|
"""Calculate total size of WOFF2 font, including any meta- and/or private data.""" |
|
offset = self.directorySize |
|
for entry in self.tables.values(): |
|
offset += len(entry.toString()) |
|
offset += self.totalCompressedSize |
|
offset = (offset + 3) & ~3 |
|
offset = self._calcFlavorDataOffsetsAndSize(offset) |
|
return offset |
|
|
|
def _calcFlavorDataOffsetsAndSize(self, start): |
|
"""Calculate offsets and lengths for any meta- and/or private data.""" |
|
offset = start |
|
data = self.flavorData |
|
if data.metaData: |
|
self.metaOrigLength = len(data.metaData) |
|
self.metaOffset = offset |
|
self.compressedMetaData = brotli.compress( |
|
data.metaData, mode=brotli.MODE_TEXT |
|
) |
|
self.metaLength = len(self.compressedMetaData) |
|
offset += self.metaLength |
|
else: |
|
self.metaOffset = self.metaLength = self.metaOrigLength = 0 |
|
self.compressedMetaData = b"" |
|
if data.privData: |
|
|
|
offset = (offset + 3) & ~3 |
|
self.privOffset = offset |
|
self.privLength = len(data.privData) |
|
offset += self.privLength |
|
else: |
|
self.privOffset = self.privLength = 0 |
|
return offset |
|
|
|
def _getVersion(self): |
|
"""Return the WOFF2 font's (majorVersion, minorVersion) tuple.""" |
|
data = self.flavorData |
|
if data.majorVersion is not None and data.minorVersion is not None: |
|
return data.majorVersion, data.minorVersion |
|
else: |
|
|
|
if "head" in self.tables: |
|
return struct.unpack(">HH", self.tables["head"].data[4:8]) |
|
else: |
|
return 0, 0 |
|
|
|
def _packTableDirectory(self): |
|
"""Return WOFF2 table directory data.""" |
|
directory = sstruct.pack(self.directoryFormat, self) |
|
for entry in self.tables.values(): |
|
directory = directory + entry.toString() |
|
return directory |
|
|
|
def _writeFlavorData(self): |
|
"""Write metadata and/or private data using appropiate padding.""" |
|
compressedMetaData = self.compressedMetaData |
|
privData = self.flavorData.privData |
|
if compressedMetaData and privData: |
|
compressedMetaData = pad(compressedMetaData, size=4) |
|
if compressedMetaData: |
|
self.file.seek(self.metaOffset) |
|
assert self.file.tell() == self.metaOffset |
|
self.file.write(compressedMetaData) |
|
if privData: |
|
self.file.seek(self.privOffset) |
|
assert self.file.tell() == self.privOffset |
|
self.file.write(privData) |
|
|
|
def reordersTables(self): |
|
return True |
|
|
|
|
|
|
|
|
|
woff2DirectoryFormat = """ |
|
> # big endian |
|
signature: 4s # "wOF2" |
|
sfntVersion: 4s |
|
length: L # total woff2 file size |
|
numTables: H # number of tables |
|
reserved: H # set to 0 |
|
totalSfntSize: L # uncompressed size |
|
totalCompressedSize: L # compressed size |
|
majorVersion: H # major version of WOFF file |
|
minorVersion: H # minor version of WOFF file |
|
metaOffset: L # offset to metadata block |
|
metaLength: L # length of compressed metadata |
|
metaOrigLength: L # length of uncompressed metadata |
|
privOffset: L # offset to private data block |
|
privLength: L # length of private data block |
|
""" |
|
|
|
woff2DirectorySize = sstruct.calcsize(woff2DirectoryFormat) |
|
|
|
woff2KnownTags = ( |
|
"cmap", |
|
"head", |
|
"hhea", |
|
"hmtx", |
|
"maxp", |
|
"name", |
|
"OS/2", |
|
"post", |
|
"cvt ", |
|
"fpgm", |
|
"glyf", |
|
"loca", |
|
"prep", |
|
"CFF ", |
|
"VORG", |
|
"EBDT", |
|
"EBLC", |
|
"gasp", |
|
"hdmx", |
|
"kern", |
|
"LTSH", |
|
"PCLT", |
|
"VDMX", |
|
"vhea", |
|
"vmtx", |
|
"BASE", |
|
"GDEF", |
|
"GPOS", |
|
"GSUB", |
|
"EBSC", |
|
"JSTF", |
|
"MATH", |
|
"CBDT", |
|
"CBLC", |
|
"COLR", |
|
"CPAL", |
|
"SVG ", |
|
"sbix", |
|
"acnt", |
|
"avar", |
|
"bdat", |
|
"bloc", |
|
"bsln", |
|
"cvar", |
|
"fdsc", |
|
"feat", |
|
"fmtx", |
|
"fvar", |
|
"gvar", |
|
"hsty", |
|
"just", |
|
"lcar", |
|
"mort", |
|
"morx", |
|
"opbd", |
|
"prop", |
|
"trak", |
|
"Zapf", |
|
"Silf", |
|
"Glat", |
|
"Gloc", |
|
"Feat", |
|
"Sill", |
|
) |
|
|
|
woff2FlagsFormat = """ |
|
> # big endian |
|
flags: B # table type and flags |
|
""" |
|
|
|
woff2FlagsSize = sstruct.calcsize(woff2FlagsFormat) |
|
|
|
woff2UnknownTagFormat = """ |
|
> # big endian |
|
tag: 4s # 4-byte tag (optional) |
|
""" |
|
|
|
woff2UnknownTagSize = sstruct.calcsize(woff2UnknownTagFormat) |
|
|
|
woff2UnknownTagIndex = 0x3F |
|
|
|
woff2Base128MaxSize = 5 |
|
woff2DirectoryEntryMaxSize = ( |
|
woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize |
|
) |
|
|
|
woff2TransformedTableTags = ("glyf", "loca") |
|
|
|
woff2GlyfTableFormat = """ |
|
> # big endian |
|
version: H # = 0x0000 |
|
optionFlags: H # Bit 0: we have overlapSimpleBitmap[], Bits 1-15: reserved |
|
numGlyphs: H # Number of glyphs |
|
indexFormat: H # Offset format for loca table |
|
nContourStreamSize: L # Size of nContour stream |
|
nPointsStreamSize: L # Size of nPoints stream |
|
flagStreamSize: L # Size of flag stream |
|
glyphStreamSize: L # Size of glyph stream |
|
compositeStreamSize: L # Size of composite stream |
|
bboxStreamSize: L # Comnined size of bboxBitmap and bboxStream |
|
instructionStreamSize: L # Size of instruction stream |
|
""" |
|
|
|
woff2GlyfTableFormatSize = sstruct.calcsize(woff2GlyfTableFormat) |
|
|
|
bboxFormat = """ |
|
> # big endian |
|
xMin: h |
|
yMin: h |
|
xMax: h |
|
yMax: h |
|
""" |
|
|
|
woff2OverlapSimpleBitmapFlag = 0x0001 |
|
|
|
|
|
def getKnownTagIndex(tag): |
|
"""Return index of 'tag' in woff2KnownTags list. Return 63 if not found.""" |
|
for i in range(len(woff2KnownTags)): |
|
if tag == woff2KnownTags[i]: |
|
return i |
|
return woff2UnknownTagIndex |
|
|
|
|
|
class WOFF2DirectoryEntry(DirectoryEntry): |
|
def fromFile(self, file): |
|
pos = file.tell() |
|
data = file.read(woff2DirectoryEntryMaxSize) |
|
left = self.fromString(data) |
|
consumed = len(data) - len(left) |
|
file.seek(pos + consumed) |
|
|
|
def fromString(self, data): |
|
if len(data) < 1: |
|
raise TTLibError("can't read table 'flags': not enough data") |
|
dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self) |
|
if self.flags & 0x3F == 0x3F: |
|
|
|
if len(data) < woff2UnknownTagSize: |
|
raise TTLibError("can't read table 'tag': not enough data") |
|
dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self) |
|
else: |
|
|
|
self.tag = woff2KnownTags[self.flags & 0x3F] |
|
self.tag = Tag(self.tag) |
|
self.origLength, data = unpackBase128(data) |
|
self.length = self.origLength |
|
if self.transformed: |
|
self.length, data = unpackBase128(data) |
|
if self.tag == "loca" and self.length != 0: |
|
raise TTLibError("the transformLength of the 'loca' table must be 0") |
|
|
|
return data |
|
|
|
def toString(self): |
|
data = bytechr(self.flags) |
|
if (self.flags & 0x3F) == 0x3F: |
|
data += struct.pack(">4s", self.tag.tobytes()) |
|
data += packBase128(self.origLength) |
|
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.""" |
|
|
|
|
|
|
|
|
|
if self.tag in {"glyf", "loca"}: |
|
return self.transformVersion != 3 |
|
else: |
|
return self.transformVersion != 0 |
|
|
|
@transformed.setter |
|
def transformed(self, booleanValue): |
|
|
|
|
|
|
|
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 |
|
the 'indexFormat' as encoded in the WOFF2 glyf table. |
|
""" |
|
|
|
def __init__(self, tag=None): |
|
self.tableTag = Tag(tag or "loca") |
|
|
|
def compile(self, ttFont): |
|
try: |
|
max_location = max(self.locations) |
|
except AttributeError: |
|
self.set([]) |
|
max_location = 0 |
|
if "glyf" in ttFont and hasattr(ttFont["glyf"], "indexFormat"): |
|
|
|
indexFormat = ttFont["glyf"].indexFormat |
|
if indexFormat == 0: |
|
if max_location >= 0x20000: |
|
raise TTLibError("indexFormat is 0 but local offsets > 0x20000") |
|
if not all(l % 2 == 0 for l in self.locations): |
|
raise TTLibError( |
|
"indexFormat is 0 but local offsets not multiples of 2" |
|
) |
|
locations = array.array("H") |
|
for i in range(len(self.locations)): |
|
locations.append(self.locations[i] // 2) |
|
else: |
|
locations = array.array("I", self.locations) |
|
if sys.byteorder != "big": |
|
locations.byteswap() |
|
data = locations.tobytes() |
|
else: |
|
|
|
data = super(WOFF2LocaTable, self).compile(ttFont) |
|
return data |
|
|
|
|
|
class WOFF2GlyfTable(getTableClass("glyf")): |
|
"""Decoder/Encoder for WOFF2 'glyf' table transform.""" |
|
|
|
subStreams = ( |
|
"nContourStream", |
|
"nPointsStream", |
|
"flagStream", |
|
"glyphStream", |
|
"compositeStream", |
|
"bboxStream", |
|
"instructionStream", |
|
) |
|
|
|
def __init__(self, tag=None): |
|
self.tableTag = Tag(tag or "glyf") |
|
|
|
def reconstruct(self, data, ttFont): |
|
"""Decompile transformed 'glyf' data.""" |
|
inputDataSize = len(data) |
|
|
|
if inputDataSize < woff2GlyfTableFormatSize: |
|
raise TTLibError("not enough 'glyf' data") |
|
dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self) |
|
offset = woff2GlyfTableFormatSize |
|
|
|
for stream in self.subStreams: |
|
size = getattr(self, stream + "Size") |
|
setattr(self, stream, data[:size]) |
|
data = data[size:] |
|
offset += size |
|
|
|
hasOverlapSimpleBitmap = self.optionFlags & woff2OverlapSimpleBitmapFlag |
|
self.overlapSimpleBitmap = None |
|
if hasOverlapSimpleBitmap: |
|
overlapSimpleBitmapSize = (self.numGlyphs + 7) >> 3 |
|
self.overlapSimpleBitmap = array.array("B", data[:overlapSimpleBitmapSize]) |
|
offset += overlapSimpleBitmapSize |
|
|
|
if offset != inputDataSize: |
|
raise TTLibError( |
|
"incorrect size of transformed 'glyf' table: expected %d, received %d bytes" |
|
% (offset, inputDataSize) |
|
) |
|
|
|
bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 |
|
bboxBitmap = self.bboxStream[:bboxBitmapSize] |
|
self.bboxBitmap = array.array("B", bboxBitmap) |
|
self.bboxStream = self.bboxStream[bboxBitmapSize:] |
|
|
|
self.nContourStream = array.array("h", self.nContourStream) |
|
if sys.byteorder != "big": |
|
self.nContourStream.byteswap() |
|
assert len(self.nContourStream) == self.numGlyphs |
|
|
|
if "head" in ttFont: |
|
ttFont["head"].indexToLocFormat = self.indexFormat |
|
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)]) |
|
else: |
|
if len(self.glyphOrder) != self.numGlyphs: |
|
raise TTLibError( |
|
"incorrect glyphOrder: expected %d glyphs, found %d" |
|
% (len(self.glyphOrder), self.numGlyphs) |
|
) |
|
|
|
glyphs = self.glyphs = {} |
|
for glyphID, glyphName in enumerate(self.glyphOrder): |
|
glyph = self._decodeGlyph(glyphID) |
|
glyphs[glyphName] = glyph |
|
|
|
def transform(self, ttFont): |
|
"""Return transformed 'glyf' data""" |
|
self.numGlyphs = len(self.glyphs) |
|
assert len(self.glyphOrder) == self.numGlyphs |
|
if "maxp" in ttFont: |
|
ttFont["maxp"].numGlyphs = self.numGlyphs |
|
self.indexFormat = ttFont["head"].indexToLocFormat |
|
|
|
for stream in self.subStreams: |
|
setattr(self, stream, b"") |
|
bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 |
|
self.bboxBitmap = array.array("B", [0] * bboxBitmapSize) |
|
|
|
self.overlapSimpleBitmap = array.array("B", [0] * ((self.numGlyphs + 7) >> 3)) |
|
for glyphID in range(self.numGlyphs): |
|
try: |
|
self._encodeGlyph(glyphID) |
|
except NotImplementedError: |
|
return None |
|
hasOverlapSimpleBitmap = any(self.overlapSimpleBitmap) |
|
|
|
self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream |
|
for stream in self.subStreams: |
|
setattr(self, stream + "Size", len(getattr(self, stream))) |
|
self.version = 0 |
|
self.optionFlags = 0 |
|
if hasOverlapSimpleBitmap: |
|
self.optionFlags |= woff2OverlapSimpleBitmapFlag |
|
data = sstruct.pack(woff2GlyfTableFormat, self) |
|
data += bytesjoin([getattr(self, s) for s in self.subStreams]) |
|
if hasOverlapSimpleBitmap: |
|
data += self.overlapSimpleBitmap.tobytes() |
|
return data |
|
|
|
def _decodeGlyph(self, glyphID): |
|
glyph = getTableModule("glyf").Glyph() |
|
glyph.numberOfContours = self.nContourStream[glyphID] |
|
if glyph.numberOfContours == 0: |
|
return glyph |
|
elif glyph.isComposite(): |
|
self._decodeComponents(glyph) |
|
else: |
|
self._decodeCoordinates(glyph) |
|
self._decodeOverlapSimpleFlag(glyph, glyphID) |
|
self._decodeBBox(glyphID, glyph) |
|
return glyph |
|
|
|
def _decodeComponents(self, glyph): |
|
data = self.compositeStream |
|
glyph.components = [] |
|
more = 1 |
|
haveInstructions = 0 |
|
while more: |
|
component = getTableModule("glyf").GlyphComponent() |
|
more, haveInstr, data = component.decompile(data, self) |
|
haveInstructions = haveInstructions | haveInstr |
|
glyph.components.append(component) |
|
self.compositeStream = data |
|
if haveInstructions: |
|
self._decodeInstructions(glyph) |
|
|
|
def _decodeCoordinates(self, glyph): |
|
data = self.nPointsStream |
|
endPtsOfContours = [] |
|
endPoint = -1 |
|
for i in range(glyph.numberOfContours): |
|
ptsOfContour, data = unpack255UShort(data) |
|
endPoint += ptsOfContour |
|
endPtsOfContours.append(endPoint) |
|
glyph.endPtsOfContours = endPtsOfContours |
|
self.nPointsStream = data |
|
self._decodeTriplets(glyph) |
|
self._decodeInstructions(glyph) |
|
|
|
def _decodeOverlapSimpleFlag(self, glyph, glyphID): |
|
if self.overlapSimpleBitmap is None or glyph.numberOfContours <= 0: |
|
return |
|
byte = glyphID >> 3 |
|
bit = glyphID & 7 |
|
if self.overlapSimpleBitmap[byte] & (0x80 >> bit): |
|
glyph.flags[0] |= _g_l_y_f.flagOverlapSimple |
|
|
|
def _decodeInstructions(self, glyph): |
|
glyphStream = self.glyphStream |
|
instructionStream = self.instructionStream |
|
instructionLength, glyphStream = unpack255UShort(glyphStream) |
|
glyph.program = ttProgram.Program() |
|
glyph.program.fromBytecode(instructionStream[:instructionLength]) |
|
self.glyphStream = glyphStream |
|
self.instructionStream = instructionStream[instructionLength:] |
|
|
|
def _decodeBBox(self, glyphID, glyph): |
|
haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7))) |
|
if glyph.isComposite() and not haveBBox: |
|
raise TTLibError("no bbox values for composite glyph %d" % glyphID) |
|
if haveBBox: |
|
dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph) |
|
else: |
|
glyph.recalcBounds(self) |
|
|
|
def _decodeTriplets(self, glyph): |
|
def withSign(flag, baseval): |
|
assert 0 <= baseval and baseval < 65536, "integer overflow" |
|
return baseval if flag & 1 else -baseval |
|
|
|
nPoints = glyph.endPtsOfContours[-1] + 1 |
|
flagSize = nPoints |
|
if flagSize > len(self.flagStream): |
|
raise TTLibError("not enough 'flagStream' data") |
|
flagsData = self.flagStream[:flagSize] |
|
self.flagStream = self.flagStream[flagSize:] |
|
flags = array.array("B", flagsData) |
|
|
|
triplets = array.array("B", self.glyphStream) |
|
nTriplets = len(triplets) |
|
assert nPoints <= nTriplets |
|
|
|
x = 0 |
|
y = 0 |
|
glyph.coordinates = getTableModule("glyf").GlyphCoordinates.zeros(nPoints) |
|
glyph.flags = array.array("B") |
|
tripletIndex = 0 |
|
for i in range(nPoints): |
|
flag = flags[i] |
|
onCurve = not bool(flag >> 7) |
|
flag &= 0x7F |
|
if flag < 84: |
|
nBytes = 1 |
|
elif flag < 120: |
|
nBytes = 2 |
|
elif flag < 124: |
|
nBytes = 3 |
|
else: |
|
nBytes = 4 |
|
assert (tripletIndex + nBytes) <= nTriplets |
|
if flag < 10: |
|
dx = 0 |
|
dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex]) |
|
elif flag < 20: |
|
dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex]) |
|
dy = 0 |
|
elif flag < 84: |
|
b0 = flag - 20 |
|
b1 = triplets[tripletIndex] |
|
dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4)) |
|
dy = withSign(flag >> 1, 1 + ((b0 & 0x0C) << 2) + (b1 & 0x0F)) |
|
elif flag < 120: |
|
b0 = flag - 84 |
|
dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex]) |
|
dy = withSign( |
|
flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1] |
|
) |
|
elif flag < 124: |
|
b2 = triplets[tripletIndex + 1] |
|
dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4)) |
|
dy = withSign( |
|
flag >> 1, ((b2 & 0x0F) << 8) + triplets[tripletIndex + 2] |
|
) |
|
else: |
|
dx = withSign( |
|
flag, (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1] |
|
) |
|
dy = withSign( |
|
flag >> 1, |
|
(triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3], |
|
) |
|
tripletIndex += nBytes |
|
x += dx |
|
y += dy |
|
glyph.coordinates[i] = (x, y) |
|
glyph.flags.append(int(onCurve)) |
|
bytesConsumed = tripletIndex |
|
self.glyphStream = self.glyphStream[bytesConsumed:] |
|
|
|
def _encodeGlyph(self, glyphID): |
|
glyphName = self.getGlyphName(glyphID) |
|
glyph = self[glyphName] |
|
self.nContourStream += struct.pack(">h", glyph.numberOfContours) |
|
if glyph.numberOfContours == 0: |
|
return |
|
elif glyph.isComposite(): |
|
self._encodeComponents(glyph) |
|
elif glyph.isVarComposite(): |
|
raise NotImplementedError |
|
else: |
|
self._encodeCoordinates(glyph) |
|
self._encodeOverlapSimpleFlag(glyph, glyphID) |
|
self._encodeBBox(glyphID, glyph) |
|
|
|
def _encodeComponents(self, glyph): |
|
lastcomponent = len(glyph.components) - 1 |
|
more = 1 |
|
haveInstructions = 0 |
|
for i in range(len(glyph.components)): |
|
if i == lastcomponent: |
|
haveInstructions = hasattr(glyph, "program") |
|
more = 0 |
|
component = glyph.components[i] |
|
self.compositeStream += component.compile(more, haveInstructions, self) |
|
if haveInstructions: |
|
self._encodeInstructions(glyph) |
|
|
|
def _encodeCoordinates(self, glyph): |
|
lastEndPoint = -1 |
|
if _g_l_y_f.flagCubic in glyph.flags: |
|
raise NotImplementedError |
|
for endPoint in glyph.endPtsOfContours: |
|
ptsOfContour = endPoint - lastEndPoint |
|
self.nPointsStream += pack255UShort(ptsOfContour) |
|
lastEndPoint = endPoint |
|
self._encodeTriplets(glyph) |
|
self._encodeInstructions(glyph) |
|
|
|
def _encodeOverlapSimpleFlag(self, glyph, glyphID): |
|
if glyph.numberOfContours <= 0: |
|
return |
|
if glyph.flags[0] & _g_l_y_f.flagOverlapSimple: |
|
byte = glyphID >> 3 |
|
bit = glyphID & 7 |
|
self.overlapSimpleBitmap[byte] |= 0x80 >> bit |
|
|
|
def _encodeInstructions(self, glyph): |
|
instructions = glyph.program.getBytecode() |
|
self.glyphStream += pack255UShort(len(instructions)) |
|
self.instructionStream += instructions |
|
|
|
def _encodeBBox(self, glyphID, glyph): |
|
assert glyph.numberOfContours != 0, "empty glyph has no bbox" |
|
if not glyph.isComposite(): |
|
|
|
|
|
currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax |
|
calculatedBBox = calcIntBounds(glyph.coordinates) |
|
if currentBBox == calculatedBBox: |
|
return |
|
self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7) |
|
self.bboxStream += sstruct.pack(bboxFormat, glyph) |
|
|
|
def _encodeTriplets(self, glyph): |
|
assert len(glyph.coordinates) == len(glyph.flags) |
|
coordinates = glyph.coordinates.copy() |
|
coordinates.absoluteToRelative() |
|
|
|
flags = array.array("B") |
|
triplets = array.array("B") |
|
for i in range(len(coordinates)): |
|
onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve |
|
x, y = coordinates[i] |
|
absX = abs(x) |
|
absY = abs(y) |
|
onCurveBit = 0 if onCurve else 128 |
|
xSignBit = 0 if (x < 0) else 1 |
|
ySignBit = 0 if (y < 0) else 1 |
|
xySignBits = xSignBit + 2 * ySignBit |
|
|
|
if x == 0 and absY < 1280: |
|
flags.append(onCurveBit + ((absY & 0xF00) >> 7) + ySignBit) |
|
triplets.append(absY & 0xFF) |
|
elif y == 0 and absX < 1280: |
|
flags.append(onCurveBit + 10 + ((absX & 0xF00) >> 7) + xSignBit) |
|
triplets.append(absX & 0xFF) |
|
elif absX < 65 and absY < 65: |
|
flags.append( |
|
onCurveBit |
|
+ 20 |
|
+ ((absX - 1) & 0x30) |
|
+ (((absY - 1) & 0x30) >> 2) |
|
+ xySignBits |
|
) |
|
triplets.append((((absX - 1) & 0xF) << 4) | ((absY - 1) & 0xF)) |
|
elif absX < 769 and absY < 769: |
|
flags.append( |
|
onCurveBit |
|
+ 84 |
|
+ 12 * (((absX - 1) & 0x300) >> 8) |
|
+ (((absY - 1) & 0x300) >> 6) |
|
+ xySignBits |
|
) |
|
triplets.append((absX - 1) & 0xFF) |
|
triplets.append((absY - 1) & 0xFF) |
|
elif absX < 4096 and absY < 4096: |
|
flags.append(onCurveBit + 120 + xySignBits) |
|
triplets.append(absX >> 4) |
|
triplets.append(((absX & 0xF) << 4) | (absY >> 8)) |
|
triplets.append(absY & 0xFF) |
|
else: |
|
flags.append(onCurveBit + 124 + xySignBits) |
|
triplets.append(absX >> 8) |
|
triplets.append(absX & 0xFF) |
|
triplets.append(absY >> 8) |
|
triplets.append(absY & 0xFF) |
|
|
|
self.flagStream += flags.tobytes() |
|
self.glyphStream += triplets.tobytes() |
|
|
|
|
|
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) |
|
|
|
|
|
hasLsbArray = flags & 1 == 0 |
|
|
|
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: |
|
|
|
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: |
|
|
|
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 |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
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 hasLsbArray and hasLeftSideBearingArray: |
|
return |
|
|
|
|
|
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.tobytes() |
|
|
|
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.tobytes() |
|
|
|
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.tobytes() |
|
|
|
return data |
|
|
|
|
|
class WOFF2FlavorData(WOFFFlavorData): |
|
Flavor = "woff2" |
|
|
|
def __init__(self, reader=None, data=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. |
|
|
|
Args: |
|
reader: an SFNTReader (or subclass) object to read flavor data from. |
|
data: another WOFFFlavorData object to initialise data from. |
|
transformedTables: set of strings containing table tags to be transformed. |
|
|
|
Raises: |
|
ImportError if the brotli module is not installed. |
|
|
|
NOTE: The 'reader' argument, on the one hand, and the 'data' and |
|
'transformedTables' arguments, on the other hand, are mutually exclusive. |
|
""" |
|
if not haveBrotli: |
|
raise ImportError("No module named brotli") |
|
|
|
if reader is not None: |
|
if data is not None: |
|
raise TypeError("'reader' and 'data' arguments are mutually exclusive") |
|
if transformedTables is not None: |
|
raise TypeError( |
|
"'reader' and 'transformedTables' arguments are mutually exclusive" |
|
) |
|
|
|
if transformedTables is not None and ( |
|
"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") |
|
super(WOFF2FlavorData, self).__init__(reader=reader) |
|
if reader: |
|
transformedTables = [ |
|
tag for tag, entry in reader.tables.items() if entry.transformed |
|
] |
|
elif data: |
|
self.majorVersion = data.majorVersion |
|
self.majorVersion = data.minorVersion |
|
self.metaData = data.metaData |
|
self.privData = data.privData |
|
if transformedTables is None and hasattr(data, "transformedTables"): |
|
transformedTables = data.transformedTables |
|
|
|
if transformedTables is None: |
|
transformedTables = woff2TransformedTableTags |
|
|
|
self.transformedTables = set(transformedTables) |
|
|
|
def _decompress(self, rawData): |
|
return brotli.decompress(rawData) |
|
|
|
|
|
def unpackBase128(data): |
|
r"""Read one to five bytes from UIntBase128-encoded input string, and return |
|
a tuple containing the decoded integer plus any leftover data. |
|
|
|
>>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00") |
|
True |
|
>>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295 |
|
True |
|
>>> unpackBase128(b'\x80\x80\x3f') # doctest: +IGNORE_EXCEPTION_DETAIL |
|
Traceback (most recent call last): |
|
File "<stdin>", line 1, in ? |
|
TTLibError: UIntBase128 value must not start with leading zeros |
|
>>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0] # doctest: +IGNORE_EXCEPTION_DETAIL |
|
Traceback (most recent call last): |
|
File "<stdin>", line 1, in ? |
|
TTLibError: UIntBase128-encoded sequence is longer than 5 bytes |
|
>>> unpackBase128(b'\x90\x80\x80\x80\x00')[0] # doctest: +IGNORE_EXCEPTION_DETAIL |
|
Traceback (most recent call last): |
|
File "<stdin>", line 1, in ? |
|
TTLibError: UIntBase128 value exceeds 2**32-1 |
|
""" |
|
if len(data) == 0: |
|
raise TTLibError("not enough data to unpack UIntBase128") |
|
result = 0 |
|
if byteord(data[0]) == 0x80: |
|
|
|
raise TTLibError("UIntBase128 value must not start with leading zeros") |
|
for i in range(woff2Base128MaxSize): |
|
if len(data) == 0: |
|
raise TTLibError("not enough data to unpack UIntBase128") |
|
code = byteord(data[0]) |
|
data = data[1:] |
|
|
|
if result & 0xFE000000: |
|
raise TTLibError("UIntBase128 value exceeds 2**32-1") |
|
|
|
result = (result << 7) | (code & 0x7F) |
|
|
|
if (code & 0x80) == 0: |
|
|
|
return result, data |
|
|
|
raise TTLibError("UIntBase128-encoded sequence is longer than 5 bytes") |
|
|
|
|
|
def base128Size(n): |
|
"""Return the length in bytes of a UIntBase128-encoded sequence with value n. |
|
|
|
>>> base128Size(0) |
|
1 |
|
>>> base128Size(24567) |
|
3 |
|
>>> base128Size(2**32-1) |
|
5 |
|
""" |
|
assert n >= 0 |
|
size = 1 |
|
while n >= 128: |
|
size += 1 |
|
n >>= 7 |
|
return size |
|
|
|
|
|
def packBase128(n): |
|
r"""Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of |
|
bytes using UIntBase128 variable-length encoding. Produce the shortest possible |
|
encoding. |
|
|
|
>>> packBase128(63) == b"\x3f" |
|
True |
|
>>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f' |
|
True |
|
""" |
|
if n < 0 or n >= 2**32: |
|
raise TTLibError("UIntBase128 format requires 0 <= integer <= 2**32-1") |
|
data = b"" |
|
size = base128Size(n) |
|
for i in range(size): |
|
b = (n >> (7 * (size - i - 1))) & 0x7F |
|
if i < size - 1: |
|
b |= 0x80 |
|
data += struct.pack("B", b) |
|
return data |
|
|
|
|
|
def unpack255UShort(data): |
|
"""Read one to three bytes from 255UInt16-encoded input string, and return a |
|
tuple containing the decoded integer plus any leftover data. |
|
|
|
>>> unpack255UShort(bytechr(252))[0] |
|
252 |
|
|
|
Note that some numbers (e.g. 506) can have multiple encodings: |
|
>>> unpack255UShort(struct.pack("BB", 254, 0))[0] |
|
506 |
|
>>> unpack255UShort(struct.pack("BB", 255, 253))[0] |
|
506 |
|
>>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0] |
|
506 |
|
""" |
|
code = byteord(data[:1]) |
|
data = data[1:] |
|
if code == 253: |
|
|
|
if len(data) < 2: |
|
raise TTLibError("not enough data to unpack 255UInt16") |
|
(result,) = struct.unpack(">H", data[:2]) |
|
data = data[2:] |
|
elif code == 254: |
|
|
|
if len(data) == 0: |
|
raise TTLibError("not enough data to unpack 255UInt16") |
|
result = byteord(data[:1]) |
|
result += 506 |
|
data = data[1:] |
|
elif code == 255: |
|
|
|
if len(data) == 0: |
|
raise TTLibError("not enough data to unpack 255UInt16") |
|
result = byteord(data[:1]) |
|
result += 253 |
|
data = data[1:] |
|
else: |
|
|
|
result = code |
|
|
|
return result, data |
|
|
|
|
|
def pack255UShort(value): |
|
r"""Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring |
|
using 255UInt16 variable-length encoding. |
|
|
|
>>> pack255UShort(252) == b'\xfc' |
|
True |
|
>>> pack255UShort(506) == b'\xfe\x00' |
|
True |
|
>>> pack255UShort(762) == b'\xfd\x02\xfa' |
|
True |
|
""" |
|
if value < 0 or value > 0xFFFF: |
|
raise TTLibError("255UInt16 format requires 0 <= integer <= 65535") |
|
if value < 253: |
|
return struct.pack(">B", value) |
|
elif value < 506: |
|
return struct.pack(">BB", 255, value - 253) |
|
elif value < 762: |
|
return struct.pack(">BB", 254, value - 506) |
|
else: |
|
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( |
|
data=font.flavorData, 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): |
|
"""Compress and decompress WOFF2 fonts""" |
|
import argparse |
|
from fontTools import configLogger |
|
from fontTools.ttx import makeOutputFileName |
|
|
|
class _HelpAction(argparse._HelpAction): |
|
def __call__(self, parser, namespace, values, option_string=None): |
|
subparsers_actions = [ |
|
action |
|
for action in parser._actions |
|
if isinstance(action, argparse._SubParsersAction) |
|
] |
|
for subparsers_action in subparsers_actions: |
|
for choice, subparser in subparsers_action.choices.items(): |
|
print(subparser.format_help()) |
|
parser.exit() |
|
|
|
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=main.__doc__, add_help=False |
|
) |
|
|
|
parser.add_argument( |
|
"-h", "--help", action=_HelpAction, help="show this help message and exit" |
|
) |
|
|
|
parser_group = parser.add_subparsers(title="sub-commands") |
|
parser_compress = parser_group.add_parser( |
|
"compress", description="Compress a TTF or OTF font to WOFF2" |
|
) |
|
parser_decompress = parser_group.add_parser( |
|
"decompress", description="Decompress a WOFF2 font to OTF" |
|
) |
|
|
|
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: |
|
|
|
with open(options["input_file"], "rb") as f: |
|
f.seek(4) |
|
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__": |
|
sys.exit(main()) |
|
|