from fontTools.varLib import _add_avar, load_designspace from fontTools.varLib.models import VariationModel from fontTools.varLib.varStore import VarStoreInstancer from fontTools.misc.fixedTools import fixedToFloat as fi2fl from fontTools.misc.cliTools import makeOutputFileName from itertools import product import logging log = logging.getLogger("fontTools.varLib.avar") 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 main(args=None): """Add `avar` table from designspace file to variable font.""" if args is None: import sys args = sys.argv[1:] from fontTools import configLogger from fontTools.ttLib import TTFont from fontTools.designspaceLib import DesignSpaceDocument import argparse parser = argparse.ArgumentParser( "fonttools varLib.avar", description="Add `avar` table from designspace file to variable font.", ) parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.") parser.add_argument( "designspace", metavar="family.designspace", help="Designspace file.", nargs="?", default=None, ) parser.add_argument( "-o", "--output-file", type=str, help="Output font file name.", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." ) options = parser.parse_args(args) configLogger(level=("INFO" if options.verbose else "WARNING")) font = TTFont(options.font) if not "fvar" in font: log.error("Not a variable font.") return 1 if options.designspace is None: from pprint import pprint segments, mappings = mappings_from_avar(font) pprint(segments) pprint(mappings) print(len(mappings), "mappings") return axisTags = [a.axisTag for a in font["fvar"].axes] ds = load_designspace(options.designspace, require_sources=False) if "avar" in font: log.warning("avar table already present, overwriting.") del font["avar"] _add_avar(font, ds.axes, ds.axisMappings, axisTags) if options.output_file is None: outfile = makeOutputFileName(options.font, overWrite=True, suffix=".avar") else: outfile = options.output_file if outfile: log.info("Saving %s", outfile) font.save(outfile) if __name__ == "__main__": import sys sys.exit(main())