fonttools/Lib/fontTools/varLib/avarPlanner.py

557 lines
15 KiB
Python
Raw Normal View History

from fontTools.ttLib import newTable
2023-07-22 21:18:52 -06:00
from fontTools.pens.areaPen import AreaPen
2023-07-23 12:09:02 -06:00
from fontTools.varLib.models import piecewiseLinearMap, normalizeValue
2023-07-22 21:18:52 -06:00
from fontTools.misc.cliTools import makeOutputFileName
import math
import logging
from pprint import pformat
__all__ = [
"planWeightAxis",
"planWidthAxis",
"planAxis",
"measureBlackness",
"measureWidth",
"makeDesignspaceSnippet",
"addEmptyAvar",
"main",
]
log = logging.getLogger("fontTools.varLib.avarPlanner")
2023-07-22 21:18:52 -06:00
WEIGHTS = [
50,
100,
150,
200,
250,
300,
350,
400,
450,
500,
550,
600,
650,
700,
750,
800,
850,
900,
950,
]
2023-07-22 21:18:52 -06:00
WIDTHS = [
50.0,
62.5,
75.0,
87.5,
100.0,
112.5,
125.0,
137.5,
150.0,
162.5,
175.0,
187.5,
200.0,
]
2023-07-22 21:18:52 -06:00
SAMPLES = 8
2023-07-23 10:23:39 -06:00
def measureBlackness(glyphset, glyphs=None):
2023-07-23 10:38:33 -06:00
if isinstance(glyphs, dict):
frequencies = glyphs
else:
frequencies = {g: 1 for g in glyphs}
2023-07-22 21:18:52 -06:00
wght_sum = wdth_sum = 0
2023-07-23 10:38:33 -06:00
for glyph_name in glyphs:
if frequencies is not None:
frequency = frequencies.get(glyph_name, 0)
if frequency == 0:
continue
else:
frequency = 1
2023-07-22 21:18:52 -06:00
glyph = glyphset[glyph_name]
pen = AreaPen(glyphset=glyphset)
glyph.draw(pen)
wght_sum += abs(pen.value) * glyph.width * frequency
wdth_sum += glyph.width * frequency
2023-07-22 21:18:52 -06:00
return wght_sum / wdth_sum
def measureWidth(glyphset, glyphs=None):
if isinstance(glyphs, dict):
frequencies = glyphs
else:
frequencies = {g: 1 for g in glyphs}
wdth_sum = 0
freq_sum = 0
for glyph_name in glyphs:
if frequencies is not None:
frequency = frequencies.get(glyph_name, 0)
if frequency == 0:
continue
else:
frequency = 1
glyph = glyphset[glyph_name]
pen = AreaPen(glyphset=glyphset)
glyph.draw(pen)
wdth_sum += glyph.width * frequency
freq_sum += frequency
return wdth_sum / freq_sum
def planAxis(
axisTag,
measureFunc,
glyphSetFunc,
minValue,
defaultValue,
maxValue,
values=None,
samples=None,
glyphs=None,
designUnits=None,
2023-07-23 12:09:02 -06:00
pins=None,
):
2023-07-23 10:27:19 -06:00
if samples is None:
samples = SAMPLES
2023-07-23 10:38:33 -06:00
if glyphs is None:
glyphs = glyphSetFunc({}).keys()
2023-07-23 12:09:02 -06:00
if pins is None:
pins = {}
else:
pins = pins.copy()
2023-07-23 10:27:19 -06:00
log.info("Value min %g / default %g / max %g", minValue, defaultValue, maxValue)
triple = (minValue, defaultValue, maxValue)
if designUnits is not None:
log.info("Value design-units min %g / default %g / max %g", *designUnits)
else:
designUnits = triple
2023-07-22 21:18:52 -06:00
# if "avar" in font:
# log.debug("Checking that font doesn't have axis mapping already.")
# existingMapping = font["avar"].segments[axisTag]
# if existingMapping and existingMapping != {-1: -1, 0: 0, +1: +1}:
# log.error("Font already has a `avar` value mapping. Remove it.")
2023-07-23 09:45:58 -06:00
2023-07-23 12:09:02 -06:00
if pins:
log.info("Pins %s", sorted(pins.items()))
pins.update(
{
minValue: designUnits[0],
defaultValue: designUnits[1],
maxValue: designUnits[2],
}
)
2023-07-23 12:09:02 -06:00
2023-07-22 21:18:52 -06:00
out = {}
outNormalized = {}
upem = 1 # font["head"].unitsPerEm
axisBlackness = {}
for value in sorted({minValue, defaultValue, maxValue} | set(pins.values())):
glyphset = glyphSetFunc(location={axisTag: value})
designValue = piecewiseLinearMap(value, pins)
axisBlackness[designValue] = measureFunc(glyphset, glyphs) / (upem * upem)
2023-07-22 21:18:52 -06:00
log.debug("Calculated average value:\n%s", pformat(axisBlackness))
2023-07-22 21:18:52 -06:00
2023-07-23 12:09:02 -06:00
for (rangeMin, targetMin), (rangeMax, targetMax) in zip(
list(sorted(pins.items()))[:-1],
list(sorted(pins.items()))[1:],
):
targetValues = {w for w in values if rangeMin < w < rangeMax}
if not targetValues:
2023-07-22 21:18:52 -06:00
continue
2023-07-23 12:09:02 -06:00
normalizedMin = normalizeValue(rangeMin, triple)
normalizedMax = normalizeValue(rangeMax, triple)
normalizedTargetMin = normalizeValue(targetMin, designUnits)
normalizedTargetMax = normalizeValue(targetMax, designUnits)
2023-07-22 21:18:52 -06:00
log.info("Planning target values %s.", sorted(targetValues))
2023-07-23 10:27:19 -06:00
log.info("Sampling %u points in range %g,%g.", samples, rangeMin, rangeMax)
valueBlackness = axisBlackness.copy()
2023-07-23 10:27:19 -06:00
for sample in range(1, samples + 1):
value = rangeMin + (rangeMax - rangeMin) * sample / (samples + 1)
log.info("Sampling value %g.", value)
glyphset = glyphSetFunc(location={axisTag: value})
designValue = piecewiseLinearMap(value, pins)
valueBlackness[designValue] = measureFunc(glyphset, glyphs) / (upem * upem)
log.debug("Sampled average value:\n%s", pformat(valueBlackness))
blacknessValue = {}
for value in sorted(valueBlackness):
blacknessValue[valueBlackness[value]] = value
logMin = math.log(valueBlackness[targetMin])
logMax = math.log(valueBlackness[targetMax])
2023-07-23 12:09:02 -06:00
out[rangeMin] = targetMin
outNormalized[normalizedMin] = normalizedTargetMin
for value in sorted(targetValues):
t = (value - rangeMin) / (rangeMax - rangeMin)
targetBlackness = math.exp(logMin + t * (logMax - logMin))
targetValue = piecewiseLinearMap(targetBlackness, blacknessValue)
log.info("Planned mapping value %g to %g." % (value, targetValue))
out[value] = targetValue
2023-07-23 12:09:02 -06:00
outNormalized[
normalizedMin + t * (normalizedMax - normalizedMin)
] = normalizedTargetMin + (targetValue - targetMin) / (
2023-07-23 12:09:02 -06:00
targetMax - targetMin
) * (
normalizedTargetMax - normalizedTargetMin
)
out[rangeMax] = targetMax
outNormalized[normalizedMax] = normalizedTargetMax
2023-07-22 21:18:52 -06:00
log.info("Planned mapping for the `%s` axis:\n%s", axisTag, pformat(out))
log.info(
"Planned normalized mapping for the `%s` axis:\n%s",
axisTag,
pformat(outNormalized),
)
if all(abs(k - v) < 0.02 for k, v in outNormalized.items()):
log.info("Detected identity mapping for the `%s` axis. Dropping.", axisTag)
out = {}
outNormalized = {}
2023-07-22 21:18:52 -06:00
return out, outNormalized
def planWeightAxis(
glyphSetFunc,
minValue,
defaultValue,
maxValue,
weights=None,
samples=None,
glyphs=None,
designUnits=None,
pins=None,
):
if weights is None:
weights = WEIGHTS
return planAxis(
"wght",
measureBlackness,
glyphSetFunc,
minValue,
defaultValue,
maxValue,
values=weights,
samples=samples,
glyphs=glyphs,
designUnits=designUnits,
pins=pins,
)
def planWidthAxis(
glyphSetFunc,
minValue,
defaultValue,
maxValue,
widths=None,
samples=None,
glyphs=None,
designUnits=None,
pins=None,
):
if widths is None:
widths = WIDTHS
return planAxis(
"wdth",
measureWidth,
glyphSetFunc,
minValue,
defaultValue,
maxValue,
values=widths,
samples=samples,
glyphs=glyphs,
designUnits=designUnits,
pins=pins,
)
def makeDesignspaceSnippet(axisTag, axisName, axisLimit, mapping):
designspaceSnippet = (
' <axis tag="%s" name="%s" minimum="%g" default="%g" maximum="%g"'
% ((axisTag, axisName) + axisLimit)
)
if mapping:
designspaceSnippet += ">\n"
else:
designspaceSnippet += "/>"
for key, value in mapping.items():
designspaceSnippet += ' <map input="%g" output="%g"/>\n' % (key, value)
if mapping:
designspaceSnippet += " </axis>"
return designspaceSnippet
2023-07-23 10:19:35 -06:00
def addEmptyAvar(font):
font["avar"] = newTable("avar")
for axis in fvar.axes:
font["avar"].segments[axis.axisTag] = {}
2023-07-22 21:18:52 -06:00
def main(args=None):
from fontTools import configLogger
2023-07-22 21:18:52 -06:00
if args is None:
import sys
args = sys.argv[1:]
from fontTools.ttLib import TTFont
import argparse
parser = argparse.ArgumentParser(
"fonttools varLib.avarPlanner",
description="Plan `avar` table for variable font",
)
parser.add_argument("font", metavar="font.ttf", help="Font file.")
parser.add_argument(
"-o",
"--output-file",
type=str,
help="Output font file name.",
)
parser.add_argument(
"--weights", type=str, help="Space-separate list of weights to generate."
)
parser.add_argument(
"--widths", type=str, help="Space-separate list of widths to generate."
)
2023-07-23 10:27:19 -06:00
parser.add_argument("-s", "--samples", type=int, help="Number of samples.")
parser.add_argument(
"-g",
"--glyphs",
type=str,
help="Space-separate list of glyphs to use for sampling.",
)
parser.add_argument(
2023-07-23 15:47:03 -06:00
"--weight-design-units",
type=str,
2023-07-23 15:47:03 -06:00
help="min:default:max in design units for the `wght` axis.",
)
2023-07-23 12:09:02 -06:00
parser.add_argument(
2023-07-23 15:47:03 -06:00
"--width-design-units",
2023-07-23 12:09:02 -06:00
type=str,
2023-07-23 15:47:03 -06:00
help="min:default:max in design units for the `wdth` axis.",
)
parser.add_argument(
"--weight-pins",
type=str,
help="Space-separate list of before:after pins. for the `wght` axis.",
2023-07-23 12:09:02 -06:00
)
parser.add_argument(
"--width-pins",
type=str,
help="Space-separate list of before:after pins. for the `wdth` axis.",
)
2023-07-23 09:41:29 -06:00
parser.add_argument(
"-p", "--plot", action="store_true", help="Plot the resulting mapping."
)
2023-07-22 21:18:52 -06:00
logging_group = parser.add_mutually_exclusive_group(required=False)
logging_group.add_argument(
"-v", "--verbose", action="store_true", help="Run more verbosely."
)
logging_group.add_argument(
"-q", "--quiet", action="store_true", help="Turn verbosity off."
)
2023-07-22 21:18:52 -06:00
options = parser.parse_args(args)
configLogger(
level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
)
2023-07-22 21:18:52 -06:00
font = TTFont(options.font)
if not "fvar" in font:
log.error("Not a variable font.")
sys.exit(1)
2023-07-22 21:18:52 -06:00
fvar = font["fvar"]
2023-07-23 15:47:03 -06:00
wghtAxis = wdthAxis = None
2023-07-22 21:18:52 -06:00
for axis in fvar.axes:
if axis.axisTag == "wght":
wghtAxis = axis
2023-07-23 15:47:03 -06:00
elif axis.axisTag == "wdth":
wdthAxis = axis
2023-07-22 21:18:52 -06:00
if "avar" in font:
existingMapping = font["avar"].segments["wght"]
if wghtAxis:
font["avar"].segments["wght"] = {}
else:
existingMapping = None
if options.glyphs is not None:
glyphs = options.glyphs.split()
if ":" in options.glyphs:
glyphs = {}
for g in options.glyphs.split():
if ":" in g:
glyph, frequency = g.split(":")
glyphs[glyph] = float(frequency)
else:
glyphs[g] = 1.0
else:
glyphs = None
if wdthAxis:
log.info("Planning width axis.")
if options.widths is not None:
widths = [float(w) for w in options.widths.split()]
else:
widths = None
if options.width_design_units is not None:
designUnits = [float(d) for d in options.width_design_units.split(":")]
else:
designUnits = None
if options.width_pins is not None:
pins = {}
for pin in options.width_pins.split():
before, after = pin.split(":")
pins[float(before)] = float(after)
else:
pins = None
widthMapping, widthMappingNormalized = planWidthAxis(
font.getGlyphSet,
wdthAxis.minValue,
wdthAxis.defaultValue,
wdthAxis.maxValue,
widths=widths,
samples=options.samples,
glyphs=glyphs,
designUnits=designUnits,
pins=pins,
)
if options.plot:
from matplotlib import pyplot
pyplot.plot(
sorted(widthMappingNormalized),
[widthMappingNormalized[k] for k in sorted(widthMappingNormalized)],
)
pyplot.show()
if existingMapping is not None:
log.info("Existing width mapping:\n%s", pformat(existingMapping))
if wghtAxis:
log.info("Planning weight axis.")
2023-07-23 10:29:59 -06:00
if options.weights is not None:
weights = [float(w) for w in options.weights.split()]
2023-07-23 10:29:59 -06:00
else:
weights = None
2023-07-23 10:38:33 -06:00
2023-07-23 15:47:03 -06:00
if options.weight_design_units is not None:
designUnits = [float(d) for d in options.weight_design_units.split(":")]
else:
designUnits = None
2023-07-23 15:47:03 -06:00
if options.weight_pins is not None:
2023-07-23 12:09:02 -06:00
pins = {}
2023-07-23 15:47:03 -06:00
for pin in options.weight_pins.split():
2023-07-23 12:09:02 -06:00
before, after = pin.split(":")
pins[float(before)] = float(after)
else:
pins = None
weightMapping, weightMappingNormalized = planWeightAxis(
font.getGlyphSet,
2023-07-23 10:27:19 -06:00
wghtAxis.minValue,
wghtAxis.defaultValue,
wghtAxis.maxValue,
2023-07-23 10:29:59 -06:00
weights=weights,
2023-07-23 10:27:19 -06:00
samples=options.samples,
2023-07-23 10:38:33 -06:00
glyphs=glyphs,
designUnits=designUnits,
2023-07-23 12:09:02 -06:00
pins=pins,
)
if options.plot:
from matplotlib import pyplot
2023-07-23 09:41:29 -06:00
pyplot.plot(
sorted(weightMappingNormalized),
[weightMappingNormalized[k] for k in sorted(weightMappingNormalized)],
)
pyplot.show()
2023-07-23 09:41:29 -06:00
if existingMapping is not None:
log.info("Existing weight mapping:\n%s", pformat(existingMapping))
if "avar" not in font:
2023-07-23 10:19:35 -06:00
addEmptyAvar(font)
2023-07-22 21:18:52 -06:00
avar = font["avar"]
if wdthAxis:
avar.segments["wdth"] = widthMappingNormalized
designspaceSnippet = makeDesignspaceSnippet(
"wdth",
"Width",
(wdthAxis.minValue, wdthAxis.defaultValue, wdthAxis.maxValue),
widthMapping,
)
log.info("Width axis designspace snippet:")
print(designspaceSnippet)
if wghtAxis:
avar.segments["wght"] = weightMappingNormalized
designspaceSnippet = makeDesignspaceSnippet(
"wght",
"Weight",
(wghtAxis.minValue, wghtAxis.defaultValue, wghtAxis.maxValue),
weightMapping,
)
log.info("Weight axis designspace snippet:")
print(designspaceSnippet)
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)
2023-07-22 21:18:52 -06:00
if __name__ == "__main__":
import sys
sys.exit(main())