1005 lines
27 KiB
Python
1005 lines
27 KiB
Python
|
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())
|