123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- 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())
|