from fontTools.varLib.models import VariationModel from fontTools.varLib.varStore import VarStoreInstancer from fontTools.misc.fixedTools import fixedToFloat as fi2fl from itertools import product import sys def _denormalize(v, axis): if v >= 0: return axis.defaultValue + v * (axis.maxValue - axis.defaultValue) else: return axis.defaultValue + v * (axis.defaultValue - axis.minValue) def _pruneLocations(locations, poles, axisTags): # Now we have all the input locations, find which ones are # not needed and remove them. # Note: This algorithm is heavily tied to how VariationModel # is implemented. It assumes that input was extracted from # VariationModel-generated object, like an ItemVariationStore # created by fontmake using varLib.models.VariationModel. # Some CoPilot blabbering: # I *think* I can prove that this algorithm is correct, but # I'm not 100% sure. It's possible that there are edge cases # where this algorithm will fail. I'm not sure how to prove # that it's correct, but I'm also not sure how to prove that # it's incorrect. I'm not sure how to write a test case that # would prove that it's incorrect. I'm not sure how to write # a test case that would prove that it's correct. model = VariationModel(locations, axisTags) modelMapping = model.mapping modelSupports = model.supports pins = {tuple(k.items()): None for k in poles} for location in poles: i = locations.index(location) i = modelMapping[i] support = modelSupports[i] supportAxes = set(support.keys()) for axisTag, (minV, _, maxV) in support.items(): for v in (minV, maxV): if v in (-1, 0, 1): continue for pin in pins.keys(): pinLocation = dict(pin) pinAxes = set(pinLocation.keys()) if pinAxes != supportAxes: continue if axisTag not in pinAxes: continue if pinLocation[axisTag] == v: break else: # No pin found. Go through the previous masters # and find a suitable pin. Going backwards is # better because it can find a pin that is close # to the pole in more dimensions, and reducing # the total number of pins needed. for candidateIdx in range(i - 1, -1, -1): candidate = modelSupports[candidateIdx] candidateAxes = set(candidate.keys()) if candidateAxes != supportAxes: continue if axisTag not in candidateAxes: continue candidate = { k: defaultV for k, (_, defaultV, _) in candidate.items() } if candidate[axisTag] == v: pins[tuple(candidate.items())] = None break else: assert False, "No pin found" return [dict(t) for t in pins.keys()] def mappings_from_avar(font, denormalize=True): fvarAxes = font["fvar"].axes axisMap = {a.axisTag: a for a in fvarAxes} axisTags = [a.axisTag for a in fvarAxes] axisIndexes = {a.axisTag: i for i, a in enumerate(fvarAxes)} if "avar" not in font: return {}, {} avar = font["avar"] axisMaps = { tag: seg for tag, seg in avar.segments.items() if seg and seg != {-1: -1, 0: 0, 1: 1} } mappings = [] if getattr(avar, "majorVersion", 1) == 2: varStore = avar.table.VarStore regions = varStore.VarRegionList.Region # Find all the input locations; this finds "poles", that are # locations of the peaks, and "corners", that are locations # of the corners of the regions. These two sets of locations # together constitute inputLocations to consider. poles = {(): None} # Just using it as an ordered set inputLocations = set({()}) for varData in varStore.VarData: regionIndices = varData.VarRegionIndex for regionIndex in regionIndices: peakLocation = [] corners = [] region = regions[regionIndex] for axisIndex, axis in enumerate(region.VarRegionAxis): if axis.PeakCoord == 0: continue axisTag = axisTags[axisIndex] peakLocation.append((axisTag, axis.PeakCoord)) corner = [] if axis.StartCoord != 0: corner.append((axisTag, axis.StartCoord)) if axis.EndCoord != 0: corner.append((axisTag, axis.EndCoord)) corners.append(corner) corners = set(product(*corners)) peakLocation = tuple(peakLocation) poles[peakLocation] = None inputLocations.add(peakLocation) inputLocations.update(corners) # Sort them by number of axes, then by axis order inputLocations = [ dict(t) for t in sorted( inputLocations, key=lambda t: (len(t), tuple(axisIndexes[tag] for tag, _ in t)), ) ] poles = [dict(t) for t in poles.keys()] inputLocations = _pruneLocations(inputLocations, list(poles), axisTags) # Find the output locations, at input locations varIdxMap = avar.table.VarIdxMap instancer = VarStoreInstancer(varStore, fvarAxes) for location in inputLocations: instancer.setLocation(location) outputLocation = {} for axisIndex, axisTag in enumerate(axisTags): varIdx = axisIndex if varIdxMap is not None: varIdx = varIdxMap[varIdx] delta = instancer[varIdx] if delta != 0: v = location.get(axisTag, 0) v = v + fi2fl(delta, 14) # See https://github.com/fonttools/fonttools/pull/3598#issuecomment-2266082009 # v = max(-1, min(1, v)) outputLocation[axisTag] = v mappings.append((location, outputLocation)) # Remove base master we added, if it maps to the default location assert mappings[0][0] == {} if mappings[0][1] == {}: mappings.pop(0) if denormalize: for tag, seg in axisMaps.items(): if tag not in axisMap: raise ValueError(f"Unknown axis tag {tag}") denorm = lambda v: _denormalize(v, axisMap[tag]) axisMaps[tag] = {denorm(k): denorm(v) for k, v in seg.items()} for i, (inputLoc, outputLoc) in enumerate(mappings): inputLoc = { tag: _denormalize(val, axisMap[tag]) for tag, val in inputLoc.items() } outputLoc = { tag: _denormalize(val, axisMap[tag]) for tag, val in outputLoc.items() } mappings[i] = (inputLoc, outputLoc) return axisMaps, mappings def unbuild(font, f=sys.stdout): fvar = font["fvar"] axes = fvar.axes segments, mappings = mappings_from_avar(font) if "name" in font: name = font["name"] axisNames = {axis.axisTag: name.getDebugName(axis.axisNameID) for axis in axes} else: axisNames = {a.axisTag: a.axisTag for a in axes} print("", file=f) print('', file=f) print(" ", file=f) for axis in axes: axisName = axisNames[axis.axisTag] triplet = (axis.minValue, axis.defaultValue, axis.maxValue) triplet = [int(v) if v == int(v) else v for v in triplet] axisMap = segments.get(axis.axisTag) closing = "/>" if axisMap is None else ">" print( f' ', file=f) print(" ", file=f) if mappings: print(" ", file=f) for inputLoc, outputLoc in mappings: print(" ", file=f) print(" ", file=f) for tag in sorted(inputLoc.keys()): v = inputLoc[tag] v = int(v) if v == int(v) else v print( f' ', file=f, ) print(" ", file=f) print(" ", file=f) for tag in sorted(outputLoc.keys()): v = outputLoc[tag] v = int(v) if v == int(v) else v print( f' ', file=f, ) print(" ", file=f) print(" ", file=f) print(" ", file=f) print(" ", file=f) print("", file=f) def main(args=None): """Print `avar` table as a designspace snippet.""" if args is None: args = sys.argv[1:] from fontTools.ttLib import TTFont import argparse parser = argparse.ArgumentParser( "fonttools varLib.avar.unbuild", description="Print `avar` table as a designspace snippet.", ) parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.") options = parser.parse_args(args) font = TTFont(options.font) if "fvar" not in font: print("Not a variable font.", file=sys.stderr) return 1 unbuild(font) if __name__ == "__main__": import sys sys.exit(main())