Spaces:
Running
Running
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.loggingTools import deprecateFunction | |
from fontTools.misc.roundTools import roundFunc | |
from fontTools.misc.psCharStrings import T2CharString, T2OutlineExtractor | |
from fontTools.pens.t2CharStringPen import T2CharStringPen | |
from functools import partial | |
from .errors import ( | |
VarLibCFFDictMergeError, | |
VarLibCFFPointTypeMergeError, | |
VarLibCFFHintTypeMergeError, | |
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 convertCFFtoCFF2(varFont): | |
from fontTools.cffLib.CFFToCFF2 import convertCFFToCFF2 | |
return convertCFFToCFF2(varFont) | |
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, filterEmpty=False): | |
if glyphName not in charstrings: | |
return None | |
cs = charstrings[glyphName] | |
if filterEmpty: | |
cs.decompile() | |
if cs.program == []: # CFF2 empty charstring | |
return None | |
elif ( | |
len(cs.program) <= 2 | |
and cs.program[-1] == "endchar" | |
and (len(cs.program) == 1 or type(cs.program[0]) in (int, float)) | |
): # CFF1 empty charstring | |
return None | |
return cs | |
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): | |
# interpret empty non-default masters as missing glyphs from a sparse master | |
all_cs = [ | |
_get_cs(td.CharStrings, gname, i != 0) for i, td in enumerate(top_dicts) | |
] | |
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 region_cs: | |
continue | |
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 | |
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, | |
blender=None, | |
): | |
super().__init__( | |
pen, localSubrs, globalSubrs, nominalWidthX, defaultWidthX, private, blender | |
) | |
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.01 | |
): | |
# For roundTolerance see https://github.com/fonttools/fonttools/issues/2838 | |
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.round = roundFunc(roundTolerance, round=round) | |
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 VarLibCFFHintTypeMergeError( | |
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 VarLibCFFHintTypeMergeError( | |
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): | |
""" | |
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:] | |
coord = [coord[0]] + deltas | |
coord.append(1) | |
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, partial(var_model.getDeltas, round=self.round) | |
) | |
if optimize: | |
commands = specializeCommands( | |
commands, generalizeFirst=False, maxstack=maxStackLimit | |
) | |
program = commandsToProgram(commands) | |
charString = T2CharString( | |
program=program, private=private, globalSubrs=globalSubrs | |
) | |
return charString | |