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