from collections import namedtuple from fontTools.cffLib import ( maxStackLimit, TopDictIndex, buildOrder, topDictOperators, topDictOperators2, privateDictOperators, privateDictOperators2, FDArrayIndex, FontDict, VarStoreData ) from io import BytesIO from fontTools.cffLib.specializer import ( specializeCommands, commandsToProgram) from fontTools.ttLib import newTable from fontTools import varLib from fontTools.varLib.models import allEqual from fontTools.misc.psCharStrings import T2CharString, T2OutlineExtractor from fontTools.pens.t2CharStringPen import T2CharStringPen, t2c_round from .errors import VarLibCFFDictMergeError, VarLibCFFPointTypeMergeError, VarLibMergeError # Backwards compatibility MergeDictError = VarLibCFFDictMergeError MergeTypeError = VarLibCFFPointTypeMergeError def addCFFVarStore(varFont, varModel, varDataList, masterSupports): fvarTable = varFont['fvar'] axisKeys = [axis.axisTag for axis in fvarTable.axes] varTupleList = varLib.builder.buildVarRegionList(masterSupports, axisKeys) varStoreCFFV = varLib.builder.buildVarStore(varTupleList, varDataList) topDict = varFont['CFF2'].cff.topDictIndex[0] topDict.VarStore = VarStoreData(otVarStore=varStoreCFFV) if topDict.FDArray[0].vstore is None: fdArray = topDict.FDArray for fontDict in fdArray: if hasattr(fontDict, "Private"): 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 decrecated 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) return num pd_blend_fields = ("BlueValues", "OtherBlues", "FamilyBlues", "FamilyOtherBlues", "BlueScale", "BlueShift", "BlueFuzz", "StdHW", "StdVW", "StemSnapH", "StemSnapV") def get_private(regionFDArrays, fd_index, ri, fd_map): region_fdArray = regionFDArrays[ri] region_fd_map = fd_map[fd_index] if ri in region_fd_map: region_fdIndex = region_fd_map[ri] private = region_fdArray[region_fdIndex].Private else: private = None return private def merge_PrivateDicts(top_dicts, vsindex_dict, var_model, fd_map): """ I step through the FontDicts in the FDArray of the varfont TopDict. For each varfont FontDict: step through each key in FontDict.Private. For each key, step through each relevant source font Private dict, and build a list of values to blend. The 'relevant' source fonts are selected by first getting the right submodel using vsindex_dict[vsindex]. The indices of the subModel.locations are mapped to source font list indices by assuming the latter order is the same as the order of the var_model.locations. I can then get the index of each subModel location in the list of var_model.locations. """ topDict = top_dicts[0] region_top_dicts = top_dicts[1:] if hasattr(region_top_dicts[0], 'FDArray'): regionFDArrays = [fdTopDict.FDArray for fdTopDict in region_top_dicts] else: regionFDArrays = [[fdTopDict] for fdTopDict in region_top_dicts] for fd_index, font_dict in enumerate(topDict.FDArray): private_dict = font_dict.Private vsindex = getattr(private_dict, 'vsindex', 0) # At the moment, no PrivateDict has a vsindex key, but let's support # how it should work. See comment at end of # merge_charstrings() - still need to optimize use of vsindex. sub_model, _ = vsindex_dict[vsindex] master_indices = [] for loc in sub_model.locations[1:]: i = var_model.locations.index(loc) - 1 master_indices.append(i) pds = [private_dict] last_pd = private_dict for ri in master_indices: pd = get_private(regionFDArrays, fd_index, ri, fd_map) # If the region font doesn't have this FontDict, just reference # the last one used. if pd is None: pd = last_pd else: last_pd = pd pds.append(pd) num_masters = len(pds) for key, value in private_dict.rawDict.items(): dataList = [] if key not in pd_blend_fields: continue if isinstance(value, list): try: values = [pd.rawDict[key] for pd in pds] except KeyError: print( "Warning: {key} in default font Private dict is " "missing from another font, and was " "discarded.".format(key=key)) continue try: values = zip(*values) except IndexError: raise VarLibCFFDictMergeError(key, value, values) """ Row 0 contains the first value from each master. Convert each row from absolute values to relative values from the previous row. e.g for three masters, a list of values was: master 0 OtherBlues = [-217,-205] master 1 OtherBlues = [-234,-222] master 1 OtherBlues = [-188,-176] The call to zip() converts this to: [(-217, -234, -188), (-205, -222, -176)] and is converted finally to: OtherBlues = [[-217, 17.0, 46.0], [-205, 0.0, 0.0]] """ prev_val_list = [0] * num_masters any_points_differ = False for val_list in values: rel_list = [(val - prev_val_list[i]) for ( i, val) in enumerate(val_list)] if (not any_points_differ) and not allEqual(rel_list): any_points_differ = True prev_val_list = val_list deltas = sub_model.getDeltas(rel_list) # For PrivateDict BlueValues, the default font # values are absolute, not relative to the prior value. deltas[0] = val_list[0] dataList.append(deltas) # If there are no blend values,then # we can collapse the blend lists. if not any_points_differ: dataList = [data[0] for data in dataList] else: values = [pd.rawDict[key] for pd in pds] if not allEqual(values): dataList = sub_model.getDeltas(values) else: dataList = values[0] # Convert numbers with no decimal part to an int if isinstance(dataList, list): for i, item in enumerate(dataList): if isinstance(item, list): for j, jtem in enumerate(item): dataList[i][j] = conv_to_int(jtem) else: dataList[i] = conv_to_int(item) else: dataList = conv_to_int(dataList) private_dict.rawDict[key] = dataList def _cff_or_cff2(font): if "CFF " in font: return font["CFF "] return font["CFF2"] def getfd_map(varFont, fonts_list): """ Since a subset source font may have fewer FontDicts in their FDArray than the default font, we have to match up the FontDicts in the different fonts . We do this with the FDSelect array, and by assuming that the same glyph will reference matching FontDicts in each source font. We return a mapping from fdIndex in the default font to a dictionary which maps each master list index of each region font to the equivalent fdIndex in the region font.""" fd_map = {} default_font = fonts_list[0] region_fonts = fonts_list[1:] num_regions = len(region_fonts) topDict = _cff_or_cff2(default_font).cff.topDictIndex[0] if not hasattr(topDict, 'FDSelect'): # All glyphs reference only one FontDict. # Map the FD index for regions to index 0. fd_map[0] = {ri:0 for ri in range(num_regions)} return fd_map gname_mapping = {} default_fdSelect = topDict.FDSelect glyphOrder = default_font.getGlyphOrder() for gid, fdIndex in enumerate(default_fdSelect): gname_mapping[glyphOrder[gid]] = fdIndex if fdIndex not in fd_map: fd_map[fdIndex] = {} for ri, region_font in enumerate(region_fonts): region_glyphOrder = region_font.getGlyphOrder() region_topDict = _cff_or_cff2(region_font).cff.topDictIndex[0] if not hasattr(region_topDict, 'FDSelect'): # All the glyphs share the same FontDict. Pick any glyph. default_fdIndex = gname_mapping[region_glyphOrder[0]] fd_map[default_fdIndex][ri] = 0 else: region_fdSelect = region_topDict.FDSelect for gid, fdIndex in enumerate(region_fdSelect): default_fdIndex = gname_mapping[region_glyphOrder[gid]] region_map = fd_map[default_fdIndex] if ri not in region_map: region_map[ri] = fdIndex return fd_map CVarData = namedtuple('CVarData', 'varDataList masterSupports vsindex_dict') def merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder): topDict = varFont['CFF2'].cff.topDictIndex[0] top_dicts = [topDict] + [ _cff_or_cff2(ttFont).cff.topDictIndex[0] for ttFont in ordered_fonts_list[1:] ] num_masters = len(model.mapping) cvData = merge_charstrings(glyphOrder, num_masters, top_dicts, model) fd_map = getfd_map(varFont, ordered_fonts_list) merge_PrivateDicts(top_dicts, cvData.vsindex_dict, model, fd_map) addCFFVarStore(varFont, model, cvData.varDataList, cvData.masterSupports) def _get_cs(charstrings, glyphName): if glyphName not in charstrings: return None return charstrings[glyphName] def _add_new_vsindex(model, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList): varTupleIndexes = [] for support in model.supports[1:]: if support not in masterSupports: masterSupports.append(support) varTupleIndexes.append(masterSupports.index(support)) var_data = varLib.builder.buildVarData(varTupleIndexes, None, False) vsindex = len(vsindex_dict) vsindex_by_key[key] = vsindex vsindex_dict[vsindex] = (model, [key]) varDataList.append(var_data) return vsindex def merge_charstrings(glyphOrder, num_masters, top_dicts, masterModel): vsindex_dict = {} vsindex_by_key = {} varDataList = [] masterSupports = [] default_charstrings = top_dicts[0].CharStrings for gid, gname in enumerate(glyphOrder): all_cs = [ _get_cs(td.CharStrings, gname) for td in top_dicts] if len([gs for gs in all_cs if gs is not None]) == 1: continue model, model_cs = masterModel.getSubModel(all_cs) # create the first pass CFF2 charstring, from # the default charstring. default_charstring = model_cs[0] var_pen = CFF2CharStringMergePen([], gname, num_masters, 0) # We need to override outlineExtractor because these # charstrings do have widths in the 'program'; we need to drop these # values rather than post assertion error for them. default_charstring.outlineExtractor = MergeOutlineExtractor default_charstring.draw(var_pen) # Add the coordinates from all the other regions to the # blend lists in the CFF2 charstring. region_cs = model_cs[1:] for region_idx, region_charstring in enumerate(region_cs, start=1): var_pen.restart(region_idx) region_charstring.outlineExtractor = MergeOutlineExtractor region_charstring.draw(var_pen) # Collapse each coordinate list to a blend operator and its args. new_cs = var_pen.getCharString( private=default_charstring.private, globalSubrs=default_charstring.globalSubrs, var_model=model, optimize=True) default_charstrings[gname] = new_cs if (not var_pen.seen_moveto) or ('blend' not in new_cs.program): # If this is not a marking glyph, or if there are no blend # arguments, then we can use vsindex 0. No need to # check if we need a new vsindex. continue # If the charstring required a new model, create # a VarData table to go with, and set vsindex. key = tuple(v is not None for v in all_cs) try: vsindex = vsindex_by_key[key] except KeyError: vsindex = _add_new_vsindex(model, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList) # We do not need to check for an existing new_cs.private.vsindex, # as we know it doesn't exist yet. if vsindex != 0: new_cs.program[:0] = [vsindex, 'vsindex'] # If there is no variation in any of the charstrings, then vsindex_dict # never gets built. This could still be needed if there is variation # in the PrivatDict, so we will build the default data for vsindex = 0. if not vsindex_dict: key = (True,) * num_masters _add_new_vsindex(masterModel, key, masterSupports, vsindex_dict, vsindex_by_key, varDataList) cvData = CVarData(varDataList=varDataList, masterSupports=masterSupports, vsindex_dict=vsindex_dict) # XXX To do: optimize use of vsindex between the PrivateDicts and # charstrings return cvData def makeRoundNumberFunc(tolerance): if tolerance < 0: raise ValueError("Rounding tolerance must be positive") def roundNumber(val): return t2c_round(val, tolerance) return roundNumber class CFFToCFF2OutlineExtractor(T2OutlineExtractor): """ This class is used to remove the initial width from the CFF charstring without trying to add the width to self.nominalWidthX, which is None. """ def popallWidth(self, evenOdd=0): args = self.popall() if not self.gotWidth: if evenOdd ^ (len(args) % 2): args = args[1:] self.width = self.defaultWidthX self.gotWidth = 1 return args class MergeOutlineExtractor(CFFToCFF2OutlineExtractor): """ Used to extract the charstring commands - including hints - from a CFF charstring in order to merge it as another set of region data into a CFF2 variable font charstring.""" def __init__(self, pen, localSubrs, globalSubrs, nominalWidthX, defaultWidthX, private=None): super().__init__(pen, localSubrs, globalSubrs, nominalWidthX, defaultWidthX, private) def countHints(self): args = self.popallWidth() self.hintCount = self.hintCount + len(args) // 2 return args def _hint_op(self, type, args): self.pen.add_hint(type, args) def op_hstem(self, index): args = self.countHints() self._hint_op('hstem', args) def op_vstem(self, index): args = self.countHints() self._hint_op('vstem', args) def op_hstemhm(self, index): args = self.countHints() self._hint_op('hstemhm', args) def op_vstemhm(self, index): args = self.countHints() self._hint_op('vstemhm', args) def _get_hintmask(self, index): if not self.hintMaskBytes: args = self.countHints() if args: self._hint_op('vstemhm', args) self.hintMaskBytes = (self.hintCount + 7) // 8 hintMaskBytes, index = self.callingStack[-1].getBytes(index, self.hintMaskBytes) return index, hintMaskBytes def op_hintmask(self, index): index, hintMaskBytes = self._get_hintmask(index) self.pen.add_hintmask('hintmask', [hintMaskBytes]) return hintMaskBytes, index def op_cntrmask(self, index): index, hintMaskBytes = self._get_hintmask(index) self.pen.add_hintmask('cntrmask', [hintMaskBytes]) return hintMaskBytes, index class CFF2CharStringMergePen(T2CharStringPen): """Pen to merge Type 2 CharStrings. """ def __init__( self, default_commands, glyphName, num_masters, master_idx, roundTolerance=0.5): super().__init__( width=None, glyphSet=None, CFF2=True, roundTolerance=roundTolerance) self.pt_index = 0 self._commands = default_commands self.m_index = master_idx self.num_masters = num_masters self.prev_move_idx = 0 self.seen_moveto = False self.glyphName = glyphName self.roundNumber = makeRoundNumberFunc(roundTolerance) def add_point(self, point_type, pt_coords): if self.m_index == 0: self._commands.append([point_type, [pt_coords]]) else: cmd = self._commands[self.pt_index] if cmd[0] != point_type: raise VarLibCFFPointTypeMergeError( point_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName) cmd[1].append(pt_coords) self.pt_index += 1 def add_hint(self, hint_type, args): if self.m_index == 0: self._commands.append([hint_type, [args]]) else: cmd = self._commands[self.pt_index] if cmd[0] != hint_type: raise VarLibCFFPointTypeMergeError(hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName) cmd[1].append(args) self.pt_index += 1 def add_hintmask(self, hint_type, abs_args): # For hintmask, fonttools.cffLib.specializer.py expects # each of these to be represented by two sequential commands: # first holding only the operator name, with an empty arg list, # second with an empty string as the op name, and the mask arg list. if self.m_index == 0: self._commands.append([hint_type, []]) self._commands.append(["", [abs_args]]) else: cmd = self._commands[self.pt_index] if cmd[0] != hint_type: raise VarLibCFFPointTypeMergeError(hint_type, self.pt_index, len(cmd[1]), cmd[0], self.glyphName) self.pt_index += 1 cmd = self._commands[self.pt_index] cmd[1].append(abs_args) self.pt_index += 1 def _moveTo(self, pt): if not self.seen_moveto: self.seen_moveto = True pt_coords = self._p(pt) self.add_point('rmoveto', pt_coords) # I set prev_move_idx here because add_point() # can change self.pt_index. self.prev_move_idx = self.pt_index - 1 def _lineTo(self, pt): pt_coords = self._p(pt) self.add_point('rlineto', pt_coords) def _curveToOne(self, pt1, pt2, pt3): _p = self._p pt_coords = _p(pt1)+_p(pt2)+_p(pt3) self.add_point('rrcurveto', pt_coords) def _closePath(self): pass def _endPath(self): pass def restart(self, region_idx): self.pt_index = 0 self.m_index = region_idx self._p0 = (0, 0) def getCommands(self): return self._commands def reorder_blend_args(self, commands, get_delta_func, round_func): """ We first re-order the master coordinate values. For a moveto to lineto, the args are now arranged as: [ [master_0 x,y], [master_1 x,y], [master_2 x,y] ] We re-arrange this to [ [master_0 x, master_1 x, master_2 x], [master_0 y, master_1 y, master_2 y] ] If the master values are all the same, we collapse the list to as single value instead of a list. We then convert this to: [ [master_0 x] + [x delta tuple] + [numBlends=1] [master_0 y] + [y delta tuple] + [numBlends=1] ] """ for cmd in commands: # arg[i] is the set of arguments for this operator from master i. args = cmd[1] m_args = zip(*args) # m_args[n] is now all num_master args for the i'th argument # for this operation. cmd[1] = list(m_args) lastOp = None for cmd in commands: op = cmd[0] # masks are represented by two cmd's: first has only op names, # second has only args. if lastOp in ['hintmask', 'cntrmask']: coord = list(cmd[1]) if not allEqual(coord): raise VarLibMergeError("Hintmask values cannot differ between source fonts.") cmd[1] = [coord[0][0]] else: coords = cmd[1] new_coords = [] for coord in coords: if allEqual(coord): new_coords.append(coord[0]) else: # convert to deltas deltas = get_delta_func(coord)[1:] if round_func: deltas = [round_func(delta) for delta in deltas] coord = [coord[0]] + deltas new_coords.append(coord) cmd[1] = new_coords lastOp = op return commands def getCharString( self, private=None, globalSubrs=None, var_model=None, optimize=True): commands = self._commands commands = self.reorder_blend_args(commands, var_model.getDeltas, self.roundNumber) if optimize: commands = specializeCommands( commands, generalizeFirst=False, maxstack=maxStackLimit) program = commandsToProgram(commands) charString = T2CharString( program=program, private=private, globalSubrs=globalSubrs) return charString