xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/avarPlanner.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from fontTools.ttLib import newTable
2from fontTools.ttLib.tables._f_v_a_r import Axis as fvarAxis
3from fontTools.pens.areaPen import AreaPen
4from fontTools.pens.basePen import NullPen
5from fontTools.pens.statisticsPen import StatisticsPen
6from fontTools.varLib.models import piecewiseLinearMap, normalizeValue
7from fontTools.misc.cliTools import makeOutputFileName
8import math
9import logging
10from pprint import pformat
11
12__all__ = [
13    "planWeightAxis",
14    "planWidthAxis",
15    "planSlantAxis",
16    "planOpticalSizeAxis",
17    "planAxis",
18    "sanitizeWeight",
19    "sanitizeWidth",
20    "sanitizeSlant",
21    "measureWeight",
22    "measureWidth",
23    "measureSlant",
24    "normalizeLinear",
25    "normalizeLog",
26    "normalizeDegrees",
27    "interpolateLinear",
28    "interpolateLog",
29    "processAxis",
30    "makeDesignspaceSnippet",
31    "addEmptyAvar",
32    "main",
33]
34
35log = logging.getLogger("fontTools.varLib.avarPlanner")
36
37WEIGHTS = [
38    50,
39    100,
40    150,
41    200,
42    250,
43    300,
44    350,
45    400,
46    450,
47    500,
48    550,
49    600,
50    650,
51    700,
52    750,
53    800,
54    850,
55    900,
56    950,
57]
58
59WIDTHS = [
60    25.0,
61    37.5,
62    50.0,
63    62.5,
64    75.0,
65    87.5,
66    100.0,
67    112.5,
68    125.0,
69    137.5,
70    150.0,
71    162.5,
72    175.0,
73    187.5,
74    200.0,
75]
76
77SLANTS = list(math.degrees(math.atan(d / 20.0)) for d in range(-20, 21))
78
79SIZES = [
80    5,
81    6,
82    7,
83    8,
84    9,
85    10,
86    11,
87    12,
88    14,
89    18,
90    24,
91    30,
92    36,
93    48,
94    60,
95    72,
96    96,
97    120,
98    144,
99    192,
100    240,
101    288,
102]
103
104
105SAMPLES = 8
106
107
108def normalizeLinear(value, rangeMin, rangeMax):
109    """Linearly normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
110    return (value - rangeMin) / (rangeMax - rangeMin)
111
112
113def interpolateLinear(t, a, b):
114    """Linear interpolation between a and b, with t typically in [0, 1]."""
115    return a + t * (b - a)
116
117
118def normalizeLog(value, rangeMin, rangeMax):
119    """Logarithmically normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
120    logMin = math.log(rangeMin)
121    logMax = math.log(rangeMax)
122    return (math.log(value) - logMin) / (logMax - logMin)
123
124
125def interpolateLog(t, a, b):
126    """Logarithmic interpolation between a and b, with t typically in [0, 1]."""
127    logA = math.log(a)
128    logB = math.log(b)
129    return math.exp(logA + t * (logB - logA))
130
131
132def normalizeDegrees(value, rangeMin, rangeMax):
133    """Angularly normalize value in [rangeMin, rangeMax] to [0, 1], with extrapolation."""
134    tanMin = math.tan(math.radians(rangeMin))
135    tanMax = math.tan(math.radians(rangeMax))
136    return (math.tan(math.radians(value)) - tanMin) / (tanMax - tanMin)
137
138
139def measureWeight(glyphset, glyphs=None):
140    """Measure the perceptual average weight of the given glyphs."""
141    if isinstance(glyphs, dict):
142        frequencies = glyphs
143    else:
144        frequencies = {g: 1 for g in glyphs}
145
146    wght_sum = wdth_sum = 0
147    for glyph_name in glyphs:
148        if frequencies is not None:
149            frequency = frequencies.get(glyph_name, 0)
150            if frequency == 0:
151                continue
152        else:
153            frequency = 1
154
155        glyph = glyphset[glyph_name]
156
157        pen = AreaPen(glyphset=glyphset)
158        glyph.draw(pen)
159
160        mult = glyph.width * frequency
161        wght_sum += mult * abs(pen.value)
162        wdth_sum += mult
163
164    return wght_sum / wdth_sum
165
166
167def measureWidth(glyphset, glyphs=None):
168    """Measure the average width of the given glyphs."""
169    if isinstance(glyphs, dict):
170        frequencies = glyphs
171    else:
172        frequencies = {g: 1 for g in glyphs}
173
174    wdth_sum = 0
175    freq_sum = 0
176    for glyph_name in glyphs:
177        if frequencies is not None:
178            frequency = frequencies.get(glyph_name, 0)
179            if frequency == 0:
180                continue
181        else:
182            frequency = 1
183
184        glyph = glyphset[glyph_name]
185
186        pen = NullPen()
187        glyph.draw(pen)
188
189        wdth_sum += glyph.width * frequency
190        freq_sum += frequency
191
192    return wdth_sum / freq_sum
193
194
195def measureSlant(glyphset, glyphs=None):
196    """Measure the perceptual average slant angle of the given glyphs."""
197    if isinstance(glyphs, dict):
198        frequencies = glyphs
199    else:
200        frequencies = {g: 1 for g in glyphs}
201
202    slnt_sum = 0
203    freq_sum = 0
204    for glyph_name in glyphs:
205        if frequencies is not None:
206            frequency = frequencies.get(glyph_name, 0)
207            if frequency == 0:
208                continue
209        else:
210            frequency = 1
211
212        glyph = glyphset[glyph_name]
213
214        pen = StatisticsPen(glyphset=glyphset)
215        glyph.draw(pen)
216
217        mult = glyph.width * frequency
218        slnt_sum += mult * pen.slant
219        freq_sum += mult
220
221    return -math.degrees(math.atan(slnt_sum / freq_sum))
222
223
224def sanitizeWidth(userTriple, designTriple, pins, measurements):
225    """Sanitize the width axis limits."""
226
227    minVal, defaultVal, maxVal = (
228        measurements[designTriple[0]],
229        measurements[designTriple[1]],
230        measurements[designTriple[2]],
231    )
232
233    calculatedMinVal = userTriple[1] * (minVal / defaultVal)
234    calculatedMaxVal = userTriple[1] * (maxVal / defaultVal)
235
236    log.info("Original width axis limits: %g:%g:%g", *userTriple)
237    log.info(
238        "Calculated width axis limits: %g:%g:%g",
239        calculatedMinVal,
240        userTriple[1],
241        calculatedMaxVal,
242    )
243
244    if (
245        abs(calculatedMinVal - userTriple[0]) / userTriple[1] > 0.05
246        or abs(calculatedMaxVal - userTriple[2]) / userTriple[1] > 0.05
247    ):
248        log.warning("Calculated width axis min/max do not match user input.")
249        log.warning(
250            "  Current width axis limits: %g:%g:%g",
251            *userTriple,
252        )
253        log.warning(
254            "  Suggested width axis limits: %g:%g:%g",
255            calculatedMinVal,
256            userTriple[1],
257            calculatedMaxVal,
258        )
259
260        return False
261
262    return True
263
264
265def sanitizeWeight(userTriple, designTriple, pins, measurements):
266    """Sanitize the weight axis limits."""
267
268    if len(set(userTriple)) < 3:
269        return True
270
271    minVal, defaultVal, maxVal = (
272        measurements[designTriple[0]],
273        measurements[designTriple[1]],
274        measurements[designTriple[2]],
275    )
276
277    logMin = math.log(minVal)
278    logDefault = math.log(defaultVal)
279    logMax = math.log(maxVal)
280
281    t = (userTriple[1] - userTriple[0]) / (userTriple[2] - userTriple[0])
282    y = math.exp(logMin + t * (logMax - logMin))
283    t = (y - minVal) / (maxVal - minVal)
284    calculatedDefaultVal = userTriple[0] + t * (userTriple[2] - userTriple[0])
285
286    log.info("Original weight axis limits: %g:%g:%g", *userTriple)
287    log.info(
288        "Calculated weight axis limits: %g:%g:%g",
289        userTriple[0],
290        calculatedDefaultVal,
291        userTriple[2],
292    )
293
294    if abs(calculatedDefaultVal - userTriple[1]) / userTriple[1] > 0.05:
295        log.warning("Calculated weight axis default does not match user input.")
296
297        log.warning(
298            "  Current weight axis limits: %g:%g:%g",
299            *userTriple,
300        )
301
302        log.warning(
303            "  Suggested weight axis limits, changing default: %g:%g:%g",
304            userTriple[0],
305            calculatedDefaultVal,
306            userTriple[2],
307        )
308
309        t = (userTriple[2] - userTriple[0]) / (userTriple[1] - userTriple[0])
310        y = math.exp(logMin + t * (logDefault - logMin))
311        t = (y - minVal) / (defaultVal - minVal)
312        calculatedMaxVal = userTriple[0] + t * (userTriple[1] - userTriple[0])
313        log.warning(
314            "  Suggested weight axis limits, changing maximum: %g:%g:%g",
315            userTriple[0],
316            userTriple[1],
317            calculatedMaxVal,
318        )
319
320        t = (userTriple[0] - userTriple[2]) / (userTriple[1] - userTriple[2])
321        y = math.exp(logMax + t * (logDefault - logMax))
322        t = (y - maxVal) / (defaultVal - maxVal)
323        calculatedMinVal = userTriple[2] + t * (userTriple[1] - userTriple[2])
324        log.warning(
325            "  Suggested weight axis limits, changing minimum: %g:%g:%g",
326            calculatedMinVal,
327            userTriple[1],
328            userTriple[2],
329        )
330
331        return False
332
333    return True
334
335
336def sanitizeSlant(userTriple, designTriple, pins, measurements):
337    """Sanitize the slant axis limits."""
338
339    log.info("Original slant axis limits: %g:%g:%g", *userTriple)
340    log.info(
341        "Calculated slant axis limits: %g:%g:%g",
342        measurements[designTriple[0]],
343        measurements[designTriple[1]],
344        measurements[designTriple[2]],
345    )
346
347    if (
348        abs(measurements[designTriple[0]] - userTriple[0]) > 1
349        or abs(measurements[designTriple[1]] - userTriple[1]) > 1
350        or abs(measurements[designTriple[2]] - userTriple[2]) > 1
351    ):
352        log.warning("Calculated slant axis min/default/max do not match user input.")
353        log.warning(
354            "  Current slant axis limits: %g:%g:%g",
355            *userTriple,
356        )
357        log.warning(
358            "  Suggested slant axis limits: %g:%g:%g",
359            measurements[designTriple[0]],
360            measurements[designTriple[1]],
361            measurements[designTriple[2]],
362        )
363
364        return False
365
366    return True
367
368
369def planAxis(
370    measureFunc,
371    normalizeFunc,
372    interpolateFunc,
373    glyphSetFunc,
374    axisTag,
375    axisLimits,
376    values,
377    samples=None,
378    glyphs=None,
379    designLimits=None,
380    pins=None,
381    sanitizeFunc=None,
382):
383    """Plan an axis.
384
385    measureFunc: callable that takes a glyphset and an optional
386    list of glyphnames, and returns the glyphset-wide measurement
387    to be used for the axis.
388
389    normalizeFunc: callable that takes a measurement and a minimum
390    and maximum, and normalizes the measurement into the range 0..1,
391    possibly extrapolating too.
392
393    interpolateFunc: callable that takes a normalized t value, and a
394    minimum and maximum, and returns the interpolated value,
395    possibly extrapolating too.
396
397    glyphSetFunc: callable that takes a variations "location" dictionary,
398    and returns a glyphset.
399
400    axisTag: the axis tag string.
401
402    axisLimits: a triple of minimum, default, and maximum values for
403    the axis. Or an `fvar` Axis object.
404
405    values: a list of output values to map for this axis.
406
407    samples: the number of samples to use when sampling. Default 8.
408
409    glyphs: a list of glyph names to use when sampling. Defaults to None,
410    which will process all glyphs.
411
412    designLimits: an optional triple of minimum, default, and maximum values
413    represenging the "design" limits for the axis. If not provided, the
414    axisLimits will be used.
415
416    pins: an optional dictionary of before/after mapping entries to pin in
417    the output.
418
419    sanitizeFunc: an optional callable to call to sanitize the axis limits.
420    """
421
422    if isinstance(axisLimits, fvarAxis):
423        axisLimits = (axisLimits.minValue, axisLimits.defaultValue, axisLimits.maxValue)
424    minValue, defaultValue, maxValue = axisLimits
425
426    if samples is None:
427        samples = SAMPLES
428    if glyphs is None:
429        glyphs = glyphSetFunc({}).keys()
430    if pins is None:
431        pins = {}
432    else:
433        pins = pins.copy()
434
435    log.info(
436        "Axis limits min %g / default %g / max %g", minValue, defaultValue, maxValue
437    )
438    triple = (minValue, defaultValue, maxValue)
439
440    if designLimits is not None:
441        log.info("Axis design-limits min %g / default %g / max %g", *designLimits)
442    else:
443        designLimits = triple
444
445    if pins:
446        log.info("Pins %s", sorted(pins.items()))
447    pins.update(
448        {
449            minValue: designLimits[0],
450            defaultValue: designLimits[1],
451            maxValue: designLimits[2],
452        }
453    )
454
455    out = {}
456    outNormalized = {}
457
458    axisMeasurements = {}
459    for value in sorted({minValue, defaultValue, maxValue} | set(pins.keys())):
460        glyphset = glyphSetFunc(location={axisTag: value})
461        designValue = pins[value]
462        axisMeasurements[designValue] = measureFunc(glyphset, glyphs)
463
464    if sanitizeFunc is not None:
465        log.info("Sanitizing axis limit values for the `%s` axis.", axisTag)
466        sanitizeFunc(triple, designLimits, pins, axisMeasurements)
467
468    log.debug("Calculated average value:\n%s", pformat(axisMeasurements))
469
470    for (rangeMin, targetMin), (rangeMax, targetMax) in zip(
471        list(sorted(pins.items()))[:-1],
472        list(sorted(pins.items()))[1:],
473    ):
474        targetValues = {w for w in values if rangeMin < w < rangeMax}
475        if not targetValues:
476            continue
477
478        normalizedMin = normalizeValue(rangeMin, triple)
479        normalizedMax = normalizeValue(rangeMax, triple)
480        normalizedTargetMin = normalizeValue(targetMin, designLimits)
481        normalizedTargetMax = normalizeValue(targetMax, designLimits)
482
483        log.info("Planning target values %s.", sorted(targetValues))
484        log.info("Sampling %u points in range %g,%g.", samples, rangeMin, rangeMax)
485        valueMeasurements = axisMeasurements.copy()
486        for sample in range(1, samples + 1):
487            value = rangeMin + (rangeMax - rangeMin) * sample / (samples + 1)
488            log.debug("Sampling value %g.", value)
489            glyphset = glyphSetFunc(location={axisTag: value})
490            designValue = piecewiseLinearMap(value, pins)
491            valueMeasurements[designValue] = measureFunc(glyphset, glyphs)
492        log.debug("Sampled average value:\n%s", pformat(valueMeasurements))
493
494        measurementValue = {}
495        for value in sorted(valueMeasurements):
496            measurementValue[valueMeasurements[value]] = value
497
498        out[rangeMin] = targetMin
499        outNormalized[normalizedMin] = normalizedTargetMin
500        for value in sorted(targetValues):
501            t = normalizeFunc(value, rangeMin, rangeMax)
502            targetMeasurement = interpolateFunc(
503                t, valueMeasurements[targetMin], valueMeasurements[targetMax]
504            )
505            targetValue = piecewiseLinearMap(targetMeasurement, measurementValue)
506            log.debug("Planned mapping value %g to %g." % (value, targetValue))
507            out[value] = targetValue
508            valueNormalized = normalizedMin + (value - rangeMin) / (
509                rangeMax - rangeMin
510            ) * (normalizedMax - normalizedMin)
511            outNormalized[valueNormalized] = normalizedTargetMin + (
512                targetValue - targetMin
513            ) / (targetMax - targetMin) * (normalizedTargetMax - normalizedTargetMin)
514        out[rangeMax] = targetMax
515        outNormalized[normalizedMax] = normalizedTargetMax
516
517    log.info("Planned mapping for the `%s` axis:\n%s", axisTag, pformat(out))
518    log.info(
519        "Planned normalized mapping for the `%s` axis:\n%s",
520        axisTag,
521        pformat(outNormalized),
522    )
523
524    if all(abs(k - v) < 0.01 for k, v in outNormalized.items()):
525        log.info("Detected identity mapping for the `%s` axis. Dropping.", axisTag)
526        out = {}
527        outNormalized = {}
528
529    return out, outNormalized
530
531
532def planWeightAxis(
533    glyphSetFunc,
534    axisLimits,
535    weights=None,
536    samples=None,
537    glyphs=None,
538    designLimits=None,
539    pins=None,
540    sanitize=False,
541):
542    """Plan a weight (`wght`) axis.
543
544    weights: A list of weight values to plan for. If None, the default
545    values are used.
546
547    This function simply calls planAxis with values=weights, and the appropriate
548    arguments. See documenation for planAxis for more information.
549    """
550
551    if weights is None:
552        weights = WEIGHTS
553
554    return planAxis(
555        measureWeight,
556        normalizeLinear,
557        interpolateLog,
558        glyphSetFunc,
559        "wght",
560        axisLimits,
561        values=weights,
562        samples=samples,
563        glyphs=glyphs,
564        designLimits=designLimits,
565        pins=pins,
566        sanitizeFunc=sanitizeWeight if sanitize else None,
567    )
568
569
570def planWidthAxis(
571    glyphSetFunc,
572    axisLimits,
573    widths=None,
574    samples=None,
575    glyphs=None,
576    designLimits=None,
577    pins=None,
578    sanitize=False,
579):
580    """Plan a width (`wdth`) axis.
581
582    widths: A list of width values (percentages) to plan for. If None, the default
583    values are used.
584
585    This function simply calls planAxis with values=widths, and the appropriate
586    arguments. See documenation for planAxis for more information.
587    """
588
589    if widths is None:
590        widths = WIDTHS
591
592    return planAxis(
593        measureWidth,
594        normalizeLinear,
595        interpolateLinear,
596        glyphSetFunc,
597        "wdth",
598        axisLimits,
599        values=widths,
600        samples=samples,
601        glyphs=glyphs,
602        designLimits=designLimits,
603        pins=pins,
604        sanitizeFunc=sanitizeWidth if sanitize else None,
605    )
606
607
608def planSlantAxis(
609    glyphSetFunc,
610    axisLimits,
611    slants=None,
612    samples=None,
613    glyphs=None,
614    designLimits=None,
615    pins=None,
616    sanitize=False,
617):
618    """Plan a slant (`slnt`) axis.
619
620    slants: A list slant angles to plan for. If None, the default
621    values are used.
622
623    This function simply calls planAxis with values=slants, and the appropriate
624    arguments. See documenation for planAxis for more information.
625    """
626
627    if slants is None:
628        slants = SLANTS
629
630    return planAxis(
631        measureSlant,
632        normalizeDegrees,
633        interpolateLinear,
634        glyphSetFunc,
635        "slnt",
636        axisLimits,
637        values=slants,
638        samples=samples,
639        glyphs=glyphs,
640        designLimits=designLimits,
641        pins=pins,
642        sanitizeFunc=sanitizeSlant if sanitize else None,
643    )
644
645
646def planOpticalSizeAxis(
647    glyphSetFunc,
648    axisLimits,
649    sizes=None,
650    samples=None,
651    glyphs=None,
652    designLimits=None,
653    pins=None,
654    sanitize=False,
655):
656    """Plan a optical-size (`opsz`) axis.
657
658    sizes: A list of optical size values to plan for. If None, the default
659    values are used.
660
661    This function simply calls planAxis with values=sizes, and the appropriate
662    arguments. See documenation for planAxis for more information.
663    """
664
665    if sizes is None:
666        sizes = SIZES
667
668    return planAxis(
669        measureWeight,
670        normalizeLog,
671        interpolateLog,
672        glyphSetFunc,
673        "opsz",
674        axisLimits,
675        values=sizes,
676        samples=samples,
677        glyphs=glyphs,
678        designLimits=designLimits,
679        pins=pins,
680    )
681
682
683def makeDesignspaceSnippet(axisTag, axisName, axisLimit, mapping):
684    """Make a designspace snippet for a single axis."""
685
686    designspaceSnippet = (
687        '    <axis tag="%s" name="%s" minimum="%g" default="%g" maximum="%g"'
688        % ((axisTag, axisName) + axisLimit)
689    )
690    if mapping:
691        designspaceSnippet += ">\n"
692    else:
693        designspaceSnippet += "/>"
694
695    for key, value in mapping.items():
696        designspaceSnippet += '      <map input="%g" output="%g"/>\n' % (key, value)
697
698    if mapping:
699        designspaceSnippet += "    </axis>"
700
701    return designspaceSnippet
702
703
704def addEmptyAvar(font):
705    """Add an empty `avar` table to the font."""
706    font["avar"] = avar = newTable("avar")
707    for axis in fvar.axes:
708        avar.segments[axis.axisTag] = {}
709
710
711def processAxis(
712    font,
713    planFunc,
714    axisTag,
715    axisName,
716    values,
717    samples=None,
718    glyphs=None,
719    designLimits=None,
720    pins=None,
721    sanitize=False,
722    plot=False,
723):
724    """Process a single axis."""
725
726    axisLimits = None
727    for axis in font["fvar"].axes:
728        if axis.axisTag == axisTag:
729            axisLimits = axis
730            break
731    if axisLimits is None:
732        return ""
733    axisLimits = (axisLimits.minValue, axisLimits.defaultValue, axisLimits.maxValue)
734
735    log.info("Planning %s axis.", axisName)
736
737    if "avar" in font:
738        existingMapping = font["avar"].segments[axisTag]
739        font["avar"].segments[axisTag] = {}
740    else:
741        existingMapping = None
742
743    if values is not None and isinstance(values, str):
744        values = [float(w) for w in values.split()]
745
746    if designLimits is not None and isinstance(designLimits, str):
747        designLimits = [float(d) for d in options.designLimits.split(":")]
748        assert (
749            len(designLimits) == 3
750            and designLimits[0] <= designLimits[1] <= designLimits[2]
751        )
752    else:
753        designLimits = None
754
755    if pins is not None and isinstance(pins, str):
756        newPins = {}
757        for pin in pins.split():
758            before, after = pin.split(":")
759            newPins[float(before)] = float(after)
760        pins = newPins
761        del newPins
762
763    mapping, mappingNormalized = planFunc(
764        font.getGlyphSet,
765        axisLimits,
766        values,
767        samples=samples,
768        glyphs=glyphs,
769        designLimits=designLimits,
770        pins=pins,
771        sanitize=sanitize,
772    )
773
774    if plot:
775        from matplotlib import pyplot
776
777        pyplot.plot(
778            sorted(mappingNormalized),
779            [mappingNormalized[k] for k in sorted(mappingNormalized)],
780        )
781        pyplot.show()
782
783    if existingMapping is not None:
784        log.info("Existing %s mapping:\n%s", axisName, pformat(existingMapping))
785
786    if mapping:
787        if "avar" not in font:
788            addEmptyAvar(font)
789        font["avar"].segments[axisTag] = mappingNormalized
790    else:
791        if "avar" in font:
792            font["avar"].segments[axisTag] = {}
793
794    designspaceSnippet = makeDesignspaceSnippet(
795        axisTag,
796        axisName,
797        axisLimits,
798        mapping,
799    )
800    return designspaceSnippet
801
802
803def main(args=None):
804    """Plan the standard axis mappings for a variable font"""
805
806    if args is None:
807        import sys
808
809        args = sys.argv[1:]
810
811    from fontTools import configLogger
812    from fontTools.ttLib import TTFont
813    import argparse
814
815    parser = argparse.ArgumentParser(
816        "fonttools varLib.avarPlanner",
817        description="Plan `avar` table for variable font",
818    )
819    parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.")
820    parser.add_argument(
821        "-o",
822        "--output-file",
823        type=str,
824        help="Output font file name.",
825    )
826    parser.add_argument(
827        "--weights", type=str, help="Space-separate list of weights to generate."
828    )
829    parser.add_argument(
830        "--widths", type=str, help="Space-separate list of widths to generate."
831    )
832    parser.add_argument(
833        "--slants", type=str, help="Space-separate list of slants to generate."
834    )
835    parser.add_argument(
836        "--sizes", type=str, help="Space-separate list of optical-sizes to generate."
837    )
838    parser.add_argument("--samples", type=int, help="Number of samples.")
839    parser.add_argument(
840        "-s", "--sanitize", action="store_true", help="Sanitize axis limits"
841    )
842    parser.add_argument(
843        "-g",
844        "--glyphs",
845        type=str,
846        help="Space-separate list of glyphs to use for sampling.",
847    )
848    parser.add_argument(
849        "--weight-design-limits",
850        type=str,
851        help="min:default:max in design units for the `wght` axis.",
852    )
853    parser.add_argument(
854        "--width-design-limits",
855        type=str,
856        help="min:default:max in design units for the `wdth` axis.",
857    )
858    parser.add_argument(
859        "--slant-design-limits",
860        type=str,
861        help="min:default:max in design units for the `slnt` axis.",
862    )
863    parser.add_argument(
864        "--optical-size-design-limits",
865        type=str,
866        help="min:default:max in design units for the `opsz` axis.",
867    )
868    parser.add_argument(
869        "--weight-pins",
870        type=str,
871        help="Space-separate list of before:after pins for the `wght` axis.",
872    )
873    parser.add_argument(
874        "--width-pins",
875        type=str,
876        help="Space-separate list of before:after pins for the `wdth` axis.",
877    )
878    parser.add_argument(
879        "--slant-pins",
880        type=str,
881        help="Space-separate list of before:after pins for the `slnt` axis.",
882    )
883    parser.add_argument(
884        "--optical-size-pins",
885        type=str,
886        help="Space-separate list of before:after pins for the `opsz` axis.",
887    )
888    parser.add_argument(
889        "-p", "--plot", action="store_true", help="Plot the resulting mapping."
890    )
891
892    logging_group = parser.add_mutually_exclusive_group(required=False)
893    logging_group.add_argument(
894        "-v", "--verbose", action="store_true", help="Run more verbosely."
895    )
896    logging_group.add_argument(
897        "-q", "--quiet", action="store_true", help="Turn verbosity off."
898    )
899
900    options = parser.parse_args(args)
901
902    configLogger(
903        level=("DEBUG" if options.verbose else "WARNING" if options.quiet else "INFO")
904    )
905
906    font = TTFont(options.font)
907    if not "fvar" in font:
908        log.error("Not a variable font.")
909        return 1
910
911    if options.glyphs is not None:
912        glyphs = options.glyphs.split()
913        if ":" in options.glyphs:
914            glyphs = {}
915            for g in options.glyphs.split():
916                if ":" in g:
917                    glyph, frequency = g.split(":")
918                    glyphs[glyph] = float(frequency)
919                else:
920                    glyphs[g] = 1.0
921    else:
922        glyphs = None
923
924    designspaceSnippets = []
925
926    designspaceSnippets.append(
927        processAxis(
928            font,
929            planWeightAxis,
930            "wght",
931            "Weight",
932            values=options.weights,
933            samples=options.samples,
934            glyphs=glyphs,
935            designLimits=options.weight_design_limits,
936            pins=options.weight_pins,
937            sanitize=options.sanitize,
938            plot=options.plot,
939        )
940    )
941    designspaceSnippets.append(
942        processAxis(
943            font,
944            planWidthAxis,
945            "wdth",
946            "Width",
947            values=options.widths,
948            samples=options.samples,
949            glyphs=glyphs,
950            designLimits=options.width_design_limits,
951            pins=options.width_pins,
952            sanitize=options.sanitize,
953            plot=options.plot,
954        )
955    )
956    designspaceSnippets.append(
957        processAxis(
958            font,
959            planSlantAxis,
960            "slnt",
961            "Slant",
962            values=options.slants,
963            samples=options.samples,
964            glyphs=glyphs,
965            designLimits=options.slant_design_limits,
966            pins=options.slant_pins,
967            sanitize=options.sanitize,
968            plot=options.plot,
969        )
970    )
971    designspaceSnippets.append(
972        processAxis(
973            font,
974            planOpticalSizeAxis,
975            "opsz",
976            "OpticalSize",
977            values=options.sizes,
978            samples=options.samples,
979            glyphs=glyphs,
980            designLimits=options.optical_size_design_limits,
981            pins=options.optical_size_pins,
982            sanitize=options.sanitize,
983            plot=options.plot,
984        )
985    )
986
987    log.info("Designspace snippet:")
988    for snippet in designspaceSnippets:
989        if snippet:
990            print(snippet)
991
992    if options.output_file is None:
993        outfile = makeOutputFileName(options.font, overWrite=True, suffix=".avar")
994    else:
995        outfile = options.output_file
996    if outfile:
997        log.info("Saving %s", outfile)
998        font.save(outfile)
999
1000
1001if __name__ == "__main__":
1002    import sys
1003
1004    sys.exit(main())
1005