| 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""" | |
| from fontTools import configLogger | |
| 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." | |
| ) | |
| 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.") | |
| sys.exit(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()) | |