631 lines
21 KiB
Python
631 lines
21 KiB
Python
"""Variation fonts interpolation models."""
|
|
|
|
__all__ = [
|
|
"normalizeValue",
|
|
"normalizeLocation",
|
|
"supportScalar",
|
|
"piecewiseLinearMap",
|
|
"VariationModel",
|
|
]
|
|
|
|
from fontTools.misc.roundTools import noRound
|
|
from .errors import VariationModelError
|
|
|
|
|
|
def nonNone(lst):
|
|
return [l for l in lst if l is not None]
|
|
|
|
|
|
def allNone(lst):
|
|
return all(l is None for l in lst)
|
|
|
|
|
|
def allEqualTo(ref, lst, mapper=None):
|
|
if mapper is None:
|
|
return all(ref == item for item in lst)
|
|
|
|
mapped = mapper(ref)
|
|
return all(mapped == mapper(item) for item in lst)
|
|
|
|
|
|
def allEqual(lst, mapper=None):
|
|
if not lst:
|
|
return True
|
|
it = iter(lst)
|
|
try:
|
|
first = next(it)
|
|
except StopIteration:
|
|
return True
|
|
return allEqualTo(first, it, mapper=mapper)
|
|
|
|
|
|
def subList(truth, lst):
|
|
assert len(truth) == len(lst)
|
|
return [l for l, t in zip(lst, truth) if t]
|
|
|
|
|
|
def normalizeValue(v, triple, extrapolate=False):
|
|
"""Normalizes value based on a min/default/max triple.
|
|
|
|
>>> normalizeValue(400, (100, 400, 900))
|
|
0.0
|
|
>>> normalizeValue(100, (100, 400, 900))
|
|
-1.0
|
|
>>> normalizeValue(650, (100, 400, 900))
|
|
0.5
|
|
"""
|
|
lower, default, upper = triple
|
|
if not (lower <= default <= upper):
|
|
raise ValueError(
|
|
f"Invalid axis values, must be minimum, default, maximum: "
|
|
f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
|
|
)
|
|
if not extrapolate:
|
|
v = max(min(v, upper), lower)
|
|
|
|
if v == default or lower == upper:
|
|
return 0.0
|
|
|
|
if (v < default and lower != default) or (v > default and upper == default):
|
|
return (v - default) / (default - lower)
|
|
else:
|
|
assert (v > default and upper != default) or (
|
|
v < default and lower == default
|
|
), f"Ooops... v={v}, triple=({lower}, {default}, {upper})"
|
|
return (v - default) / (upper - default)
|
|
|
|
|
|
def normalizeLocation(location, axes, extrapolate=False):
|
|
"""Normalizes location based on axis min/default/max values from axes.
|
|
|
|
>>> axes = {"wght": (100, 400, 900)}
|
|
>>> normalizeLocation({"wght": 400}, axes)
|
|
{'wght': 0.0}
|
|
>>> normalizeLocation({"wght": 100}, axes)
|
|
{'wght': -1.0}
|
|
>>> normalizeLocation({"wght": 900}, axes)
|
|
{'wght': 1.0}
|
|
>>> normalizeLocation({"wght": 650}, axes)
|
|
{'wght': 0.5}
|
|
>>> normalizeLocation({"wght": 1000}, axes)
|
|
{'wght': 1.0}
|
|
>>> normalizeLocation({"wght": 0}, axes)
|
|
{'wght': -1.0}
|
|
>>> axes = {"wght": (0, 0, 1000)}
|
|
>>> normalizeLocation({"wght": 0}, axes)
|
|
{'wght': 0.0}
|
|
>>> normalizeLocation({"wght": -1}, axes)
|
|
{'wght': 0.0}
|
|
>>> normalizeLocation({"wght": 1000}, axes)
|
|
{'wght': 1.0}
|
|
>>> normalizeLocation({"wght": 500}, axes)
|
|
{'wght': 0.5}
|
|
>>> normalizeLocation({"wght": 1001}, axes)
|
|
{'wght': 1.0}
|
|
>>> axes = {"wght": (0, 1000, 1000)}
|
|
>>> normalizeLocation({"wght": 0}, axes)
|
|
{'wght': -1.0}
|
|
>>> normalizeLocation({"wght": -1}, axes)
|
|
{'wght': -1.0}
|
|
>>> normalizeLocation({"wght": 500}, axes)
|
|
{'wght': -0.5}
|
|
>>> normalizeLocation({"wght": 1000}, axes)
|
|
{'wght': 0.0}
|
|
>>> normalizeLocation({"wght": 1001}, axes)
|
|
{'wght': 0.0}
|
|
"""
|
|
out = {}
|
|
for tag, triple in axes.items():
|
|
v = location.get(tag, triple[1])
|
|
out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
|
|
return out
|
|
|
|
|
|
def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
|
|
"""Returns the scalar multiplier at location, for a master
|
|
with support. If ot is True, then a peak value of zero
|
|
for support of an axis means "axis does not participate". That
|
|
is how OpenType Variation Font technology works.
|
|
|
|
If extrapolate is True, axisRanges must be a dict that maps axis
|
|
names to (axisMin, axisMax) tuples.
|
|
|
|
>>> supportScalar({}, {})
|
|
1.0
|
|
>>> supportScalar({'wght':.2}, {})
|
|
1.0
|
|
>>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
|
|
0.1
|
|
>>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
|
|
0.75
|
|
>>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
|
|
0.75
|
|
>>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
|
|
0.375
|
|
>>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
|
|
0.75
|
|
>>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
|
|
0.75
|
|
>>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
|
|
-1.0
|
|
>>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
|
|
-1.0
|
|
>>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
|
|
1.5
|
|
>>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
|
|
-0.5
|
|
"""
|
|
if extrapolate and axisRanges is None:
|
|
raise TypeError("axisRanges must be passed when extrapolate is True")
|
|
scalar = 1.0
|
|
for axis, (lower, peak, upper) in support.items():
|
|
if ot:
|
|
# OpenType-specific case handling
|
|
if peak == 0.0:
|
|
continue
|
|
if lower > peak or peak > upper:
|
|
continue
|
|
if lower < 0.0 and upper > 0.0:
|
|
continue
|
|
v = location.get(axis, 0.0)
|
|
else:
|
|
assert axis in location
|
|
v = location[axis]
|
|
if v == peak:
|
|
continue
|
|
|
|
if extrapolate:
|
|
axisMin, axisMax = axisRanges[axis]
|
|
if v < axisMin and lower <= axisMin:
|
|
if peak <= axisMin and peak < upper:
|
|
scalar *= (v - upper) / (peak - upper)
|
|
continue
|
|
elif axisMin < peak:
|
|
scalar *= (v - lower) / (peak - lower)
|
|
continue
|
|
elif axisMax < v and axisMax <= upper:
|
|
if axisMax <= peak and lower < peak:
|
|
scalar *= (v - lower) / (peak - lower)
|
|
continue
|
|
elif peak < axisMax:
|
|
scalar *= (v - upper) / (peak - upper)
|
|
continue
|
|
|
|
if v <= lower or upper <= v:
|
|
scalar = 0.0
|
|
break
|
|
|
|
if v < peak:
|
|
scalar *= (v - lower) / (peak - lower)
|
|
else: # v > peak
|
|
scalar *= (v - upper) / (peak - upper)
|
|
return scalar
|
|
|
|
|
|
class VariationModel(object):
|
|
"""Locations must have the base master at the origin (ie. 0).
|
|
|
|
If the extrapolate argument is set to True, then values are extrapolated
|
|
outside the axis range.
|
|
|
|
>>> from pprint import pprint
|
|
>>> locations = [ \
|
|
{'wght':100}, \
|
|
{'wght':-100}, \
|
|
{'wght':-180}, \
|
|
{'wdth':+.3}, \
|
|
{'wght':+120,'wdth':.3}, \
|
|
{'wght':+120,'wdth':.2}, \
|
|
{}, \
|
|
{'wght':+180,'wdth':.3}, \
|
|
{'wght':+180}, \
|
|
]
|
|
>>> model = VariationModel(locations, axisOrder=['wght'])
|
|
>>> pprint(model.locations)
|
|
[{},
|
|
{'wght': -100},
|
|
{'wght': -180},
|
|
{'wght': 100},
|
|
{'wght': 180},
|
|
{'wdth': 0.3},
|
|
{'wdth': 0.3, 'wght': 180},
|
|
{'wdth': 0.3, 'wght': 120},
|
|
{'wdth': 0.2, 'wght': 120}]
|
|
>>> pprint(model.deltaWeights)
|
|
[{},
|
|
{0: 1.0},
|
|
{0: 1.0},
|
|
{0: 1.0},
|
|
{0: 1.0},
|
|
{0: 1.0},
|
|
{0: 1.0, 4: 1.0, 5: 1.0},
|
|
{0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
|
|
{0: 1.0,
|
|
3: 0.75,
|
|
4: 0.25,
|
|
5: 0.6666666666666667,
|
|
6: 0.4444444444444445,
|
|
7: 0.6666666666666667}]
|
|
"""
|
|
|
|
def __init__(self, locations, axisOrder=None, extrapolate=False):
|
|
if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
|
|
raise VariationModelError("Locations must be unique.")
|
|
|
|
self.origLocations = locations
|
|
self.axisOrder = axisOrder if axisOrder is not None else []
|
|
self.extrapolate = extrapolate
|
|
self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None
|
|
|
|
locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
|
|
keyFunc = self.getMasterLocationsSortKeyFunc(
|
|
locations, axisOrder=self.axisOrder
|
|
)
|
|
self.locations = sorted(locations, key=keyFunc)
|
|
|
|
# Mapping from user's master order to our master order
|
|
self.mapping = [self.locations.index(l) for l in locations]
|
|
self.reverseMapping = [locations.index(l) for l in self.locations]
|
|
|
|
self._computeMasterSupports()
|
|
self._subModels = {}
|
|
|
|
def getSubModel(self, items):
|
|
"""Return a sub-model and the items that are not None.
|
|
|
|
The sub-model is necessary for working with the subset
|
|
of items when some are None.
|
|
|
|
The sub-model is cached."""
|
|
if None not in items:
|
|
return self, items
|
|
key = tuple(v is not None for v in items)
|
|
subModel = self._subModels.get(key)
|
|
if subModel is None:
|
|
subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
|
|
self._subModels[key] = subModel
|
|
return subModel, subList(key, items)
|
|
|
|
@staticmethod
|
|
def computeAxisRanges(locations):
|
|
axisRanges = {}
|
|
allAxes = {axis for loc in locations for axis in loc.keys()}
|
|
for loc in locations:
|
|
for axis in allAxes:
|
|
value = loc.get(axis, 0)
|
|
axisMin, axisMax = axisRanges.get(axis, (value, value))
|
|
axisRanges[axis] = min(value, axisMin), max(value, axisMax)
|
|
return axisRanges
|
|
|
|
@staticmethod
|
|
def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
|
|
if {} not in locations:
|
|
raise VariationModelError("Base master not found.")
|
|
axisPoints = {}
|
|
for loc in locations:
|
|
if len(loc) != 1:
|
|
continue
|
|
axis = next(iter(loc))
|
|
value = loc[axis]
|
|
if axis not in axisPoints:
|
|
axisPoints[axis] = {0.0}
|
|
assert (
|
|
value not in axisPoints[axis]
|
|
), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
|
|
axisPoints[axis].add(value)
|
|
|
|
def getKey(axisPoints, axisOrder):
|
|
def sign(v):
|
|
return -1 if v < 0 else +1 if v > 0 else 0
|
|
|
|
def key(loc):
|
|
rank = len(loc)
|
|
onPointAxes = [
|
|
axis
|
|
for axis, value in loc.items()
|
|
if axis in axisPoints and value in axisPoints[axis]
|
|
]
|
|
orderedAxes = [axis for axis in axisOrder if axis in loc]
|
|
orderedAxes.extend(
|
|
[axis for axis in sorted(loc.keys()) if axis not in axisOrder]
|
|
)
|
|
return (
|
|
rank, # First, order by increasing rank
|
|
-len(onPointAxes), # Next, by decreasing number of onPoint axes
|
|
tuple(
|
|
axisOrder.index(axis) if axis in axisOrder else 0x10000
|
|
for axis in orderedAxes
|
|
), # Next, by known axes
|
|
tuple(orderedAxes), # Next, by all axes
|
|
tuple(
|
|
sign(loc[axis]) for axis in orderedAxes
|
|
), # Next, by signs of axis values
|
|
tuple(
|
|
abs(loc[axis]) for axis in orderedAxes
|
|
), # Next, by absolute value of axis values
|
|
)
|
|
|
|
return key
|
|
|
|
ret = getKey(axisPoints, axisOrder)
|
|
return ret
|
|
|
|
def reorderMasters(self, master_list, mapping):
|
|
# For changing the master data order without
|
|
# recomputing supports and deltaWeights.
|
|
new_list = [master_list[idx] for idx in mapping]
|
|
self.origLocations = [self.origLocations[idx] for idx in mapping]
|
|
locations = [
|
|
{k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
|
|
]
|
|
self.mapping = [self.locations.index(l) for l in locations]
|
|
self.reverseMapping = [locations.index(l) for l in self.locations]
|
|
self._subModels = {}
|
|
return new_list
|
|
|
|
def _computeMasterSupports(self):
|
|
self.supports = []
|
|
regions = self._locationsToRegions()
|
|
for i, region in enumerate(regions):
|
|
locAxes = set(region.keys())
|
|
# Walk over previous masters now
|
|
for prev_region in regions[:i]:
|
|
# Master with extra axes do not participte
|
|
if set(prev_region.keys()) != locAxes:
|
|
continue
|
|
# If it's NOT in the current box, it does not participate
|
|
relevant = True
|
|
for axis, (lower, peak, upper) in region.items():
|
|
if not (
|
|
prev_region[axis][1] == peak
|
|
or lower < prev_region[axis][1] < upper
|
|
):
|
|
relevant = False
|
|
break
|
|
if not relevant:
|
|
continue
|
|
|
|
# Split the box for new master; split in whatever direction
|
|
# that has largest range ratio.
|
|
#
|
|
# For symmetry, we actually cut across multiple axes
|
|
# if they have the largest, equal, ratio.
|
|
# https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
|
|
|
|
bestAxes = {}
|
|
bestRatio = -1
|
|
for axis in prev_region.keys():
|
|
val = prev_region[axis][1]
|
|
assert axis in region
|
|
lower, locV, upper = region[axis]
|
|
newLower, newUpper = lower, upper
|
|
if val < locV:
|
|
newLower = val
|
|
ratio = (val - locV) / (lower - locV)
|
|
elif locV < val:
|
|
newUpper = val
|
|
ratio = (val - locV) / (upper - locV)
|
|
else: # val == locV
|
|
# Can't split box in this direction.
|
|
continue
|
|
if ratio > bestRatio:
|
|
bestAxes = {}
|
|
bestRatio = ratio
|
|
if ratio == bestRatio:
|
|
bestAxes[axis] = (newLower, locV, newUpper)
|
|
|
|
for axis, triple in bestAxes.items():
|
|
region[axis] = triple
|
|
self.supports.append(region)
|
|
self._computeDeltaWeights()
|
|
|
|
def _locationsToRegions(self):
|
|
locations = self.locations
|
|
# Compute min/max across each axis, use it as total range.
|
|
# TODO Take this as input from outside?
|
|
minV = {}
|
|
maxV = {}
|
|
for l in locations:
|
|
for k, v in l.items():
|
|
minV[k] = min(v, minV.get(k, v))
|
|
maxV[k] = max(v, maxV.get(k, v))
|
|
|
|
regions = []
|
|
for loc in locations:
|
|
region = {}
|
|
for axis, locV in loc.items():
|
|
if locV > 0:
|
|
region[axis] = (0, locV, maxV[axis])
|
|
else:
|
|
region[axis] = (minV[axis], locV, 0)
|
|
regions.append(region)
|
|
return regions
|
|
|
|
def _computeDeltaWeights(self):
|
|
self.deltaWeights = []
|
|
for i, loc in enumerate(self.locations):
|
|
deltaWeight = {}
|
|
# Walk over previous masters now, populate deltaWeight
|
|
for j, support in enumerate(self.supports[:i]):
|
|
scalar = supportScalar(loc, support)
|
|
if scalar:
|
|
deltaWeight[j] = scalar
|
|
self.deltaWeights.append(deltaWeight)
|
|
|
|
def getDeltas(self, masterValues, *, round=noRound):
|
|
assert len(masterValues) == len(self.deltaWeights)
|
|
mapping = self.reverseMapping
|
|
out = []
|
|
for i, weights in enumerate(self.deltaWeights):
|
|
delta = masterValues[mapping[i]]
|
|
for j, weight in weights.items():
|
|
if weight == 1:
|
|
delta -= out[j]
|
|
else:
|
|
delta -= out[j] * weight
|
|
out.append(round(delta))
|
|
return out
|
|
|
|
def getDeltasAndSupports(self, items, *, round=noRound):
|
|
model, items = self.getSubModel(items)
|
|
return model.getDeltas(items, round=round), model.supports
|
|
|
|
def getScalars(self, loc):
|
|
"""Return scalars for each delta, for the given location.
|
|
If interpolating many master-values at the same location,
|
|
this function allows speed up by fetching the scalars once
|
|
and using them with interpolateFromMastersAndScalars()."""
|
|
return [
|
|
supportScalar(
|
|
loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
|
|
)
|
|
for support in self.supports
|
|
]
|
|
|
|
def getMasterScalars(self, targetLocation):
|
|
"""Return multipliers for each master, for the given location.
|
|
If interpolating many master-values at the same location,
|
|
this function allows speed up by fetching the scalars once
|
|
and using them with interpolateFromValuesAndScalars().
|
|
|
|
Note that the scalars used in interpolateFromMastersAndScalars(),
|
|
are *not* the same as the ones returned here. They are the result
|
|
of getScalars()."""
|
|
out = self.getScalars(targetLocation)
|
|
for i, weights in reversed(list(enumerate(self.deltaWeights))):
|
|
for j, weight in weights.items():
|
|
out[j] -= out[i] * weight
|
|
|
|
out = [out[self.mapping[i]] for i in range(len(out))]
|
|
return out
|
|
|
|
@staticmethod
|
|
def interpolateFromValuesAndScalars(values, scalars):
|
|
"""Interpolate from values and scalars coefficients.
|
|
|
|
If the values are master-values, then the scalars should be
|
|
fetched from getMasterScalars().
|
|
|
|
If the values are deltas, then the scalars should be fetched
|
|
from getScalars(); in which case this is the same as
|
|
interpolateFromDeltasAndScalars().
|
|
"""
|
|
v = None
|
|
assert len(values) == len(scalars)
|
|
for value, scalar in zip(values, scalars):
|
|
if not scalar:
|
|
continue
|
|
contribution = value * scalar
|
|
if v is None:
|
|
v = contribution
|
|
else:
|
|
v += contribution
|
|
return v
|
|
|
|
@staticmethod
|
|
def interpolateFromDeltasAndScalars(deltas, scalars):
|
|
"""Interpolate from deltas and scalars fetched from getScalars()."""
|
|
return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
|
|
|
|
def interpolateFromDeltas(self, loc, deltas):
|
|
"""Interpolate from deltas, at location loc."""
|
|
scalars = self.getScalars(loc)
|
|
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
|
|
|
def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
|
|
"""Interpolate from master-values, at location loc."""
|
|
scalars = self.getMasterScalars(loc)
|
|
return self.interpolateFromValuesAndScalars(masterValues, scalars)
|
|
|
|
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
|
|
"""Interpolate from master-values, and scalars fetched from
|
|
getScalars(), which is useful when you want to interpolate
|
|
multiple master-values with the same location."""
|
|
deltas = self.getDeltas(masterValues, round=round)
|
|
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
|
|
|
|
|
def piecewiseLinearMap(v, mapping):
|
|
keys = mapping.keys()
|
|
if not keys:
|
|
return v
|
|
if v in keys:
|
|
return mapping[v]
|
|
k = min(keys)
|
|
if v < k:
|
|
return v + mapping[k] - k
|
|
k = max(keys)
|
|
if v > k:
|
|
return v + mapping[k] - k
|
|
# Interpolate
|
|
a = max(k for k in keys if k < v)
|
|
b = min(k for k in keys if k > v)
|
|
va = mapping[a]
|
|
vb = mapping[b]
|
|
return va + (vb - va) * (v - a) / (b - a)
|
|
|
|
|
|
def main(args=None):
|
|
"""Normalize locations on a given designspace"""
|
|
from fontTools import configLogger
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
"fonttools varLib.models",
|
|
description=main.__doc__,
|
|
)
|
|
parser.add_argument(
|
|
"--loglevel",
|
|
metavar="LEVEL",
|
|
default="INFO",
|
|
help="Logging level (defaults to INFO)",
|
|
)
|
|
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
|
|
group.add_argument(
|
|
"-l",
|
|
"--locations",
|
|
metavar="LOCATION",
|
|
nargs="+",
|
|
help="Master locations as comma-separate coordinates. One must be all zeros.",
|
|
)
|
|
|
|
args = parser.parse_args(args)
|
|
|
|
configLogger(level=args.loglevel)
|
|
from pprint import pprint
|
|
|
|
if args.designspace:
|
|
from fontTools.designspaceLib import DesignSpaceDocument
|
|
|
|
doc = DesignSpaceDocument()
|
|
doc.read(args.designspace)
|
|
locs = [s.location for s in doc.sources]
|
|
print("Original locations:")
|
|
pprint(locs)
|
|
doc.normalize()
|
|
print("Normalized locations:")
|
|
locs = [s.location for s in doc.sources]
|
|
pprint(locs)
|
|
else:
|
|
axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
|
locs = [
|
|
dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
|
|
]
|
|
|
|
model = VariationModel(locs)
|
|
print("Sorted locations:")
|
|
pprint(model.locations)
|
|
print("Supports:")
|
|
pprint(model.supports)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import doctest, sys
|
|
|
|
if len(sys.argv) > 1:
|
|
sys.exit(main())
|
|
|
|
sys.exit(doctest.testmod().failed)
|