2023-09-15 07:57:04 -06:00
|
|
|
from fontTools.varLib import _add_avar, load_designspace
|
2024-08-02 17:12:09 -06:00
|
|
|
from fontTools.varLib.models import VariationModel
|
2024-08-02 14:10:52 -06:00
|
|
|
from fontTools.varLib.varStore import VarStoreInstancer
|
|
|
|
from fontTools.misc.fixedTools import fixedToFloat as fi2fl
|
2023-09-15 07:57:04 -06:00
|
|
|
from fontTools.misc.cliTools import makeOutputFileName
|
2024-08-02 14:10:52 -06:00
|
|
|
from itertools import product
|
2023-09-15 07:57:04 -06:00
|
|
|
import logging
|
|
|
|
|
|
|
|
log = logging.getLogger("fontTools.varLib.avar")
|
|
|
|
|
|
|
|
|
2024-08-02 14:10:52 -06:00
|
|
|
def _denormalize(v, axis):
|
2024-08-02 17:41:18 -06:00
|
|
|
if v >= 0:
|
|
|
|
return axis.defaultValue + v * (axis.maxValue - axis.defaultValue)
|
|
|
|
else:
|
|
|
|
return axis.defaultValue + v * (axis.defaultValue - axis.minValue)
|
2024-08-02 14:10:52 -06:00
|
|
|
|
|
|
|
|
2024-08-02 18:38:37 -06:00
|
|
|
def _pruneLocations(locations, poles, axisTags):
|
|
|
|
# Now we have all the input locations, find which ones are
|
|
|
|
# not needed and remove them.
|
2024-08-02 20:00:21 -06:00
|
|
|
|
|
|
|
# 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.
|
|
|
|
|
2024-08-02 18:38:37 -06:00
|
|
|
model = VariationModel(locations, axisTags)
|
|
|
|
modelMapping = model.mapping
|
|
|
|
modelSupports = model.supports
|
2024-08-02 19:32:45 -06:00
|
|
|
pins = {tuple(k.items()): None for k in poles}
|
|
|
|
for location in poles:
|
2024-08-02 18:38:37 -06:00
|
|
|
i = locations.index(location)
|
|
|
|
i = modelMapping[i]
|
|
|
|
support = modelSupports[i]
|
|
|
|
supportAxes = set(support.keys())
|
2024-08-02 19:32:45 -06:00
|
|
|
for axisTag, (minV, _, maxV) in support.items():
|
2024-08-02 18:38:37 -06:00
|
|
|
for v in (minV, maxV):
|
2024-08-02 19:32:45 -06:00
|
|
|
if v in (-1, 0, 1):
|
|
|
|
continue
|
2024-08-02 18:38:37 -06:00
|
|
|
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
|
2024-08-02 19:32:45 -06:00
|
|
|
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"
|
2024-08-02 18:38:37 -06:00
|
|
|
return [dict(t) for t in pins.keys()]
|
|
|
|
|
|
|
|
|
2024-08-02 14:10:52 -06:00
|
|
|
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
|
2024-08-02 18:24:18 -06:00
|
|
|
|
|
|
|
# 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
|
2024-08-02 17:55:56 -06:00
|
|
|
inputLocations = set({()})
|
2024-08-02 14:10:52 -06:00
|
|
|
for varData in varStore.VarData:
|
|
|
|
regionIndices = varData.VarRegionIndex
|
|
|
|
for regionIndex in regionIndices:
|
2024-08-02 16:44:09 -06:00
|
|
|
peakLocation = []
|
2024-08-02 14:10:52 -06:00
|
|
|
corners = []
|
|
|
|
region = regions[regionIndex]
|
|
|
|
for axisIndex, axis in enumerate(region.VarRegionAxis):
|
|
|
|
if axis.PeakCoord == 0:
|
|
|
|
continue
|
|
|
|
axisTag = axisTags[axisIndex]
|
2024-08-02 16:44:09 -06:00
|
|
|
peakLocation.append((axisTag, axis.PeakCoord))
|
2024-08-02 14:10:52 -06:00
|
|
|
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))
|
2024-08-02 17:12:09 -06:00
|
|
|
peakLocation = tuple(peakLocation)
|
2024-08-02 17:41:18 -06:00
|
|
|
poles[peakLocation] = None
|
2024-08-02 17:12:09 -06:00
|
|
|
inputLocations.add(peakLocation)
|
2024-08-02 14:10:52 -06:00
|
|
|
inputLocations.update(corners)
|
|
|
|
|
2024-08-02 18:24:18 -06:00
|
|
|
# Sort them by number of axes, then by axis order
|
2024-08-02 14:10:52 -06:00
|
|
|
inputLocations = [
|
|
|
|
dict(t)
|
|
|
|
for t in sorted(
|
|
|
|
inputLocations,
|
|
|
|
key=lambda t: (len(t), tuple(axisIndexes[tag] for tag, _ in t)),
|
|
|
|
)
|
|
|
|
]
|
2024-08-02 19:32:45 -06:00
|
|
|
poles = [dict(t) for t in poles.keys()]
|
|
|
|
inputLocations = _pruneLocations(inputLocations, list(poles), axisTags)
|
2024-08-02 17:57:38 -06:00
|
|
|
|
2024-08-02 18:24:18 -06:00
|
|
|
# Find the output locations, at input locations
|
2024-08-02 17:57:38 -06:00
|
|
|
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))
|
2024-08-02 17:12:09 -06:00
|
|
|
|
2024-08-02 18:24:18 -06:00
|
|
|
# Remove base master we added, if it maps to the default location
|
|
|
|
assert mappings[0][0] == {}
|
|
|
|
if mappings[0][1] == {}:
|
|
|
|
mappings.pop(0)
|
2024-08-02 18:16:11 -06:00
|
|
|
|
2024-08-02 14:10:52 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2023-09-15 07:57:04 -06:00
|
|
|
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(
|
2024-08-02 14:10:52 -06:00
|
|
|
"designspace",
|
|
|
|
metavar="family.designspace",
|
|
|
|
help="Designspace file.",
|
|
|
|
nargs="?",
|
|
|
|
default=None,
|
2023-09-15 07:57:04 -06:00
|
|
|
)
|
|
|
|
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
|
|
|
|
|
2024-08-02 14:10:52 -06:00
|
|
|
if options.designspace is None:
|
|
|
|
from pprint import pprint
|
|
|
|
|
2024-08-02 17:12:09 -06:00
|
|
|
segments, mappings = mappings_from_avar(font)
|
|
|
|
pprint(segments)
|
|
|
|
pprint(mappings)
|
2024-08-02 19:32:45 -06:00
|
|
|
print(len(mappings), "mappings")
|
2024-08-02 14:10:52 -06:00
|
|
|
return
|
|
|
|
|
2023-09-15 07:57:04 -06:00
|
|
|
axisTags = [a.axisTag for a in font["fvar"].axes]
|
|
|
|
|
2024-08-02 14:39:41 -06:00
|
|
|
ds = load_designspace(options.designspace, require_sources=False)
|
2023-09-15 07:57:04 -06:00
|
|
|
|
|
|
|
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())
|