From 39ec4e6c0c4155d42d4dac7218d881a0f36c10f8 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 May 2024 08:10:24 -0700 Subject: [PATCH] [cffLib] Add CFFToCFF2 and CFF2ToCFF cmdline and module The CFF2ToCFF module is rather solid, at least IMO. This takes convertCFFToCFF2 from cffLib. Apparently there's a more complete one in varLib.cff: https://github.com/fonttools/fonttools/issues/1835 Should merge the two and finish them. --- Lib/fontTools/cffLib/CFF2ToCFF.py | 132 ++++++++++++++++ Lib/fontTools/cffLib/CFFToCFF2.py | 166 +++++++++++++++++++++ Lib/fontTools/cffLib/__init__.py | 123 +-------------- Lib/fontTools/cffLib/width.py | 3 + Lib/fontTools/varLib/instancer/__init__.py | 6 +- 5 files changed, 309 insertions(+), 121 deletions(-) create mode 100644 Lib/fontTools/cffLib/CFF2ToCFF.py create mode 100644 Lib/fontTools/cffLib/CFFToCFF2.py diff --git a/Lib/fontTools/cffLib/CFF2ToCFF.py b/Lib/fontTools/cffLib/CFF2ToCFF.py new file mode 100644 index 000000000..744621fd9 --- /dev/null +++ b/Lib/fontTools/cffLib/CFF2ToCFF.py @@ -0,0 +1,132 @@ +"""CFF to CFF2 converter.""" + +from fontTools.ttLib import TTFont +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 + + +__all__ = ["convertCFF2ToCFF", "main"] + + +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`.)""" + + cff.major = 1 + topDict = cff.topDictIndex[0] + if hasattr(topDict, "VarStore"): + raise ValueError("Variable CFF2 font cannot be converted to CFF format.") + + 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, "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.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 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.", + ) + options = parser.parse_args(args) + + 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) + cff = font["CFF2"].cff + + cff.convertCFF2ToCFF(font) + + del font["CFF2"] + table = font["CFF "] = newTable("CFF ") + table.cff = cff + + +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..abdf7e7a1 --- /dev/null +++ b/Lib/fontTools/cffLib/CFFToCFF2.py @@ -0,0 +1,166 @@ +"""CFF to CFF2 converter.""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib import TTFont, newTable +from fontTools.misc.cliTools import makeOutputFileName +from fontTools.cffLib import ( + TopDictIndex, + FDArrayIndex, + FontDict, + buildOrder, + topDictOperators, + privateDictOperators, + topDictOperators2, + privateDictOperators2, +) +from io import BytesIO + +__all__ = ["convertCFFToCFF2", "main"] + + +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`.)""" + + 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 + 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) + + 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) + + 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 + + # TODO(behdad): What does the following comment even mean? Both CFF and CFF2 + # use the same T2Charstring class. + # What I see missing is dropping the endchar and return operators... + + # 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 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.", + ) + options = parser.parse_args(args) + + 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) + cff = font["CFF "].cff + del font["CFF "] + + cff.convertCFFToCFF2(font) + + table = font["CFF2"] = newTable("CFF2") + table.cff = cff + + +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 2750c16d9..89f4860ec 100644 --- a/Lib/fontTools/cffLib/__init__.py +++ b/Lib/fontTools/cffLib/__init__.py @@ -386,125 +386,14 @@ 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) - - 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) - - 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 - - # TODO(behdad): What does the following comment even mean? Both CFF and CFF2 - # use the same T2Charstring class. - # What I see missing is dropping the endchar and return operators... - - # 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) + convertCFFToCFF2(self, otFont) def convertCFF2ToCFF(self, otFont): - """Converts this object from CFF2 format to CFF format. This conversion - is done 'in-place'. The conversion cannot be reversed. + from .CFF2ToCFF import convertCFF2ToCFF - The CFF2 font cannot be variable. This method will remove the VarStore, - but will not process the CharStrings in any way (TODO instantiate to default). - - This assumes a decompiled CFF table. (i.e. that the object has been - filled via :meth:`decompile`.)""" - self.major = 1 - topDictData = TopDictIndex(None) - topDictData.items = self.topDictIndex.items - self.topDictIndex = topDictData - topDict = topDictData[0] - 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.program.append("endchar") - - # TODO Add "return" to subrs that don't end in endchar? - - # 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=False) - # file.seek(0) - # self.decompile(file, otFont, isCFF2=False) + convertCFF2ToCFF(self, otFont) def desubroutinize(self): for fontName in self.fontNames: @@ -801,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 @@ -819,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) @@ -997,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 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/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 8d4d001dc..2d61124de 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, getTableClass +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 @@ -601,7 +601,7 @@ def instantiateCFF2( varStore = topDict.VarStore.otVarStore if not varStore: if downgrade: - table = varfont["CFF "] = getTableClass("CFF ")() + table = varfont["CFF "] = newTable("CFF ") table.cff = cff cff.convertCFF2ToCFF(varfont) del varfont["CFF2"] @@ -821,7 +821,7 @@ def instantiateCFF2( del private.vstore if downgrade: - table = varfont["CFF "] = getTableClass("CFF ")() + table = varfont["CFF "] = newTable("CFF ") table.cff = cff cff.convertCFF2ToCFF(varfont) del varfont["CFF2"]