|
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()) |
|
|