diff --git a/Lib/fontTools/cffLib/CFF2ToCFF.py b/Lib/fontTools/cffLib/CFF2ToCFF.py new file mode 100644 index 000000000..5dc48a7fc --- /dev/null +++ b/Lib/fontTools/cffLib/CFF2ToCFF.py @@ -0,0 +1,165 @@ +"""CFF2 to CFF converter.""" + +from fontTools.ttLib import TTFont, newTable +from fontTools.misc.cliTools import makeOutputFileName +from fontTools.cffLib import TopDictIndex, buildOrder, topDictOperators +from .width import optimizeWidths +from collections import defaultdict +import logging + + +__all__ = ["convertCFF2ToCFF", "main"] + + +log = logging.getLogger("fontTools.cffLib") + + +def _convertCFF2ToCFF(cff, otFont): + """Converts this object from CFF2 format to CFF format. This conversion + is done 'in-place'. The conversion cannot be reversed. + + The CFF2 font cannot be variable. (TODO Accept those and convert to the + default instance?) + + This assumes a decompiled CFF table. (i.e. that the object has been + filled via :meth:`decompile` and e.g. not loaded from XML.)""" + + cff.major = 1 + + topDictData = TopDictIndex(None, isCFF2=True) + for item in cff.topDictIndex: + # Iterate over, such that all are decompiled + topDictData.append(item) + cff.topDictIndex = topDictData + topDict = topDictData[0] + + if hasattr(topDict, "VarStore"): + raise ValueError("Variable CFF2 font cannot be converted to CFF format.") + + if hasattr(topDict, "Private"): + privateDict = topDict.Private + else: + privateDict = None + opOrder = buildOrder(topDictOperators) + topDict.order = opOrder + + fdArray = topDict.FDArray + charStrings = topDict.CharStrings + + for cs in charStrings.values(): + cs.decompile() + cs.program.append("endchar") + for subrSets in [cff.GlobalSubrs] + [ + getattr(fd.Private, "Subrs", []) for fd in fdArray + ]: + for cs in subrSets: + cs.program.append("return") + + # Add (optimal) width to CharStrings that need it. + widths = defaultdict(list) + metrics = otFont["hmtx"].metrics + for glyphName in charStrings.keys(): + cs, fdIndex = charStrings.getItemAndSelector(glyphName) + if fdIndex == None: + fdIndex = 0 + widths[fdIndex].append(metrics[glyphName][0]) + for fdIndex, widthList in widths.items(): + bestDefault, bestNominal = optimizeWidths(widthList) + private = fdArray[fdIndex].Private + private.defaultWidthX = bestDefault + private.nominalWidthX = bestNominal + for glyphName in charStrings.keys(): + cs, fdIndex = charStrings.getItemAndSelector(glyphName) + if fdIndex == None: + fdIndex = 0 + private = fdArray[fdIndex].Private + width = metrics[glyphName][0] + if width != private.defaultWidthX: + cs.program.insert(0, width - private.nominalWidthX) + + +def convertCFF2ToCFF(font, *, updatePostTable=True): + cff = font["CFF2"].cff + _convertCFF2ToCFF(cff, font) + del font["CFF2"] + table = font["CFF "] = newTable("CFF ") + table.cff = cff + + if updatePostTable and "post" in font: + # Only version supported for fonts with CFF table is 0x00030000 not 0x20000 + post = font["post"] + if post.formatType == 2.0: + post.formatType = 3.0 + + +def main(args=None): + """Convert CFF OTF font to CFF2 OTF font""" + if args is None: + import sys + + args = sys.argv[1:] + + import argparse + + parser = argparse.ArgumentParser( + "fonttools cffLib.CFFToCFF2", + description="Upgrade a CFF font to CFF2.", + ) + parser.add_argument( + "input", metavar="INPUT.ttf", help="Input OTF file with CFF table." + ) + parser.add_argument( + "-o", + "--output", + metavar="OUTPUT.ttf", + default=None, + help="Output instance OTF file (default: INPUT-CFF2.ttf).", + ) + parser.add_argument( + "--no-recalc-timestamp", + dest="recalc_timestamp", + action="store_false", + help="Don't set the output font's timestamp to the current time.", + ) + loggingGroup = parser.add_mutually_exclusive_group(required=False) + loggingGroup.add_argument( + "-v", "--verbose", action="store_true", help="Run more verbosely." + ) + loggingGroup.add_argument( + "-q", "--quiet", action="store_true", help="Turn verbosity off." + ) + options = parser.parse_args(args) + + from fontTools import configLogger + + configLogger( + level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") + ) + + import os + + infile = options.input + if not os.path.isfile(infile): + parser.error("No such file '{}'".format(infile)) + + outfile = ( + makeOutputFileName(infile, overWrite=True, suffix="-CFF") + if not options.output + else options.output + ) + + font = TTFont(infile, recalcTimestamp=options.recalc_timestamp, recalcBBoxes=False) + + convertCFF2ToCFF(font) + + log.info( + "Saving %s", + outfile, + ) + font.save(outfile) + + +if __name__ == "__main__": + import sys + + sys.exit(main(sys.argv[1:])) diff --git a/Lib/fontTools/cffLib/CFFToCFF2.py b/Lib/fontTools/cffLib/CFFToCFF2.py new file mode 100644 index 000000000..804af8477 --- /dev/null +++ b/Lib/fontTools/cffLib/CFFToCFF2.py @@ -0,0 +1,273 @@ +"""CFF to CFF2 converter.""" + +from fontTools.ttLib import TTFont, newTable +from fontTools.misc.cliTools import makeOutputFileName +from fontTools.misc.psCharStrings import T2WidthExtractor +from fontTools.cffLib import ( + TopDictIndex, + FDArrayIndex, + FontDict, + buildOrder, + topDictOperators, + privateDictOperators, + topDictOperators2, + privateDictOperators2, +) +from io import BytesIO +import logging + +__all__ = ["convertCFFToCFF2", "main"] + + +log = logging.getLogger("fontTools.cffLib") + + +class _NominalWidthUsedError(Exception): + def __add__(self, other): + raise self + + def __radd__(self, other): + raise self + + +def _convertCFFToCFF2(cff, otFont): + """Converts this object from CFF format to CFF2 format. This conversion + is done 'in-place'. The conversion cannot be reversed. + + This assumes a decompiled CFF table. (i.e. that the object has been + filled via :meth:`decompile` and e.g. not loaded from XML.)""" + + # Clean up T2CharStrings + + topDict = cff.topDictIndex[0] + fdArray = topDict.FDArray if hasattr(topDict, "FDArray") else None + charStrings = topDict.CharStrings + globalSubrs = cff.GlobalSubrs + localSubrs = [getattr(fd.Private, "Subrs", []) for fd in fdArray] if fdArray else [] + + for glyphName in charStrings.keys(): + cs, fdIndex = charStrings.getItemAndSelector(glyphName) + cs.decompile() + + # Clean up subroutines first + for subrs in [globalSubrs] + localSubrs: + for subr in subrs: + program = subr.program + i = j = len(program) + try: + i = program.index("return") + except ValueError: + pass + try: + j = program.index("endchar") + except ValueError: + pass + program[min(i, j) :] = [] + + # Clean up glyph charstrings + nominalWidthXError = _NominalWidthUsedError() + for glyphName in charStrings.keys(): + cs, fdIndex = charStrings.getItemAndSelector(glyphName) + program = cs.program + if fdIndex == None: + fdIndex = 0 + + # Intentionally use custom type for nominalWidthX, such that any + # CharString that has an explicit width encoded will throw back to us. + extractor = T2WidthExtractor( + localSubrs[fdIndex] if localSubrs else [], + globalSubrs, + nominalWidthXError, + 0, + ) + try: + extractor.execute(cs) + except _NominalWidthUsedError: + # Program has explicit width. We want to drop it, but can't + # just pop the first number since it may be a subroutine call. + # Instead, when seeing that, we embed the subroutine and recurse. + # This has the problem that some subroutines might become unused. + # We don't currently prune those. Subset module has code for this + # kind of stuff, possibly plug it in here if pruning becomes needed. + while program[1] in ["callsubr", "callgsubr"]: + subrNumber = program.pop(0) + op = program.pop(0) + bias = extractor.localBias if op == "callsubr" else extractor.globalBias + subrNumber += bias + subrSet = localSubrs[fdIndex] if op == "callsubr" else globalSubrs + subrProgram = subrSet[subrNumber].program + program[:0] = subrProgram + # Now pop the actual width + program.pop(0) + + if program and program[-1] == "endchar": + program.pop() + + # Upconvert TopDict + + cff.major = 2 + cff2GetGlyphOrder = cff.otFont.getGlyphOrder + topDictData = TopDictIndex(None, cff2GetGlyphOrder) + for item in cff.topDictIndex: + # Iterate over, such that all are decompiled + topDictData.append(item) + cff.topDictIndex = topDictData + topDict = topDictData[0] + if hasattr(topDict, "Private"): + privateDict = topDict.Private + else: + privateDict = None + opOrder = buildOrder(topDictOperators2) + topDict.order = opOrder + topDict.cff2GetGlyphOrder = cff2GetGlyphOrder + + if not hasattr(topDict, "FDArray"): + fdArray = topDict.FDArray = FDArrayIndex() + fdArray.strings = None + fdArray.GlobalSubrs = topDict.GlobalSubrs + topDict.GlobalSubrs.fdArray = fdArray + charStrings = topDict.CharStrings + if charStrings.charStringsAreIndexed: + charStrings.charStringsIndex.fdArray = fdArray + else: + charStrings.fdArray = fdArray + fontDict = FontDict() + fontDict.setCFF2(True) + fdArray.append(fontDict) + fontDict.Private = privateDict + privateOpOrder = buildOrder(privateDictOperators2) + if privateDict is not None: + for entry in privateDictOperators: + key = entry[1] + if key not in privateOpOrder: + if key in privateDict.rawDict: + # print "Removing private dict", key + del privateDict.rawDict[key] + if hasattr(privateDict, key): + delattr(privateDict, key) + # print "Removing privateDict attr", key + else: + # clean up the PrivateDicts in the fdArray + fdArray = topDict.FDArray + privateOpOrder = buildOrder(privateDictOperators2) + for fontDict in fdArray: + fontDict.setCFF2(True) + for key in list(fontDict.rawDict.keys()): + if key not in fontDict.order: + del fontDict.rawDict[key] + if hasattr(fontDict, key): + delattr(fontDict, key) + + privateDict = fontDict.Private + for entry in privateDictOperators: + key = entry[1] + if key not in privateOpOrder: + if key in list(privateDict.rawDict.keys()): + # print "Removing private dict", key + del privateDict.rawDict[key] + if hasattr(privateDict, key): + delattr(privateDict, key) + # print "Removing privateDict attr", key + + # Now delete up the deprecated topDict operators from CFF 1.0 + for entry in topDictOperators: + key = entry[1] + if key not in opOrder: + if key in topDict.rawDict: + del topDict.rawDict[key] + if hasattr(topDict, key): + delattr(topDict, key) + + # TODO(behdad): What does the following comment even mean? Both CFF and CFF2 + # use the same T2Charstring class. I *think* what it means is that the CharStrings + # were loaded for CFF1, and we need to reload them for CFF2 to set varstore, etc + # on them. At least that's what I understand. It's probably safe to remove this + # and just set vstore where needed. + + # At this point, the Subrs and Charstrings are all still T2Charstring class + # easiest to fix this by compiling, then decompiling again + file = BytesIO() + cff.compile(file, otFont, isCFF2=True) + file.seek(0) + cff.decompile(file, otFont, isCFF2=True) + + +def convertCFFToCFF2(font): + cff = font["CFF "].cff + del font["CFF "] + _convertCFFToCFF2(cff, font) + table = font["CFF2"] = newTable("CFF2") + table.cff = cff + + +def main(args=None): + """Convert CFF OTF font to CFF2 OTF font""" + if args is None: + import sys + + args = sys.argv[1:] + + import argparse + + parser = argparse.ArgumentParser( + "fonttools cffLib.CFFToCFF2", + description="Upgrade a CFF font to CFF2.", + ) + parser.add_argument( + "input", metavar="INPUT.ttf", help="Input OTF file with CFF table." + ) + parser.add_argument( + "-o", + "--output", + metavar="OUTPUT.ttf", + default=None, + help="Output instance OTF file (default: INPUT-CFF2.ttf).", + ) + parser.add_argument( + "--no-recalc-timestamp", + dest="recalc_timestamp", + action="store_false", + help="Don't set the output font's timestamp to the current time.", + ) + loggingGroup = parser.add_mutually_exclusive_group(required=False) + loggingGroup.add_argument( + "-v", "--verbose", action="store_true", help="Run more verbosely." + ) + loggingGroup.add_argument( + "-q", "--quiet", action="store_true", help="Turn verbosity off." + ) + options = parser.parse_args(args) + + from fontTools import configLogger + + configLogger( + level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") + ) + + import os + + infile = options.input + if not os.path.isfile(infile): + parser.error("No such file '{}'".format(infile)) + + outfile = ( + makeOutputFileName(infile, overWrite=True, suffix="-CFF2") + if not options.output + else options.output + ) + + font = TTFont(infile, recalcTimestamp=options.recalc_timestamp, recalcBBoxes=False) + + convertCFFToCFF2(font) + + log.info( + "Saving %s", + outfile, + ) + font.save(outfile) + + +if __name__ == "__main__": + import sys + + sys.exit(main(sys.argv[1:])) diff --git a/Lib/fontTools/cffLib/__init__.py b/Lib/fontTools/cffLib/__init__.py index f1e78d24d..bd954f515 100644 --- a/Lib/fontTools/cffLib/__init__.py +++ b/Lib/fontTools/cffLib/__init__.py @@ -99,9 +99,6 @@ class _DesubroutinizingT2Decompiler(psCharStrings.SimpleT2Decompiler): desubroutinized = desubroutinized[ : desubroutinized.index("endchar") + 1 ] - else: - if not len(desubroutinized) or desubroutinized[-1] != "return": - desubroutinized.append("return") charString._desubroutinized = desubroutinized del charString._patches @@ -389,91 +386,20 @@ class CFFFontSet(object): self.minor = int(attrs["value"]) def convertCFFToCFF2(self, otFont): - """Converts this object from CFF format to CFF2 format. This conversion - is done 'in-place'. The conversion cannot be reversed. + from .CFFToCFF2 import _convertCFFToCFF2 - This assumes a decompiled CFF table. (i.e. that the object has been - filled via :meth:`decompile`.)""" - self.major = 2 - cff2GetGlyphOrder = self.otFont.getGlyphOrder - topDictData = TopDictIndex(None, cff2GetGlyphOrder) - topDictData.items = self.topDictIndex.items - self.topDictIndex = topDictData - topDict = topDictData[0] - if hasattr(topDict, "Private"): - privateDict = topDict.Private - else: - privateDict = None - opOrder = buildOrder(topDictOperators2) - topDict.order = opOrder - topDict.cff2GetGlyphOrder = cff2GetGlyphOrder - for entry in topDictOperators: - key = entry[1] - if key not in opOrder: - if key in topDict.rawDict: - del topDict.rawDict[key] - if hasattr(topDict, key): - delattr(topDict, key) + _convertCFFToCFF2(self, otFont) - if not hasattr(topDict, "FDArray"): - fdArray = topDict.FDArray = FDArrayIndex() - fdArray.strings = None - fdArray.GlobalSubrs = topDict.GlobalSubrs - topDict.GlobalSubrs.fdArray = fdArray - charStrings = topDict.CharStrings - if charStrings.charStringsAreIndexed: - charStrings.charStringsIndex.fdArray = fdArray - else: - charStrings.fdArray = fdArray - fontDict = FontDict() - fontDict.setCFF2(True) - fdArray.append(fontDict) - fontDict.Private = privateDict - privateOpOrder = buildOrder(privateDictOperators2) - for entry in privateDictOperators: - key = entry[1] - if key not in privateOpOrder: - if key in privateDict.rawDict: - # print "Removing private dict", key - del privateDict.rawDict[key] - if hasattr(privateDict, key): - delattr(privateDict, key) - # print "Removing privateDict attr", key - else: - # clean up the PrivateDicts in the fdArray - fdArray = topDict.FDArray - privateOpOrder = buildOrder(privateDictOperators2) - for fontDict in fdArray: - fontDict.setCFF2(True) - for key in fontDict.rawDict.keys(): - if key not in fontDict.order: - del fontDict.rawDict[key] - if hasattr(fontDict, key): - delattr(fontDict, key) + def convertCFF2ToCFF(self, otFont): + from .CFF2ToCFF import _convertCFF2ToCFF - privateDict = fontDict.Private - for entry in privateDictOperators: - key = entry[1] - if key not in privateOpOrder: - if key in privateDict.rawDict: - # print "Removing private dict", key - del privateDict.rawDict[key] - if hasattr(privateDict, key): - delattr(privateDict, key) - # print "Removing privateDict attr", key - # At this point, the Subrs and Charstrings are all still T2Charstring class - # easiest to fix this by compiling, then decompiling again - file = BytesIO() - self.compile(file, otFont, isCFF2=True) - file.seek(0) - self.decompile(file, otFont, isCFF2=True) + _convertCFF2ToCFF(self, otFont) def desubroutinize(self): for fontName in self.fontNames: font = self[fontName] cs = font.CharStrings - for g in font.charset: - c, _ = cs.getItemAndSelector(g) + for c in cs.values(): c.decompile() subrs = getattr(c.private, "Subrs", []) decompiler = _DesubroutinizingT2Decompiler( @@ -764,8 +690,8 @@ class Index(object): compilerClass = IndexCompiler def __init__(self, file=None, isCFF2=None): - assert (isCFF2 is None) == (file is None) self.items = [] + self.offsets = offsets = [] name = self.__class__.__name__ if file is None: return @@ -782,7 +708,6 @@ class Index(object): offSize = readCard8(file) log.log(DEBUG, " index count: %s offSize: %s", count, offSize) assert offSize <= 4, "offSize too large: %s" % offSize - self.offsets = offsets = [] pad = b"\0" * (4 - offSize) for index in range(count + 1): chunk = file.read(offSize) @@ -960,7 +885,6 @@ class TopDictIndex(Index): compilerClass = TopDictIndexCompiler def __init__(self, file=None, cff2GetGlyphOrder=None, topSize=0, isCFF2=None): - assert (isCFF2 is None) == (file is None) self.cff2GetGlyphOrder = cff2GetGlyphOrder if file is not None and isCFF2: self._isCFF2 = isCFF2 @@ -2861,9 +2785,11 @@ class PrivateDict(BaseDict): # Provide dummy values. This avoids needing to provide # an isCFF2 state in a lot of places. self.nominalWidthX = self.defaultWidthX = None + self._isCFF2 = True else: self.defaults = buildDefaults(privateDictOperators) self.order = buildOrder(privateDictOperators) + self._isCFF2 = False @property def in_cff2(self): diff --git a/Lib/fontTools/cffLib/specializer.py b/Lib/fontTools/cffLib/specializer.py index 39f51ed75..bb7f89e4f 100644 --- a/Lib/fontTools/cffLib/specializer.py +++ b/Lib/fontTools/cffLib/specializer.py @@ -43,10 +43,8 @@ def programToCommands(program, getNumRegions=None): hintmask/cntrmask argument, as well as stray arguments at the end of the program (🤷). 'getNumRegions' may be None, or a callable object. It must return the - number of regions. 'getNumRegions' takes a single argument, vsindex. If - the vsindex argument is None, getNumRegions returns the default number - of regions for the charstring, else it returns the numRegions for - the vsindex. + number of regions. 'getNumRegions' takes a single argument, vsindex. It + returns the numRegions for the vsindex. The Charstring may or may not start with a width value. If the first non-blend operator has an odd number of arguments, then the first argument is a width, and is popped off. This is complicated with blend operators, as @@ -61,7 +59,7 @@ def programToCommands(program, getNumRegions=None): """ seenWidthOp = False - vsIndex = None + vsIndex = 0 lenBlendStack = 0 lastBlendIndex = 0 commands = [] diff --git a/Lib/fontTools/cffLib/width.py b/Lib/fontTools/cffLib/width.py index 0ba3ed39b..78ff27e4f 100644 --- a/Lib/fontTools/cffLib/width.py +++ b/Lib/fontTools/cffLib/width.py @@ -13,6 +13,9 @@ from operator import add from functools import reduce +__all__ = ["optimizeWidths", "main"] + + class missingdict(dict): def __init__(self, missing_func): self.missing_func = missing_func diff --git a/Lib/fontTools/merge/tables.py b/Lib/fontTools/merge/tables.py index 57ed64d33..d132cb2a0 100644 --- a/Lib/fontTools/merge/tables.py +++ b/Lib/fontTools/merge/tables.py @@ -294,6 +294,8 @@ def merge(self, m, tables): extractor.execute(c) width = extractor.width if width is not defaultWidthXToken: + # The following will be wrong if the width is added + # by a subroutine. Ouch! c.program.pop(0) else: width = defaultWidthX diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index 578f6328f..8a9f146b2 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -537,7 +537,7 @@ class TTFont(object): # # Not enough names found in the 'post' table. # Can happen when 'post' format 1 is improperly used on a font that - # has more than 258 glyphs (the lenght of 'standardGlyphOrder'). + # has more than 258 glyphs (the length of 'standardGlyphOrder'). # log.warning( "Not enough names found in the 'post' table, generating them from cmap instead" diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 1e0f2ec2f..6d0e00ee1 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -845,9 +845,10 @@ def _add_CFF2(varFont, model, master_fonts): glyphOrder = varFont.getGlyphOrder() if "CFF2" not in varFont: - from .cff import convertCFFtoCFF2 + from fontTools.cffLib.CFFToCFF2 import convertCFFToCFF2 + + convertCFFToCFF2(varFont) - convertCFFtoCFF2(varFont) ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping) # re-ordering the master list simplifies building the CFF2 data item lists. merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) diff --git a/Lib/fontTools/varLib/cff.py b/Lib/fontTools/varLib/cff.py index 52e6a8848..393c793e3 100644 --- a/Lib/fontTools/varLib/cff.py +++ b/Lib/fontTools/varLib/cff.py @@ -49,95 +49,6 @@ def addCFFVarStore(varFont, varModel, varDataList, masterSupports): fontDict.Private.vstore = topDict.VarStore -def lib_convertCFFToCFF2(cff, otFont): - # This assumes a decompiled CFF table. - cff2GetGlyphOrder = cff.otFont.getGlyphOrder - topDictData = TopDictIndex(None, cff2GetGlyphOrder, None) - topDictData.items = cff.topDictIndex.items - cff.topDictIndex = topDictData - topDict = topDictData[0] - if hasattr(topDict, "Private"): - privateDict = topDict.Private - else: - privateDict = None - opOrder = buildOrder(topDictOperators2) - topDict.order = opOrder - topDict.cff2GetGlyphOrder = cff2GetGlyphOrder - if not hasattr(topDict, "FDArray"): - fdArray = topDict.FDArray = FDArrayIndex() - fdArray.strings = None - fdArray.GlobalSubrs = topDict.GlobalSubrs - topDict.GlobalSubrs.fdArray = fdArray - charStrings = topDict.CharStrings - if charStrings.charStringsAreIndexed: - charStrings.charStringsIndex.fdArray = fdArray - else: - charStrings.fdArray = fdArray - fontDict = FontDict() - fontDict.setCFF2(True) - fdArray.append(fontDict) - fontDict.Private = privateDict - privateOpOrder = buildOrder(privateDictOperators2) - if privateDict is not None: - for entry in privateDictOperators: - key = entry[1] - if key not in privateOpOrder: - if key in privateDict.rawDict: - # print "Removing private dict", key - del privateDict.rawDict[key] - if hasattr(privateDict, key): - delattr(privateDict, key) - # print "Removing privateDict attr", key - else: - # clean up the PrivateDicts in the fdArray - fdArray = topDict.FDArray - privateOpOrder = buildOrder(privateDictOperators2) - for fontDict in fdArray: - fontDict.setCFF2(True) - for key in list(fontDict.rawDict.keys()): - if key not in fontDict.order: - del fontDict.rawDict[key] - if hasattr(fontDict, key): - delattr(fontDict, key) - - privateDict = fontDict.Private - for entry in privateDictOperators: - key = entry[1] - if key not in privateOpOrder: - if key in privateDict.rawDict: - # print "Removing private dict", key - del privateDict.rawDict[key] - if hasattr(privateDict, key): - delattr(privateDict, key) - # print "Removing privateDict attr", key - # Now delete up the deprecated topDict operators from CFF 1.0 - for entry in topDictOperators: - key = entry[1] - if key not in opOrder: - if key in topDict.rawDict: - del topDict.rawDict[key] - if hasattr(topDict, key): - delattr(topDict, key) - - # At this point, the Subrs and Charstrings are all still T2Charstring class - # easiest to fix this by compiling, then decompiling again - cff.major = 2 - file = BytesIO() - cff.compile(file, otFont, isCFF2=True) - file.seek(0) - cff.decompile(file, otFont, isCFF2=True) - - -def convertCFFtoCFF2(varFont): - # Convert base font to a single master CFF2 font. - cffTable = varFont["CFF "] - lib_convertCFFToCFF2(cffTable.cff, varFont) - newCFF2 = newTable("CFF2") - newCFF2.cff = cffTable.cff - varFont["CFF2"] = newCFF2 - del varFont["CFF "] - - def conv_to_int(num): if isinstance(num, float) and num.is_integer(): return int(num) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 4c58a6143..7120b0831 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -89,7 +89,7 @@ from fontTools.misc.fixedTools import ( otRound, ) from fontTools.varLib.models import normalizeValue, piecewiseLinearMap -from fontTools.ttLib import TTFont +from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import _g_l_y_f from fontTools import varLib @@ -97,6 +97,13 @@ from fontTools import varLib # we import the `subset` module because we use the `prune_lookups` method on the GSUB # table class, and that method is only defined dynamically upon importing `subset` from fontTools import subset # noqa: F401 +from fontTools.cffLib import privateDictOperators2 +from fontTools.cffLib.specializer import ( + programToCommands, + commandsToProgram, + specializeCommands, + generalizeCommands, +) from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger @@ -566,6 +573,259 @@ def changeTupleVariationAxisLimit(var, axisTag, axisLimit): return out +def instantiateCFF2( + varfont, + axisLimits, + *, + round=round, + specialize=True, + generalize=False, + downgrade=False, +): + # The algorithm here is rather simple: + # + # Take all blend operations and store their deltas in the (otherwise empty) + # CFF2 VarStore. Then, instantiate the VarStore with the given axis limits, + # and read back the new deltas. This is done for both the CharStrings and + # the Private dicts. + # + # Then prune unused things and possibly drop the VarStore if it's empty. + # In which case, downgrade to CFF table if requested. + + log.info("Instantiating CFF2 table") + + fvarAxes = varfont["fvar"].axes + + cff = varfont["CFF2"].cff + topDict = cff.topDictIndex[0] + varStore = topDict.VarStore.otVarStore + if not varStore: + if downgrade: + from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF + + convertCFF2ToCFF(varfont) + return + + cff.desubroutinize() + + def getNumRegions(vsindex): + return varStore.VarData[vsindex if vsindex is not None else 0].VarRegionCount + + charStrings = topDict.CharStrings.values() + + # Gather all unique private dicts + uniquePrivateDicts = set() + privateDicts = [] + for fd in topDict.FDArray: + if fd.Private not in uniquePrivateDicts: + uniquePrivateDicts.add(fd.Private) + privateDicts.append(fd.Private) + + allCommands = [] + for cs in charStrings: + assert cs.private.vstore.otVarStore is varStore # Or in many places!! + commands = programToCommands(cs.program, getNumRegions=getNumRegions) + if generalize: + commands = generalizeCommands(commands) + if specialize: + commands = specializeCommands(commands, generalizeFirst=not generalize) + allCommands.append(commands) + + def storeBlendsToVarStore(arg): + if not isinstance(arg, list): + return + + if any(isinstance(subarg, list) for subarg in arg[:-1]): + raise NotImplementedError("Nested blend lists not supported (yet)") + + count = arg[-1] + assert (len(arg) - 1) % count == 0 + nRegions = (len(arg) - 1) // count - 1 + assert nRegions == getNumRegions(vsindex) + for i in range(count, len(arg) - 1, nRegions): + deltas = arg[i : i + nRegions] + assert len(deltas) == nRegions + varData = varStore.VarData[vsindex] + varData.Item.append(deltas) + varData.ItemCount += 1 + + def fetchBlendsFromVarStore(arg): + if not isinstance(arg, list): + return [arg] + + if any(isinstance(subarg, list) for subarg in arg[:-1]): + raise NotImplementedError("Nested blend lists not supported (yet)") + + count = arg[-1] + assert (len(arg) - 1) % count == 0 + numRegions = getNumRegions(vsindex) + newDefaults = [] + newDeltas = [] + for i in range(count): + defaultValue = arg[i] + + major = vsindex + minor = varDataCursor[major] + varDataCursor[major] += 1 + + varIdx = (major << 16) + minor + + defaultValue += round(defaultDeltas[varIdx]) + newDefaults.append(defaultValue) + + varData = varStore.VarData[major] + deltas = varData.Item[minor] + assert len(deltas) == numRegions + newDeltas.extend(deltas) + + if not numRegions: + return newDefaults # No deltas, just return the defaults + + return [newDefaults + newDeltas + [count]] + + # Check VarData's are empty + for varData in varStore.VarData: + assert varData.Item == [] + assert varData.ItemCount == 0 + + # Add charstring blend lists to VarStore so we can instantiate them + for commands in allCommands: + vsindex = 0 + for command in commands: + if command[0] == "vsindex": + vsindex = command[1][0] + continue + for arg in command[1]: + storeBlendsToVarStore(arg) + + # Add private blend lists to VarStore so we can instantiate values + vsindex = 0 + for opcode, name, arg_type, default, converter in privateDictOperators2: + if arg_type not in ("number", "delta", "array"): + continue + + vsindex = 0 + for private in privateDicts: + if not hasattr(private, name): + continue + values = getattr(private, name) + + if name == "vsindex": + vsindex = values[0] + continue + + if arg_type == "number": + values = [values] + + for value in values: + if not isinstance(value, list): + continue + + assert len(value) % (getNumRegions(vsindex) + 1) == 0 + count = len(value) // (getNumRegions(vsindex) + 1) + storeBlendsToVarStore(value + [count]) + + # Instantiate VarStore + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) + + # Read back new charstring blends from the instantiated VarStore + varDataCursor = [0] * len(varStore.VarData) + for commands in allCommands: + vsindex = 0 + for command in commands: + if command[0] == "vsindex": + vsindex = command[1][0] + continue + newArgs = [] + for arg in command[1]: + newArgs.extend(fetchBlendsFromVarStore(arg)) + command[1][:] = newArgs + + # Read back new private blends from the instantiated VarStore + for opcode, name, arg_type, default, converter in privateDictOperators2: + if arg_type not in ("number", "delta", "array"): + continue + + for private in privateDicts: + if not hasattr(private, name): + continue + values = getattr(private, name) + if arg_type == "number": + values = [values] + + newValues = [] + for value in values: + if not isinstance(value, list): + newValues.append(value) + continue + + value.append(1) + value = fetchBlendsFromVarStore(value) + newValues.extend(v[:-1] if isinstance(v, list) else v for v in value) + + if arg_type == "number": + newValues = newValues[0] + + setattr(private, name, newValues) + + # Empty out the VarStore + for i, varData in enumerate(varStore.VarData): + assert varDataCursor[i] == varData.ItemCount, ( + varDataCursor[i], + varData.ItemCount, + ) + varData.Item = [] + varData.ItemCount = 0 + + # Remove vsindex commands that are no longer needed, collect those that are. + usedVsindex = set() + for commands in allCommands: + if any(isinstance(arg, list) for command in commands for arg in command[1]): + vsindex = 0 + for command in commands: + if command[0] == "vsindex": + vsindex = command[1][0] + continue + if any(isinstance(arg, list) for arg in command[1]): + usedVsindex.add(vsindex) + else: + commands[:] = [command for command in commands if command[0] != "vsindex"] + + # Remove unused VarData and update vsindex values + vsindexMapping = {v: i for i, v in enumerate(sorted(usedVsindex))} + varStore.VarData = [ + varData for i, varData in enumerate(varStore.VarData) if i in usedVsindex + ] + varStore.VarDataCount = len(varStore.VarData) + for commands in allCommands: + for command in commands: + if command[0] == "vsindex": + command[1][0] = vsindexMapping[command[1][0]] + + # Remove initial vsindex commands that are implied + for commands in allCommands: + if commands and commands[0] == ("vsindex", [0]): + commands.pop(0) + + # Ship the charstrings! + for cs, commands in zip(charStrings, allCommands): + cs.program = commandsToProgram(commands) + + # Remove empty VarStore + if not varStore.VarData: + if "VarStore" in topDict.rawDict: + del topDict.rawDict["VarStore"] + del topDict.VarStore + del topDict.CharStrings.varStore + for private in privateDicts: + del private.vstore + + if downgrade: + from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF + + convertCFF2ToCFF(varfont) + + def _instantiateGvarGlyph( glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=True ): @@ -765,22 +1025,57 @@ def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder): # TODO(anthrotype) Add support for HVAR/VVAR in CFF2 -def _instantiateVHVAR(varfont, axisLimits, tableFields): +def _instantiateVHVAR(varfont, axisLimits, tableFields, *, round=round): location = axisLimits.pinnedLocation() tableTag = tableFields.tableTag fvarAxes = varfont["fvar"].axes - # Deltas from gvar table have already been applied to the hmtx/vmtx. For full - # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return - if set(location).issuperset(axis.axisTag for axis in fvarAxes): - log.info("Dropping %s table", tableTag) - del varfont[tableTag] - return log.info("Instantiating %s table", tableTag) vhvar = varfont[tableTag].table varStore = vhvar.VarStore - # since deltas were already applied, the return value here is ignored - instantiateItemVariationStore(varStore, fvarAxes, axisLimits) + + if "glyf" in varfont: + # Deltas from gvar table have already been applied to the hmtx/vmtx. For full + # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return + if set(location).issuperset(axis.axisTag for axis in fvarAxes): + log.info("Dropping %s table", tableTag) + del varfont[tableTag] + return + + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) + + if "glyf" not in varfont: + # CFF2 fonts need hmtx/vmtx updated here. For glyf fonts, the instantiateGvar + # function already updated the hmtx/vmtx from phantom points. Maybe remove + # that and do it here for both CFF2 and glyf fonts? + # + # Specially, if a font has glyf but not gvar, the hmtx/vmtx will not have been + # updated by instantiateGvar. Though one can call that a faulty font. + metricsTag = "vmtx" if tableTag == "VVAR" else "hmtx" + if metricsTag in varfont: + advMapping = getattr(vhvar, tableFields.advMapping) + metricsTable = varfont[metricsTag] + metrics = metricsTable.metrics + for glyphName, (advanceWidth, sb) in metrics.items(): + if advMapping: + varIdx = advMapping.mapping[glyphName] + else: + varIdx = varfont.getGlyphID(glyphName) + metrics[glyphName] = (advanceWidth + round(defaultDeltas[varIdx]), sb) + + if ( + tableTag == "VVAR" + and getattr(vhvar, tableFields.vOrigMapping) is not None + ): + log.warning( + "VORG table not yet updated to reflect changes in VVAR table" + ) + + # For full instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return + if set(location).issuperset(axis.axisTag for axis in fvarAxes): + log.info("Dropping %s table", tableTag) + del varfont[tableTag] + return if varStore.VarRegionList.Region: # Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap @@ -923,6 +1218,8 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): newItemVarStore = tupleVarStore.asItemVarStore() itemVarStore.VarRegionList = newItemVarStore.VarRegionList + if not hasattr(itemVarStore, "VarDataCount"): # Happens fromXML + itemVarStore.VarDataCount = len(newItemVarStore.VarData) assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount itemVarStore.VarData = newItemVarStore.VarData @@ -1222,9 +1519,6 @@ def sanityCheckVariableTables(varfont): if "gvar" in varfont: if "glyf" not in varfont: raise ValueError("Can't have gvar without glyf") - # TODO(anthrotype) Remove once we do support partial instancing CFF2 - if "CFF2" in varfont: - raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") def instantiateVariableFont( @@ -1234,6 +1528,8 @@ def instantiateVariableFont( optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, updateFontNames=False, + *, + downgradeCFF2=False, ): """Instantiate variable font, either fully or partially. @@ -1273,6 +1569,11 @@ def instantiateVariableFont( in the head and OS/2 table will be updated so they conform to the R/I/B/BI model. If the STAT table is missing or an Axis Value table is missing for a given axis coordinate, a ValueError will be raised. + downgradeCFF2 (bool): if True, downgrade the CFF2 table to CFF table when possible + ie. full instancing of all axes. This is useful for compatibility with older + software that does not support CFF2. Defaults to False. Note that this + operation also removes overlaps within glyph shapes, as CFF does not support + overlaps but CFF2 does. """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool overlap = OverlapMode(int(overlap)) @@ -1297,6 +1598,9 @@ def instantiateVariableFont( log.info("Updating name table") names.updateNameTable(varfont, axisLimits) + if "CFF2" in varfont: + instantiateCFF2(varfont, normalizedLimits, downgrade=downgradeCFF2) + if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1488,6 +1792,11 @@ def parseArgs(args): help="Update the instantiated font's `name` table. Input font must have " "a STAT table with Axis Value Tables", ) + parser.add_argument( + "--downgrade-cff2", + action="store_true", + help="If all axes are pinned, downgrade CFF2 to CFF table format", + ) parser.add_argument( "--no-recalc-timestamp", dest="recalc_timestamp", @@ -1559,6 +1868,7 @@ def main(args=None): optimize=options.optimize, overlap=options.overlap, updateFontNames=options.update_name_table, + downgradeCFF2=options.downgrade_cff2, ) suffix = "-instance" if isFullInstance else "-partial" diff --git a/Tests/fontBuilder/fontBuilder_test.py b/Tests/fontBuilder/fontBuilder_test.py index c831d02e8..0cbf5d0ce 100644 --- a/Tests/fontBuilder/fontBuilder_test.py +++ b/Tests/fontBuilder/fontBuilder_test.py @@ -330,9 +330,9 @@ def test_build_cff_to_cff2(tmpdir): } fb.setupCFF("TestFont", {}, charStrings, {}) - from fontTools.varLib.cff import convertCFFtoCFF2 + from fontTools.cffLib.CFFToCFF2 import convertCFFToCFF2 - convertCFFtoCFF2(fb.font) + convertCFFToCFF2(fb.font) def test_setupNameTable_no_mac(): diff --git a/Tests/varLib/instancer/data/CFF2Instancer-VF-1.ttx b/Tests/varLib/instancer/data/CFF2Instancer-VF-1.ttx new file mode 100644 index 000000000..c140f2f37 --- /dev/null +++ b/Tests/varLib/instancer/data/CFF2Instancer-VF-1.ttx @@ -0,0 +1,729 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. + + + Noto Sans SC + + + Regular + + + 2.004;ADBO;NotoSansSC-Thin;ADOBE + + + Noto Sans SC + + + Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603 + + + NotoSansSC-Thin + + + Regular + + + Thin + + + Light + + + DemiLight + + + Regular + + + Medium + + + Bold + + + Black + + + Weight + + + Thin + + + NotoSansSC-Thin + + + Light + + + NotoSansSC-Light + + + DemiLight + + + NotoSansSC-DemiLight + + + Regular + + + NotoSansSC-Regular + + + Medium + + + NotoSansSC-Medium + + + Bold + + + NotoSansSC-Bold + + + Black + + + NotoSansSC-Black + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 44 256 6 -29 2 blend + rmoveto + 239 35 -239 44 90 -44 3 blend + hlineto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 100.0 + 100.0 + 900.0 + 265 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer/data/CFF2Instancer-VF-2.ttx b/Tests/varLib/instancer/data/CFF2Instancer-VF-2.ttx new file mode 100644 index 000000000..e26eba29d --- /dev/null +++ b/Tests/varLib/instancer/data/CFF2Instancer-VF-2.ttx @@ -0,0 +1,615 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + License same as MutatorMath. BSD 3-clause. [test-token: C] + + + Regular + + + 1.002;LTTR;MutatorMathTest-LightCondensed + + + MutatorMathTest LightCondensed + + + Version 1.002 + + + MutatorMathTest-LightCondensed + + + Width + + + Weight + + + width + + + weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 blend + hlineto + + + 2 blend + rmoveto + + + 3 blend + hlineto + + + 1 blend + hmoveto + + + 0 100 0 2 blend + rlineto + + + 1 vsindex + 20 30 -30 0 -104 callsubr + 40 7 220 63 -107 callsubr + 140 700 375 -56 -169 -103 callsubr + -35 -7 -195 -43 -107 callsubr + -90 -536 -235 96 79 60 -144 -60 -106 callsubr + 250 36 -250 450 220 -190 -6 174 16 -450 -220 190 -105 callsubr + 257 -200 585 23 -275 -54 -130 44 -106 callsubr + 44 9 296 121 -107 callsubr + -145 700 -375 29 151 -103 callsubr + -39 -9 -281 -121 -107 callsubr + -17 -39 15 -73 5 3 -221 -3 -106 callsubr + 47 39 -47 3 223 147 -3 221 3 -3 -223 -147 -105 callsubr + + + + + + + + + + -102 callsubr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wdth + 0x0 + 0.0 + 0.0 + 1000.0 + 256 + + + + + wght + 0x0 + 100.0 + 100.0 + 900.0 + 257 + + + + diff --git a/Tests/varLib/instancer/data/CFF2Instancer-VF-3.ttx b/Tests/varLib/instancer/data/CFF2Instancer-VF-3.ttx new file mode 100644 index 000000000..55a009ed1 --- /dev/null +++ b/Tests/varLib/instancer/data/CFF2Instancer-VF-3.ttx @@ -0,0 +1,648 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. + + + Noto Sans SC + + + Regular + + + 2.004;ADBO;NotoSansSC-Thin;ADOBE + + + Noto Sans SC + + + Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603 + + + NotoSansSC-Thin + + + Regular + + + Thin + + + Light + + + DemiLight + + + Regular + + + Medium + + + Bold + + + Black + + + Weight + + + Thin + + + NotoSansSC-Thin + + + Light + + + NotoSansSC-Light + + + DemiLight + + + NotoSansSC-DemiLight + + + Regular + + + NotoSansSC-Regular + + + Medium + + + NotoSansSC-Medium + + + Bold + + + NotoSansSC-Bold + + + Black + + + NotoSansSC-Black + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 69 271 -16 33 2 blend + rmoveto + -30 856 30 -95 40 95 3 blend + vlineto + -443 103 -81 -61 2 blend + rmoveto + -446 30 446 5 119 -5 3 blend + vlineto + -62 -110 -125 -2 2 blend + rmoveto + -87 -119 -170 -109 -147 -49 7 -5 9 -11 5 -9 145 54 174 111 90 124 11 40 23 37 5 14 24 -25 35 -45 16 -26 1 -4 -29 -15 1 -12 18 blend + rrcurveto + 71 12 39 33 2 blend + rmoveto + -27 -13 -86 -39 2 blend + rlineto + 88 -127 173 -108 158 -50 5 9 9 10 7 6 -159 45 -170 104 -84 124 7 22 -25 14 -16 1 16 28 35 46 25 23 19 -10 19 -35 6 -50 18 blend + rrcurveto + -451 501 -120 18 2 blend + rmoveto + 42 -51 44 -70 16 -45 -11 5 -10 8 -3 5 6 blend + rrcurveto + 28 15 -17 44 -44 68 -42 50 86 50 3 -4 6 -9 9 -8 8 blend + rlinecurve + 183 56 -86 -8 2 blend + rmoveto + -472 28 472 -50 111 50 3 blend + vlineto + -273 -379 -84 31 2 blend + rmoveto + 16 -26 70 33 93 45 88 42 42 -101 -2 8 -13 5 -16 7 8 blend + rlinecurve + -7 26 -97 -46 -98 -46 -65 -32 87 9 -3 7 -3 5 7 blend + -28 rlinecurve + 467 173 19 -103 2 blend + rmoveto + 59 -44 67 -64 31 -43 -24 2 -24 5 -14 5 6 blend + rrcurveto + 26 17 -33 43 -67 63 -58 43 84 51 13 -5 22 -8 22 -4 8 blend + rlinecurve + 8 104 -63 31 2 blend + rmoveto + -29 340 29 -90 -29 90 3 blend + vlineto + -291 89 -3 -2 2 blend + rmoveto + -33 -81 -82 -84 -90 -50 7 -5 9 -11 4 -6 94 52 84 89 38 88 -6 12 -3 4 6 4 18 -19 29 -37 16 -23 3 6 14 7 25 9 18 blend + rrcurveto + 251 -81 -122 39 2 blend + rmoveto + -7 -14 1 blend + vlineto + -76 -215 -200 -89 -239 -37 6 -7 8 -12 4 -7 243 43 202 90 84 231 17 41 27 6 11 5 19 -20 30 -48 10 -27 -1 4 -4 21 -1 1 18 blend + rrcurveto + -17 12 -7 -2 -71 33 -17 -2 4 blend + rlineto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wght + 0x0 + 100.0 + 100.0 + 900.0 + 265 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-1-instance-400.ttx b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-1-instance-400.ttx new file mode 100644 index 000000000..44237b80c --- /dev/null +++ b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-1-instance-400.ttx @@ -0,0 +1,476 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. + + + Noto Sans SC + + + Regular + + + 2.004;ADBO;NotoSansSC-Thin;ADOBE + + + Noto Sans SC + + + Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603 + + + NotoSansSC-Thin + + + Regular + + + Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 46 245 rmoveto + 256 70 -256 hlineto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-2-instance-400.ttx b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-2-instance-400.ttx new file mode 100644 index 000000000..69d746ff6 --- /dev/null +++ b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-2-instance-400.ttx @@ -0,0 +1,350 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + License same as MutatorMath. BSD 3-clause. [test-token: C] + + + Regular + + + 1.002;LTTR;MutatorMathTest-LightCondensed + + + MutatorMathTest LightCondensed + + + Version 1.002 + + + MutatorMathTest-LightCondensed + + + Width + + + width + + + weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 9 30 1 blend + hmoveto + 122 31 1 blend + hlineto + 119 738 312 0 2 blend + rlineto + -108 -23 1 blend + hlineto + -54 -590 -205 38 2 blend + rmoveto + 332 101 -332 379 0 -379 3 blend + hlineto + 266 -249 482 -37 2 blend + rmoveto + 155 54 1 blend + hlineto + -134 738 -318 0 2 blend + rlineto + -144 -54 1 blend + hlineto + -44 -122 17 2 2 blend + rmoveto + 131 122 -131 58 -2 -58 3 blend + hlineto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wdth + 0x0 + 0.0 + 0.0 + 1000.0 + 256 + + + + diff --git a/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-3-instance-400.ttx b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-3-instance-400.ttx new file mode 100644 index 000000000..138cb1cce --- /dev/null +++ b/Tests/varLib/instancer/data/test_results/CFF2Instancer-VF-3-instance-400.ttx @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + © 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. + + + Noto Sans SC + + + Regular + + + 2.004;ADBO;NotoSansSC-Thin;ADOBE + + + Noto Sans SC + + + Version 2.004;hotconv 1.0.118;makeotfexe 2.5.65603 + + + NotoSansSC-Thin + + + Regular + + + Regular + + + Weight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 63 284 rmoveto + -67 872 67 vlineto + -475 79 rmoveto + -444 76 444 vlineto + -111 -111 rmoveto + -83 -103 -161 -95 -145 -44 16 -15 23 -29 11 -19 145 52 163 105 90 119 rrcurveto + 86 25 rmoveto + -61 -28 rlineto + 91 -118 163 -103 152 -50 11 20 23 28 17 15 -152 41 -163 90 -82 104 rrcurveto + -498 508 rmoveto + 38 -49 40 -67 15 -43 rrcurveto + 62 35 -16 42 -42 64 -38 47 rlinecurve + 149 53 rmoveto + -492 71 492 vlineto + -306 -367 rmoveto + 32 -65 69 36 88 47 82 45 rlinecurve + -19 60 -93 -47 -95 -47 -63 -28 rlinecurve + 474 133 rmoveto + 50 -43 58 -62 26 -41 rrcurveto + 59 37 -28 41 -58 60 -49 41 rlinecurve + -17 116 rmoveto + -64 329 64 vlineto + -292 88 rmoveto + -35 -76 -83 -82 -88 -48 14 -12 20 -25 10 -15 95 54 89 92 48 92 rrcurveto + 203 -66 rmoveto + -12 vlineto + -69 -199 -189 -87 -235 -35 13 -15 20 -31 8 -18 243 45 200 98 84 231 rrcurveto + -45 25 -14 -3 rlineto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index 3dfbf448f..ca7ea93f0 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -63,6 +63,88 @@ def _get_coordinates(varfont, glyphname): ) +class InstantiateCFF2Test(object): + @pytest.mark.parametrize( + "location, expected", + [ + ( + {}, + [ + 44, + 256, + 6, + -29, + 2, + "blend", + "rmoveto", + 239, + 35, + -239, + 44, + 90, + -44, + 3, + "blend", + "hlineto", + ], + ), + ({"wght": 0}, [44, 256, "rmoveto", 239, 35, -239, "hlineto"]), + ({"wght": 0.5}, [47, 242, "rmoveto", 261, 80, -261, "hlineto"]), + ({"wght": 1}, [50, 227, "rmoveto", 283, 125, -283, "hlineto"]), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + + varfont = ttLib.TTFont() + varfont.importXML(os.path.join(TESTDATA, "CFF2Instancer-VF-1.ttx")) + + location = instancer.NormalizedAxisLimits(location) + + instancer.instantiateCFF2(varfont, location) + instancer.instantiateHVAR(varfont, location) + + program = varfont["CFF2"].cff.topDictIndex[0].CharStrings.values()[1].program + assert program == expected + + @pytest.mark.parametrize( + "source_ttx, expected_ttx", + [ + ("CFF2Instancer-VF-1.ttx", "CFF2Instancer-VF-1-instance-400.ttx"), + ("CFF2Instancer-VF-2.ttx", "CFF2Instancer-VF-2-instance-400.ttx"), + ("CFF2Instancer-VF-3.ttx", "CFF2Instancer-VF-3-instance-400.ttx"), + ], + ) + def test_full_instance(self, varfont, source_ttx, expected_ttx): + varfont = ttLib.TTFont() + varfont.importXML(os.path.join(TESTDATA, source_ttx)) + s = BytesIO() + varfont.save(s) + s.seek(0) + varfont = ttLib.TTFont(s) + + instance = instancer.instantiateVariableFont(varfont, {"wght": 400}) + s = BytesIO() + instance.save(s) + s.seek(0) + instance = ttLib.TTFont(s) + + s = StringIO() + instance.saveXML(s) + actual = stripVariableItemsFromTTX(s.getvalue()) + + expected = ttLib.TTFont() + expected.importXML(os.path.join(TESTDATA, "test_results", expected_ttx)) + s = BytesIO() + expected.save(s) + s.seek(0) + expected = ttLib.TTFont(s) + s = StringIO() + expected.saveXML(s) + expected = stripVariableItemsFromTTX(s.getvalue()) + + assert actual == expected + + class InstantiateGvarTest(object): @pytest.mark.parametrize("glyph_name", ["hyphen"]) @pytest.mark.parametrize(