aboutsummaryrefslogtreecommitdiff
path: root/Lib/fontTools/varLib/avarPlanner.py
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/fontTools/varLib/avarPlanner.py')
-rw-r--r--Lib/fontTools/varLib/avarPlanner.py1004
1 files changed, 1004 insertions, 0 deletions
diff --git a/Lib/fontTools/varLib/avarPlanner.py b/Lib/fontTools/varLib/avarPlanner.py
new file mode 100644
index 00000000..2e173443
--- /dev/null
+++ b/Lib/fontTools/varLib/avarPlanner.py
@@ -0,0 +1,1004 @@
+from fontTools.ttLib import newTable
+from fontTools.ttLib.tables._f_v_a_r import Axis as fvarAxis
+from fontTools.pens.areaPen import AreaPen
+from fontTools.pens.basePen import NullPen
+from fontTools.pens.statisticsPen import StatisticsPen
+from fontTools.varLib.models import piecewiseLinearMap, normalizeValue
+from fontTools.misc.cliTools import makeOutputFileName
+import math
+import logging
+from pprint import pformat
+
+__all__ = [
+ "planWeightAxis",
+ "planWidthAxis",
+ "planSlantAxis",
+ "planOpticalSizeAxis",
+ "planAxis",
+ "sanitizeWeight",
+ "sanitizeWidth",
+ "sanitizeSlant",
+ "measureWeight",
+ "measureWidth",
+ "measureSlant",
+ "normalizeLinear",
+ "normalizeLog",
+ "normalizeDegrees",
+ "interpolateLinear",
+ "interpolateLog",
+ "processAxis",
+ "makeDesignspaceSnippet",
+ "addEmptyAvar",
+ "main",
+]
+
+log = logging.getLogger("fontTools.varLib.avarPlanner")
+
+WEIGHTS = [
+ 50,
+ 100,
+ 150,
+ 200,
+ 250,
+ 300,
+ 350,
+ 400,
+ 450,
+ 500,
+ 550,
+ 600,
+ 650,
+ 700,
+ 750,
+ 800,
+ 850,
+ 900,
+ 950,
+]
+
+WIDTHS = [
+ 25.0,
+ 37.5,
+ 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,
+]
+
+SLANTS = list(math.degrees(math.atan(d / 20.0)) for d in range(-20, 21))
+
+SIZES = [
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 14,
+ 18,
+ 24,
+ 30,
+ 36,
+ 48,
+ 60,
+ 72,
+ 96,
+ 120,
+ 144,
+ 192,
+ 240,
+ 288,
+]
+
+
+SAMPLES = 8
+
+
+def normalizeLinear(value, rangeMin, rangeMax):
+ """Linearly normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
+ return (value - rangeMin) / (rangeMax - rangeMin)
+
+
+def interpolateLinear(t, a, b):
+ """Linear interpolation between a and b, with t typically in [0, 1]."""
+ return a + t * (b - a)
+
+
+def normalizeLog(value, rangeMin, rangeMax):
+ """Logarithmically normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
+ logMin = math.log(rangeMin)
+ logMax = math.log(rangeMax)
+ return (math.log(value) - logMin) / (logMax - logMin)
+
+
+def interpolateLog(t, a, b):
+ """Logarithmic interpolation between a and b, with t typically in [0, 1]."""
+ logA = math.log(a)
+ logB = math.log(b)
+ return math.exp(logA + t * (logB - logA))
+
+
+def normalizeDegrees(value, rangeMin, rangeMax):
+ """Angularly normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
+ tanMin = math.tan(math.radians(rangeMin))
+ tanMax = math.tan(math.radians(rangeMax))
+ return (math.tan(math.radians(value)) - tanMin) / (tanMax - tanMin)
+
+
+def measureWeight(glyphset, glyphs=None):
+ """Measure the perceptual average weight of the given glyphs."""
+ if isinstance(glyphs, dict):
+ frequencies = glyphs
+ else:
+ frequencies = {g: 1 for g in glyphs}
+
+ wght_sum = wdth_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)
+
+ mult = glyph.width * frequency
+ wght_sum += mult * abs(pen.value)
+ wdth_sum += mult
+
+ return wght_sum / wdth_sum
+
+
+def measureWidth(glyphset, glyphs=None):
+ """Measure the average width of the given glyphs."""
+ 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 = NullPen()
+ glyph.draw(pen)
+
+ wdth_sum += glyph.width * frequency
+ freq_sum += frequency
+
+ return wdth_sum / freq_sum
+
+
+def measureSlant(glyphset, glyphs=None):
+ """Measure the perceptual average slant angle of the given glyphs."""
+ if isinstance(glyphs, dict):
+ frequencies = glyphs
+ else:
+ frequencies = {g: 1 for g in glyphs}
+
+ slnt_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 = StatisticsPen(glyphset=glyphset)
+ glyph.draw(pen)
+
+ mult = glyph.width * frequency
+ slnt_sum += mult * pen.slant
+ freq_sum += mult
+
+ return -math.degrees(math.atan(slnt_sum / freq_sum))
+
+
+def sanitizeWidth(userTriple, designTriple, pins, measurements):
+ """Sanitize the width axis limits."""
+
+ minVal, defaultVal, maxVal = (
+ measurements[designTriple[0]],
+ measurements[designTriple[1]],
+ measurements[designTriple[2]],
+ )
+
+ calculatedMinVal = userTriple[1] * (minVal / defaultVal)
+ calculatedMaxVal = userTriple[1] * (maxVal / defaultVal)
+
+ log.info("Original width axis limits: %g:%g:%g", *userTriple)
+ log.info(
+ "Calculated width axis limits: %g:%g:%g",
+ calculatedMinVal,
+ userTriple[1],
+ calculatedMaxVal,
+ )
+
+ if (
+ abs(calculatedMinVal - userTriple[0]) / userTriple[1] > 0.05
+ or abs(calculatedMaxVal - userTriple[2]) / userTriple[1] > 0.05
+ ):
+ log.warning("Calculated width axis min/max do not match user input.")
+ log.warning(
+ " Current width axis limits: %g:%g:%g",
+ *userTriple,
+ )
+ log.warning(
+ " Suggested width axis limits: %g:%g:%g",
+ calculatedMinVal,
+ userTriple[1],
+ calculatedMaxVal,
+ )
+
+ return False
+
+ return True
+
+
+def sanitizeWeight(userTriple, designTriple, pins, measurements):
+ """Sanitize the weight axis limits."""
+
+ if len(set(userTriple)) < 3:
+ return True
+
+ minVal, defaultVal, maxVal = (
+ measurements[designTriple[0]],
+ measurements[designTriple[1]],
+ measurements[designTriple[2]],
+ )
+
+ logMin = math.log(minVal)
+ logDefault = math.log(defaultVal)
+ logMax = math.log(maxVal)
+
+ t = (userTriple[1] - userTriple[0]) / (userTriple[2] - userTriple[0])
+ y = math.exp(logMin + t * (logMax - logMin))
+ t = (y - minVal) / (maxVal - minVal)
+ calculatedDefaultVal = userTriple[0] + t * (userTriple[2] - userTriple[0])
+
+ log.info("Original weight axis limits: %g:%g:%g", *userTriple)
+ log.info(
+ "Calculated weight axis limits: %g:%g:%g",
+ userTriple[0],
+ calculatedDefaultVal,
+ userTriple[2],
+ )
+
+ if abs(calculatedDefaultVal - userTriple[1]) / userTriple[1] > 0.05:
+ log.warning("Calculated weight axis default does not match user input.")
+
+ log.warning(
+ " Current weight axis limits: %g:%g:%g",
+ *userTriple,
+ )
+
+ log.warning(
+ " Suggested weight axis limits, changing default: %g:%g:%g",
+ userTriple[0],
+ calculatedDefaultVal,
+ userTriple[2],
+ )
+
+ t = (userTriple[2] - userTriple[0]) / (userTriple[1] - userTriple[0])
+ y = math.exp(logMin + t * (logDefault - logMin))
+ t = (y - minVal) / (defaultVal - minVal)
+ calculatedMaxVal = userTriple[0] + t * (userTriple[1] - userTriple[0])
+ log.warning(
+ " Suggested weight axis limits, changing maximum: %g:%g:%g",
+ userTriple[0],
+ userTriple[1],
+ calculatedMaxVal,
+ )
+
+ t = (userTriple[0] - userTriple[2]) / (userTriple[1] - userTriple[2])
+ y = math.exp(logMax + t * (logDefault - logMax))
+ t = (y - maxVal) / (defaultVal - maxVal)
+ calculatedMinVal = userTriple[2] + t * (userTriple[1] - userTriple[2])
+ log.warning(
+ " Suggested weight axis limits, changing minimum: %g:%g:%g",
+ calculatedMinVal,
+ userTriple[1],
+ userTriple[2],
+ )
+
+ return False
+
+ return True
+
+
+def sanitizeSlant(userTriple, designTriple, pins, measurements):
+ """Sanitize the slant axis limits."""
+
+ log.info("Original slant axis limits: %g:%g:%g", *userTriple)
+ log.info(
+ "Calculated slant axis limits: %g:%g:%g",
+ measurements[designTriple[0]],
+ measurements[designTriple[1]],
+ measurements[designTriple[2]],
+ )
+
+ if (
+ abs(measurements[designTriple[0]] - userTriple[0]) > 1
+ or abs(measurements[designTriple[1]] - userTriple[1]) > 1
+ or abs(measurements[designTriple[2]] - userTriple[2]) > 1
+ ):
+ log.warning("Calculated slant axis min/default/max do not match user input.")
+ log.warning(
+ " Current slant axis limits: %g:%g:%g",
+ *userTriple,
+ )
+ log.warning(
+ " Suggested slant axis limits: %g:%g:%g",
+ measurements[designTriple[0]],
+ measurements[designTriple[1]],
+ measurements[designTriple[2]],
+ )
+
+ return False
+
+ return True
+
+
+def planAxis(
+ measureFunc,
+ normalizeFunc,
+ interpolateFunc,
+ glyphSetFunc,
+ axisTag,
+ axisLimits,
+ values,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitizeFunc=None,
+):
+ """Plan an axis.
+
+ measureFunc: callable that takes a glyphset and an optional
+ list of glyphnames, and returns the glyphset-wide measurement
+ to be used for the axis.
+
+ normalizeFunc: callable that takes a measurement and a minimum
+ and maximum, and normalizes the measurement into the range 0..1,
+ possibly extrapolating too.
+
+ interpolateFunc: callable that takes a normalized t value, and a
+ minimum and maximum, and returns the interpolated value,
+ possibly extrapolating too.
+
+ glyphSetFunc: callable that takes a variations "location" dictionary,
+ and returns a glyphset.
+
+ axisTag: the axis tag string.
+
+ axisLimits: a triple of minimum, default, and maximum values for
+ the axis. Or an `fvar` Axis object.
+
+ values: a list of output values to map for this axis.
+
+ samples: the number of samples to use when sampling. Default 8.
+
+ glyphs: a list of glyph names to use when sampling. Defaults to None,
+ which will process all glyphs.
+
+ designLimits: an optional triple of minimum, default, and maximum values
+ represenging the "design" limits for the axis. If not provided, the
+ axisLimits will be used.
+
+ pins: an optional dictionary of before/after mapping entries to pin in
+ the output.
+
+ sanitizeFunc: an optional callable to call to sanitize the axis limits.
+ """
+
+ if isinstance(axisLimits, fvarAxis):
+ axisLimits = (axisLimits.minValue, axisLimits.defaultValue, axisLimits.maxValue)
+ minValue, defaultValue, maxValue = axisLimits
+
+ if samples is None:
+ samples = SAMPLES
+ if glyphs is None:
+ glyphs = glyphSetFunc({}).keys()
+ if pins is None:
+ pins = {}
+ else:
+ pins = pins.copy()
+
+ log.info(
+ "Axis limits min %g / default %g / max %g", minValue, defaultValue, maxValue
+ )
+ triple = (minValue, defaultValue, maxValue)
+
+ if designLimits is not None:
+ log.info("Axis design-limits min %g / default %g / max %g", *designLimits)
+ else:
+ designLimits = triple
+
+ if pins:
+ log.info("Pins %s", sorted(pins.items()))
+ pins.update(
+ {
+ minValue: designLimits[0],
+ defaultValue: designLimits[1],
+ maxValue: designLimits[2],
+ }
+ )
+
+ out = {}
+ outNormalized = {}
+
+ axisMeasurements = {}
+ for value in sorted({minValue, defaultValue, maxValue} | set(pins.keys())):
+ glyphset = glyphSetFunc(location={axisTag: value})
+ designValue = pins[value]
+ axisMeasurements[designValue] = measureFunc(glyphset, glyphs)
+
+ if sanitizeFunc is not None:
+ log.info("Sanitizing axis limit values for the `%s` axis.", axisTag)
+ sanitizeFunc(triple, designLimits, pins, axisMeasurements)
+
+ log.debug("Calculated average value:\n%s", pformat(axisMeasurements))
+
+ 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:
+ continue
+
+ normalizedMin = normalizeValue(rangeMin, triple)
+ normalizedMax = normalizeValue(rangeMax, triple)
+ normalizedTargetMin = normalizeValue(targetMin, designLimits)
+ normalizedTargetMax = normalizeValue(targetMax, designLimits)
+
+ log.info("Planning target values %s.", sorted(targetValues))
+ log.info("Sampling %u points in range %g,%g.", samples, rangeMin, rangeMax)
+ valueMeasurements = axisMeasurements.copy()
+ for sample in range(1, samples + 1):
+ value = rangeMin + (rangeMax - rangeMin) * sample / (samples + 1)
+ log.debug("Sampling value %g.", value)
+ glyphset = glyphSetFunc(location={axisTag: value})
+ designValue = piecewiseLinearMap(value, pins)
+ valueMeasurements[designValue] = measureFunc(glyphset, glyphs)
+ log.debug("Sampled average value:\n%s", pformat(valueMeasurements))
+
+ measurementValue = {}
+ for value in sorted(valueMeasurements):
+ measurementValue[valueMeasurements[value]] = value
+
+ out[rangeMin] = targetMin
+ outNormalized[normalizedMin] = normalizedTargetMin
+ for value in sorted(targetValues):
+ t = normalizeFunc(value, rangeMin, rangeMax)
+ targetMeasurement = interpolateFunc(
+ t, valueMeasurements[targetMin], valueMeasurements[targetMax]
+ )
+ targetValue = piecewiseLinearMap(targetMeasurement, measurementValue)
+ log.debug("Planned mapping value %g to %g." % (value, targetValue))
+ out[value] = targetValue
+ valueNormalized = normalizedMin + (value - rangeMin) / (
+ rangeMax - rangeMin
+ ) * (normalizedMax - normalizedMin)
+ outNormalized[valueNormalized] = normalizedTargetMin + (
+ targetValue - targetMin
+ ) / (targetMax - targetMin) * (normalizedTargetMax - normalizedTargetMin)
+ out[rangeMax] = targetMax
+ outNormalized[normalizedMax] = normalizedTargetMax
+
+ 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.01 for k, v in outNormalized.items()):
+ log.info("Detected identity mapping for the `%s` axis. Dropping.", axisTag)
+ out = {}
+ outNormalized = {}
+
+ return out, outNormalized
+
+
+def planWeightAxis(
+ glyphSetFunc,
+ axisLimits,
+ weights=None,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitize=False,
+):
+ """Plan a weight (`wght`) axis.
+
+ weights: A list of weight values to plan for. If None, the default
+ values are used.
+
+ This function simply calls planAxis with values=weights, and the appropriate
+ arguments. See documenation for planAxis for more information.
+ """
+
+ if weights is None:
+ weights = WEIGHTS
+
+ return planAxis(
+ measureWeight,
+ normalizeLinear,
+ interpolateLog,
+ glyphSetFunc,
+ "wght",
+ axisLimits,
+ values=weights,
+ samples=samples,
+ glyphs=glyphs,
+ designLimits=designLimits,
+ pins=pins,
+ sanitizeFunc=sanitizeWeight if sanitize else None,
+ )
+
+
+def planWidthAxis(
+ glyphSetFunc,
+ axisLimits,
+ widths=None,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitize=False,
+):
+ """Plan a width (`wdth`) axis.
+
+ widths: A list of width values (percentages) to plan for. If None, the default
+ values are used.
+
+ This function simply calls planAxis with values=widths, and the appropriate
+ arguments. See documenation for planAxis for more information.
+ """
+
+ if widths is None:
+ widths = WIDTHS
+
+ return planAxis(
+ measureWidth,
+ normalizeLinear,
+ interpolateLinear,
+ glyphSetFunc,
+ "wdth",
+ axisLimits,
+ values=widths,
+ samples=samples,
+ glyphs=glyphs,
+ designLimits=designLimits,
+ pins=pins,
+ sanitizeFunc=sanitizeWidth if sanitize else None,
+ )
+
+
+def planSlantAxis(
+ glyphSetFunc,
+ axisLimits,
+ slants=None,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitize=False,
+):
+ """Plan a slant (`slnt`) axis.
+
+ slants: A list slant angles to plan for. If None, the default
+ values are used.
+
+ This function simply calls planAxis with values=slants, and the appropriate
+ arguments. See documenation for planAxis for more information.
+ """
+
+ if slants is None:
+ slants = SLANTS
+
+ return planAxis(
+ measureSlant,
+ normalizeDegrees,
+ interpolateLinear,
+ glyphSetFunc,
+ "slnt",
+ axisLimits,
+ values=slants,
+ samples=samples,
+ glyphs=glyphs,
+ designLimits=designLimits,
+ pins=pins,
+ sanitizeFunc=sanitizeSlant if sanitize else None,
+ )
+
+
+def planOpticalSizeAxis(
+ glyphSetFunc,
+ axisLimits,
+ sizes=None,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitize=False,
+):
+ """Plan a optical-size (`opsz`) axis.
+
+ sizes: A list of optical size values to plan for. If None, the default
+ values are used.
+
+ This function simply calls planAxis with values=sizes, and the appropriate
+ arguments. See documenation for planAxis for more information.
+ """
+
+ if sizes is None:
+ sizes = SIZES
+
+ return planAxis(
+ measureWeight,
+ normalizeLog,
+ interpolateLog,
+ glyphSetFunc,
+ "opsz",
+ axisLimits,
+ values=sizes,
+ samples=samples,
+ glyphs=glyphs,
+ designLimits=designLimits,
+ pins=pins,
+ )
+
+
+def makeDesignspaceSnippet(axisTag, axisName, axisLimit, mapping):
+ """Make a designspace snippet for a single axis."""
+
+ 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
+
+
+def addEmptyAvar(font):
+ """Add an empty `avar` table to the font."""
+ font["avar"] = avar = newTable("avar")
+ for axis in fvar.axes:
+ avar.segments[axis.axisTag] = {}
+
+
+def processAxis(
+ font,
+ planFunc,
+ axisTag,
+ axisName,
+ values,
+ samples=None,
+ glyphs=None,
+ designLimits=None,
+ pins=None,
+ sanitize=False,
+ plot=False,
+):
+ """Process a single axis."""
+
+ axisLimits = None
+ for axis in font["fvar"].axes:
+ if axis.axisTag == axisTag:
+ axisLimits = axis
+ break
+ if axisLimits is None:
+ return ""
+ axisLimits = (axisLimits.minValue, axisLimits.defaultValue, axisLimits.maxValue)
+
+ log.info("Planning %s axis.", axisName)
+
+ if "avar" in font:
+ existingMapping = font["avar"].segments[axisTag]
+ font["avar"].segments[axisTag] = {}
+ else:
+ existingMapping = None
+
+ if values is not None and isinstance(values, str):
+ values = [float(w) for w in values.split()]
+
+ if designLimits is not None and isinstance(designLimits, str):
+ designLimits = [float(d) for d in options.designLimits.split(":")]
+ assert (
+ len(designLimits) == 3
+ and designLimits[0] <= designLimits[1] <= designLimits[2]
+ )
+ else:
+ designLimits = None
+
+ if pins is not None and isinstance(pins, str):
+ newPins = {}
+ for pin in pins.split():
+ before, after = pin.split(":")
+ newPins[float(before)] = float(after)
+ pins = newPins
+ del newPins
+
+ mapping, mappingNormalized = planFunc(
+ font.getGlyphSet,
+ axisLimits,
+ values,
+ samples=samples,
+ glyphs=glyphs,
+ designLimits=designLimits,
+ pins=pins,
+ sanitize=sanitize,
+ )
+
+ if plot:
+ from matplotlib import pyplot
+
+ pyplot.plot(
+ sorted(mappingNormalized),
+ [mappingNormalized[k] for k in sorted(mappingNormalized)],
+ )
+ pyplot.show()
+
+ if existingMapping is not None:
+ log.info("Existing %s mapping:\n%s", axisName, pformat(existingMapping))
+
+ if mapping:
+ if "avar" not in font:
+ addEmptyAvar(font)
+ font["avar"].segments[axisTag] = mappingNormalized
+ else:
+ if "avar" in font:
+ font["avar"].segments[axisTag] = {}
+
+ designspaceSnippet = makeDesignspaceSnippet(
+ axisTag,
+ axisName,
+ axisLimits,
+ mapping,
+ )
+ return designspaceSnippet
+
+
+def main(args=None):
+ """Plan the standard axis mappings for a variable font"""
+
+ if args is None:
+ import sys
+
+ args = sys.argv[1:]
+
+ from fontTools import configLogger
+ 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="varfont.ttf", help="Variable-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."
+ )
+ parser.add_argument(
+ "--slants", type=str, help="Space-separate list of slants to generate."
+ )
+ parser.add_argument(
+ "--sizes", type=str, help="Space-separate list of optical-sizes to generate."
+ )
+ parser.add_argument("--samples", type=int, help="Number of samples.")
+ parser.add_argument(
+ "-s", "--sanitize", action="store_true", help="Sanitize axis limits"
+ )
+ parser.add_argument(
+ "-g",
+ "--glyphs",
+ type=str,
+ help="Space-separate list of glyphs to use for sampling.",
+ )
+ parser.add_argument(
+ "--weight-design-limits",
+ type=str,
+ help="min:default:max in design units for the `wght` axis.",
+ )
+ parser.add_argument(
+ "--width-design-limits",
+ type=str,
+ help="min:default:max in design units for the `wdth` axis.",
+ )
+ parser.add_argument(
+ "--slant-design-limits",
+ type=str,
+ help="min:default:max in design units for the `slnt` axis.",
+ )
+ parser.add_argument(
+ "--optical-size-design-limits",
+ type=str,
+ help="min:default:max in design units for the `opsz` axis.",
+ )
+ parser.add_argument(
+ "--weight-pins",
+ type=str,
+ help="Space-separate list of before:after pins for the `wght` axis.",
+ )
+ parser.add_argument(
+ "--width-pins",
+ type=str,
+ help="Space-separate list of before:after pins for the `wdth` axis.",
+ )
+ parser.add_argument(
+ "--slant-pins",
+ type=str,
+ help="Space-separate list of before:after pins for the `slnt` axis.",
+ )
+ parser.add_argument(
+ "--optical-size-pins",
+ type=str,
+ help="Space-separate list of before:after pins for the `opsz` axis.",
+ )
+ parser.add_argument(
+ "-p", "--plot", action="store_true", help="Plot the resulting mapping."
+ )
+
+ 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."
+ )
+
+ options = parser.parse_args(args)
+
+ configLogger(
+ level=("DEBUG" if options.verbose else "WARNING" if options.quiet else "INFO")
+ )
+
+ font = TTFont(options.font)
+ if not "fvar" in font:
+ log.error("Not a variable font.")
+ return 1
+
+ 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
+
+ designspaceSnippets = []
+
+ designspaceSnippets.append(
+ processAxis(
+ font,
+ planWeightAxis,
+ "wght",
+ "Weight",
+ values=options.weights,
+ samples=options.samples,
+ glyphs=glyphs,
+ designLimits=options.weight_design_limits,
+ pins=options.weight_pins,
+ sanitize=options.sanitize,
+ plot=options.plot,
+ )
+ )
+ designspaceSnippets.append(
+ processAxis(
+ font,
+ planWidthAxis,
+ "wdth",
+ "Width",
+ values=options.widths,
+ samples=options.samples,
+ glyphs=glyphs,
+ designLimits=options.width_design_limits,
+ pins=options.width_pins,
+ sanitize=options.sanitize,
+ plot=options.plot,
+ )
+ )
+ designspaceSnippets.append(
+ processAxis(
+ font,
+ planSlantAxis,
+ "slnt",
+ "Slant",
+ values=options.slants,
+ samples=options.samples,
+ glyphs=glyphs,
+ designLimits=options.slant_design_limits,
+ pins=options.slant_pins,
+ sanitize=options.sanitize,
+ plot=options.plot,
+ )
+ )
+ designspaceSnippets.append(
+ processAxis(
+ font,
+ planOpticalSizeAxis,
+ "opsz",
+ "OpticalSize",
+ values=options.sizes,
+ samples=options.samples,
+ glyphs=glyphs,
+ designLimits=options.optical_size_design_limits,
+ pins=options.optical_size_pins,
+ sanitize=options.sanitize,
+ plot=options.plot,
+ )
+ )
+
+ log.info("Designspace snippet:")
+ for snippet in designspaceSnippets:
+ if snippet:
+ print(snippet)
+
+ 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())