Merge branch 'fonttools:main' into main
This commit is contained in:
commit
08e3c74911
@ -1258,9 +1258,15 @@ class MultipleSubstStatement(Statement):
|
||||
"""Calls the builder object's ``add_multiple_subst`` callback."""
|
||||
prefix = [p.glyphSet() for p in self.prefix]
|
||||
suffix = [s.glyphSet() for s in self.suffix]
|
||||
builder.add_multiple_subst(
|
||||
self.location, prefix, self.glyph, suffix, self.replacement, self.forceChain
|
||||
)
|
||||
if not self.replacement and hasattr(self.glyph, "glyphSet"):
|
||||
for glyph in self.glyph.glyphSet():
|
||||
builder.add_multiple_subst(
|
||||
self.location, prefix, glyph, suffix, self.replacement, self.forceChain
|
||||
)
|
||||
else:
|
||||
builder.add_multiple_subst(
|
||||
self.location, prefix, self.glyph, suffix, self.replacement, self.forceChain
|
||||
)
|
||||
|
||||
def asFea(self, indent=""):
|
||||
res = "sub "
|
||||
|
@ -892,16 +892,26 @@ class Parser(object):
|
||||
old, new, old_prefix, old_suffix, forceChain=hasMarks, location=location
|
||||
)
|
||||
|
||||
# Glyph deletion, built as GSUB lookup type 2: Multiple substitution
|
||||
# with empty replacement.
|
||||
if is_deletion and len(old) == 1 and num_lookups == 0:
|
||||
return self.ast.MultipleSubstStatement(
|
||||
old_prefix,
|
||||
old[0],
|
||||
old_suffix,
|
||||
(),
|
||||
forceChain=hasMarks,
|
||||
location=location,
|
||||
)
|
||||
|
||||
# GSUB lookup type 2: Multiple substitution.
|
||||
# Format: "substitute f_f_i by f f i;"
|
||||
if (
|
||||
not reverse
|
||||
and len(old) == 1
|
||||
and len(old[0].glyphSet()) == 1
|
||||
and (
|
||||
(len(new) > 1 and max([len(n.glyphSet()) for n in new]) == 1)
|
||||
or len(new) == 0
|
||||
)
|
||||
and len(new) > 1
|
||||
and max([len(n.glyphSet()) for n in new]) == 1
|
||||
and num_lookups == 0
|
||||
):
|
||||
return self.ast.MultipleSubstStatement(
|
||||
|
@ -5,8 +5,9 @@ Requires https://github.com/fonttools/skia-pathops
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from typing import Iterable, Optional, Mapping
|
||||
from typing import Callable, Iterable, Optional, Mapping
|
||||
|
||||
from fontTools.misc.roundTools import otRound
|
||||
from fontTools.ttLib import ttFont
|
||||
from fontTools.ttLib.tables import _g_l_y_f
|
||||
from fontTools.ttLib.tables import _h_m_t_x
|
||||
@ -18,6 +19,10 @@ import pathops
|
||||
__all__ = ["removeOverlaps"]
|
||||
|
||||
|
||||
class RemoveOverlapsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
log = logging.getLogger("fontTools.ttLib.removeOverlaps")
|
||||
|
||||
_TTGlyphMapping = Mapping[str, ttFont._TTGlyph]
|
||||
@ -76,6 +81,48 @@ def ttfGlyphFromSkPath(path: pathops.Path) -> _g_l_y_f.Glyph:
|
||||
return glyph
|
||||
|
||||
|
||||
def _round_path(
|
||||
path: pathops.Path, round: Callable[[float], float] = otRound
|
||||
) -> pathops.Path:
|
||||
rounded_path = pathops.Path()
|
||||
for verb, points in path:
|
||||
rounded_path.add(verb, *((round(p[0]), round(p[1])) for p in points))
|
||||
return rounded_path
|
||||
|
||||
|
||||
def _simplify(path: pathops.Path, debugGlyphName: str) -> pathops.Path:
|
||||
# skia-pathops has a bug where it sometimes fails to simplify paths when there
|
||||
# are float coordinates and control points are very close to one another.
|
||||
# Rounding coordinates to integers works around the bug.
|
||||
# Since we are going to round glyf coordinates later on anyway, here it is
|
||||
# ok(-ish) to also round before simplify. Better than failing the whole process
|
||||
# for the entire font.
|
||||
# https://bugs.chromium.org/p/skia/issues/detail?id=11958
|
||||
# https://github.com/google/fonts/issues/3365
|
||||
# TODO(anthrotype): remove once this Skia bug is fixed
|
||||
try:
|
||||
return pathops.simplify(path, clockwise=path.clockwise)
|
||||
except pathops.PathOpsError:
|
||||
pass
|
||||
|
||||
path = _round_path(path)
|
||||
try:
|
||||
path = pathops.simplify(path, clockwise=path.clockwise)
|
||||
log.debug(
|
||||
"skia-pathops failed to simplify '%s' with float coordinates, "
|
||||
"but succeded using rounded integer coordinates",
|
||||
debugGlyphName,
|
||||
)
|
||||
return path
|
||||
except pathops.PathOpsError as e:
|
||||
path.dump()
|
||||
raise RemoveOverlapsError(
|
||||
f"Failed to remove overlaps from glyph {debugGlyphName!r}"
|
||||
) from e
|
||||
|
||||
raise AssertionError("Unreachable")
|
||||
|
||||
|
||||
def removeTTGlyphOverlaps(
|
||||
glyphName: str,
|
||||
glyphSet: _TTGlyphMapping,
|
||||
@ -93,7 +140,7 @@ def removeTTGlyphOverlaps(
|
||||
path = skPathFromGlyph(glyphName, glyphSet)
|
||||
|
||||
# remove overlaps
|
||||
path2 = pathops.simplify(path, clockwise=path.clockwise)
|
||||
path2 = _simplify(path, glyphName)
|
||||
|
||||
# replace TTGlyph if simplified path is different (ignoring contour order)
|
||||
if {tuple(c) for c in path.contours} != {tuple(c) for c in path2.contours}:
|
||||
|
@ -25,6 +25,7 @@ import logging
|
||||
import os
|
||||
from fontTools.misc import xmlWriter
|
||||
from fontTools.misc.filenames import userNameToFileName
|
||||
from fontTools.misc.loggingTools import deprecateFunction
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -412,9 +413,13 @@ class table__g_l_y_f(DefaultTable.DefaultTable):
|
||||
topSideY = defaultVerticalOrigin
|
||||
else:
|
||||
topSideY = verticalAdvanceWidth
|
||||
glyph = self[glyphName]
|
||||
glyph.recalcBounds(self)
|
||||
topSideBearing = otRound(topSideY - glyph.yMax)
|
||||
vMetrics = {glyphName: (verticalAdvanceWidth, topSideBearing)}
|
||||
return vMetrics
|
||||
|
||||
@deprecateFunction("use '_getPhantomPoints' instead", category=DeprecationWarning)
|
||||
def getPhantomPoints(self, glyphName, ttFont, defaultVerticalOrigin=None):
|
||||
"""Old public name for self._getPhantomPoints().
|
||||
See: https://github.com/fonttools/fonttools/pull/2266"""
|
||||
@ -422,6 +427,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable):
|
||||
vMetrics = self._synthesizeVMetrics(glyphName, ttFont, defaultVerticalOrigin)
|
||||
return self._getPhantomPoints(glyphName, hMetrics, vMetrics)
|
||||
|
||||
@deprecateFunction("use '_getCoordinatesAndControls' instead", category=DeprecationWarning)
|
||||
def getCoordinatesAndControls(self, glyphName, ttFont, defaultVerticalOrigin=None):
|
||||
"""Old public name for self._getCoordinatesAndControls().
|
||||
See: https://github.com/fonttools/fonttools/pull/2266"""
|
||||
@ -429,6 +435,7 @@ class table__g_l_y_f(DefaultTable.DefaultTable):
|
||||
vMetrics = self._synthesizeVMetrics(glyphName, ttFont, defaultVerticalOrigin)
|
||||
return self._getCoordinatesAndControls(glyphName, hMetrics, vMetrics)
|
||||
|
||||
@deprecateFunction("use '_setCoordinates' instead", category=DeprecationWarning)
|
||||
def setCoordinates(self, glyphName, ttFont):
|
||||
"""Old public name for self._setCoordinates().
|
||||
See: https://github.com/fonttools/fonttools/pull/2266"""
|
||||
|
@ -1,160 +1,174 @@
|
||||
"""Variation fonts interpolation models."""
|
||||
|
||||
__all__ = ['nonNone', 'allNone', 'allEqual', 'allEqualTo', 'subList',
|
||||
'normalizeValue', 'normalizeLocation',
|
||||
'supportScalar',
|
||||
'VariationModel']
|
||||
__all__ = [
|
||||
"nonNone",
|
||||
"allNone",
|
||||
"allEqual",
|
||||
"allEqualTo",
|
||||
"subList",
|
||||
"normalizeValue",
|
||||
"normalizeLocation",
|
||||
"supportScalar",
|
||||
"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]
|
||||
return [l for l in lst if l is not None]
|
||||
|
||||
|
||||
def allNone(lst):
|
||||
return all(l is None for l in 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)
|
||||
else:
|
||||
mapped = mapper(ref)
|
||||
return all(mapped == mapper(item) for item in lst)
|
||||
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)
|
||||
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]
|
||||
assert len(truth) == len(lst)
|
||||
return [l for l, t in zip(lst, truth) if t]
|
||||
|
||||
|
||||
def normalizeValue(v, triple):
|
||||
"""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}"
|
||||
)
|
||||
v = max(min(v, upper), lower)
|
||||
if v == default:
|
||||
v = 0.
|
||||
elif v < default:
|
||||
v = (v - default) / (default - lower)
|
||||
else:
|
||||
v = (v - default) / (upper - default)
|
||||
return v
|
||||
"""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}"
|
||||
)
|
||||
v = max(min(v, upper), lower)
|
||||
if v == default:
|
||||
v = 0.0
|
||||
elif v < default:
|
||||
v = (v - default) / (default - lower)
|
||||
else:
|
||||
v = (v - default) / (upper - default)
|
||||
return v
|
||||
|
||||
|
||||
def normalizeLocation(location, axes):
|
||||
"""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)
|
||||
return out
|
||||
"""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)
|
||||
return out
|
||||
|
||||
|
||||
def supportScalar(location, support, ot=True):
|
||||
"""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.
|
||||
>>> 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
|
||||
"""
|
||||
scalar = 1.
|
||||
for axis,(lower,peak,upper) in support.items():
|
||||
if ot:
|
||||
# OpenType-specific case handling
|
||||
if peak == 0.:
|
||||
continue
|
||||
if lower > peak or peak > upper:
|
||||
continue
|
||||
if lower < 0. and upper > 0.:
|
||||
continue
|
||||
v = location.get(axis, 0.)
|
||||
else:
|
||||
assert axis in location
|
||||
v = location[axis]
|
||||
if v == peak:
|
||||
continue
|
||||
if v <= lower or upper <= v:
|
||||
scalar = 0.
|
||||
break
|
||||
if v < peak:
|
||||
scalar *= (v - lower) / (peak - lower)
|
||||
else: # v > peak
|
||||
scalar *= (v - upper) / (peak - upper)
|
||||
return scalar
|
||||
"""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.
|
||||
>>> 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
|
||||
"""
|
||||
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 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 be in normalized space. Ie. base master
|
||||
is at origin (0).
|
||||
>>> from pprint import pprint
|
||||
@ -197,291 +211,319 @@ class VariationModel(object):
|
||||
7: 0.6666666666666667}]
|
||||
"""
|
||||
|
||||
def __init__(self, locations, axisOrder=None):
|
||||
if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
|
||||
raise VariationModelError("Locations must be unique.")
|
||||
def __init__(self, locations, axisOrder=None):
|
||||
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.origLocations = locations
|
||||
self.axisOrder = axisOrder if axisOrder is not None else []
|
||||
|
||||
locations = [{k:v for k,v in loc.items() if v != 0.} for loc in locations]
|
||||
keyFunc = self.getMasterLocationsSortKeyFunc(locations, axisOrder=self.axisOrder)
|
||||
self.locations = sorted(locations, key=keyFunc)
|
||||
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]
|
||||
# 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(keyFunc.axisPoints)
|
||||
self._subModels = {}
|
||||
self._computeMasterSupports()
|
||||
self._subModels = {}
|
||||
|
||||
def getSubModel(self, items):
|
||||
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)
|
||||
def getSubModel(self, items):
|
||||
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 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.}
|
||||
assert value not in axisPoints[axis], (
|
||||
'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
|
||||
)
|
||||
axisPoints[axis].add(value)
|
||||
@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
|
||||
def getKey(axisPoints, axisOrder):
|
||||
def sign(v):
|
||||
return -1 if v < 0 else +1 if v > 0 else 0
|
||||
|
||||
ret = getKey(axisPoints, axisOrder)
|
||||
ret.axisPoints = axisPoints
|
||||
return ret
|
||||
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
|
||||
)
|
||||
|
||||
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.}
|
||||
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
|
||||
return key
|
||||
|
||||
def _computeMasterSupports(self, axisPoints):
|
||||
supports = []
|
||||
regions = self._locationsToRegions()
|
||||
for i,region in enumerate(regions):
|
||||
locAxes = set(region.keys())
|
||||
# Walk over previous masters now
|
||||
for j,prev_region in enumerate(regions[:i]):
|
||||
# Master with extra axes do not participte
|
||||
if not set(prev_region.keys()).issubset(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 axis not in prev_region or not (prev_region[axis][1] == peak or lower < prev_region[axis][1] < upper):
|
||||
relevant = False
|
||||
break
|
||||
if not relevant:
|
||||
continue
|
||||
ret = getKey(axisPoints, axisOrder)
|
||||
return ret
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
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)
|
||||
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 not set(prev_region.keys()).issubset(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 axis not in prev_region or not (
|
||||
prev_region[axis][1] == peak
|
||||
or lower < prev_region[axis][1] < upper
|
||||
):
|
||||
relevant = False
|
||||
break
|
||||
if not relevant:
|
||||
continue
|
||||
|
||||
for axis,triple in bestAxes.items ():
|
||||
region[axis] = triple
|
||||
supports.append(region)
|
||||
self.supports = supports
|
||||
self._computeDeltaWeights()
|
||||
# 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
|
||||
|
||||
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))
|
||||
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)
|
||||
|
||||
regions = []
|
||||
for i,loc in enumerate(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
|
||||
for axis, triple in bestAxes.items():
|
||||
region[axis] = triple
|
||||
self.supports.append(region)
|
||||
self._computeDeltaWeights()
|
||||
|
||||
def _computeDeltaWeights(self):
|
||||
deltaWeights = []
|
||||
for i,loc in enumerate(self.locations):
|
||||
deltaWeight = {}
|
||||
# Walk over previous masters now, populate deltaWeight
|
||||
for j,m in enumerate(self.locations[:i]):
|
||||
scalar = supportScalar(loc, self.supports[j])
|
||||
if scalar:
|
||||
deltaWeight[j] = scalar
|
||||
deltaWeights.append(deltaWeight)
|
||||
self.deltaWeights = deltaWeights
|
||||
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))
|
||||
|
||||
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
|
||||
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 getDeltasAndSupports(self, items, *, round=noRound):
|
||||
model, items = self.getSubModel(items)
|
||||
return model.getDeltas(items, round=round), model.supports
|
||||
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 getScalars(self, loc):
|
||||
return [supportScalar(loc, support) for support in self.supports]
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def interpolateFromDeltasAndScalars(deltas, scalars):
|
||||
v = None
|
||||
assert len(deltas) == len(scalars)
|
||||
for delta, scalar in zip(deltas, scalars):
|
||||
if not scalar: continue
|
||||
contribution = delta * scalar
|
||||
if v is None:
|
||||
v = contribution
|
||||
else:
|
||||
v += contribution
|
||||
return v
|
||||
def getDeltasAndSupports(self, items, *, round=noRound):
|
||||
model, items = self.getSubModel(items)
|
||||
return model.getDeltas(items, round=round), model.supports
|
||||
|
||||
def interpolateFromDeltas(self, loc, deltas):
|
||||
scalars = self.getScalars(loc)
|
||||
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
||||
def getScalars(self, loc):
|
||||
return [supportScalar(loc, support) for support in self.supports]
|
||||
|
||||
def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
|
||||
deltas = self.getDeltas(masterValues, round=round)
|
||||
return self.interpolateFromDeltas(loc, deltas)
|
||||
@staticmethod
|
||||
def interpolateFromDeltasAndScalars(deltas, scalars):
|
||||
v = None
|
||||
assert len(deltas) == len(scalars)
|
||||
for delta, scalar in zip(deltas, scalars):
|
||||
if not scalar:
|
||||
continue
|
||||
contribution = delta * scalar
|
||||
if v is None:
|
||||
v = contribution
|
||||
else:
|
||||
v += contribution
|
||||
return v
|
||||
|
||||
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
|
||||
deltas = self.getDeltas(masterValues, round=round)
|
||||
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
||||
def interpolateFromDeltas(self, loc, deltas):
|
||||
scalars = self.getScalars(loc)
|
||||
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
||||
|
||||
def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
|
||||
deltas = self.getDeltas(masterValues, round=round)
|
||||
return self.interpolateFromDeltas(loc, deltas)
|
||||
|
||||
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
|
||||
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)
|
||||
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
|
||||
"""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)")
|
||||
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.")
|
||||
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)
|
||||
args = parser.parse_args(args)
|
||||
|
||||
configLogger(level=args.loglevel)
|
||||
from pprint import pprint
|
||||
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]
|
||||
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)
|
||||
|
||||
model = VariationModel(locs)
|
||||
print("Sorted locations:")
|
||||
pprint(model.locations)
|
||||
print("Supports:")
|
||||
pprint(model.supports)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest, sys
|
||||
import doctest, sys
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
sys.exit(main())
|
||||
if len(sys.argv) > 1:
|
||||
sys.exit(main())
|
||||
|
||||
sys.exit(doctest.testmod().failed)
|
||||
sys.exit(doctest.testmod().failed)
|
||||
|
13
NEWS.rst
13
NEWS.rst
@ -1,3 +1,16 @@
|
||||
- [removeOverlaps] Retry pathops.simplify after rounding path coordinates to integers
|
||||
if it fails the first time using floats, to work around a rare and hard to debug
|
||||
Skia bug (#2288).
|
||||
- [varLib] Added support for building, reading, writing and optimizing 32-bit
|
||||
``ItemVariationStore`` as used in COLRv1 table (#2285).
|
||||
- [otBase/otConverters] Add array readers/writers for int types (#2285).
|
||||
- [feaLib] Allow more than one lookahead glyph/class in contextual positioning with
|
||||
"value at end" (#2293, #2294).
|
||||
- [COLRv1] Default varIdx should be 0xFFFFFFFF (#2297, #2298).
|
||||
- [pens] Make RecordingPointPen actually pass on identifiers; replace asserts with
|
||||
explicit ``PenError`` exception (#2284).
|
||||
- [mutator] Round lsb for CF2 fonts as well (#2286).
|
||||
|
||||
4.22.1 (released 2021-04-26)
|
||||
----------------------------
|
||||
|
||||
|
@ -1,3 +1,7 @@
|
||||
feature test {
|
||||
sub a by NULL;
|
||||
} test;
|
||||
|
||||
feature test {
|
||||
sub [a b c] by NULL;
|
||||
} test;
|
||||
|
@ -22,13 +22,14 @@
|
||||
<FeatureRecord index="0">
|
||||
<FeatureTag value="test"/>
|
||||
<Feature>
|
||||
<!-- LookupCount=1 -->
|
||||
<!-- LookupCount=2 -->
|
||||
<LookupListIndex index="0" value="0"/>
|
||||
<LookupListIndex index="1" value="1"/>
|
||||
</Feature>
|
||||
</FeatureRecord>
|
||||
</FeatureList>
|
||||
<LookupList>
|
||||
<!-- LookupCount=1 -->
|
||||
<!-- LookupCount=2 -->
|
||||
<Lookup index="0">
|
||||
<LookupType value="2"/>
|
||||
<LookupFlag value="0"/>
|
||||
@ -37,6 +38,16 @@
|
||||
<Substitution in="a" out=""/>
|
||||
</MultipleSubst>
|
||||
</Lookup>
|
||||
<Lookup index="1">
|
||||
<LookupType value="2"/>
|
||||
<LookupFlag value="0"/>
|
||||
<!-- SubTableCount=1 -->
|
||||
<MultipleSubst index="0">
|
||||
<Substitution in="a" out=""/>
|
||||
<Substitution in="b" out=""/>
|
||||
<Substitution in="c" out=""/>
|
||||
</MultipleSubst>
|
||||
</Lookup>
|
||||
</LookupList>
|
||||
</GSUB>
|
||||
|
||||
|
51
Tests/ttLib/removeOverlaps_test.py
Normal file
51
Tests/ttLib/removeOverlaps_test.py
Normal file
@ -0,0 +1,51 @@
|
||||
import logging
|
||||
import pytest
|
||||
|
||||
pathops = pytest.importorskip("pathops")
|
||||
|
||||
from fontTools.ttLib.removeOverlaps import _simplify, _round_path
|
||||
|
||||
|
||||
def test_pathops_simplify_bug_workaround(caplog):
|
||||
# Paths extracted from Noto Sans Ethiopic instance that fails skia-pathops
|
||||
# https://github.com/google/fonts/issues/3365
|
||||
# https://bugs.chromium.org/p/skia/issues/detail?id=11958
|
||||
path = pathops.Path()
|
||||
path.moveTo(550.461, 0)
|
||||
path.lineTo(550.461, 366.308)
|
||||
path.lineTo(713.229, 366.308)
|
||||
path.lineTo(713.229, 0)
|
||||
path.close()
|
||||
path.moveTo(574.46, 0)
|
||||
path.lineTo(574.46, 276.231)
|
||||
path.lineTo(737.768, 276.231)
|
||||
path.quadTo(820.075, 276.231, 859.806, 242.654)
|
||||
path.quadTo(899.537, 209.077, 899.537, 144.154)
|
||||
path.quadTo(899.537, 79, 853.46, 39.5)
|
||||
path.quadTo(807.383, 0, 712.383, 0)
|
||||
path.close()
|
||||
|
||||
# check that it fails without workaround
|
||||
with pytest.raises(pathops.PathOpsError):
|
||||
pathops.simplify(path)
|
||||
|
||||
# check our workaround works (but with a warning)
|
||||
with caplog.at_level(logging.DEBUG, logger="fontTools.ttLib.removeOverlaps"):
|
||||
result = _simplify(path, debugGlyphName="a")
|
||||
|
||||
assert "skia-pathops failed to simplify 'a' with float coordinates" in caplog.text
|
||||
|
||||
expected = pathops.Path()
|
||||
expected.moveTo(550, 0)
|
||||
expected.lineTo(550, 366)
|
||||
expected.lineTo(713, 366)
|
||||
expected.lineTo(713, 276)
|
||||
expected.lineTo(738, 276)
|
||||
expected.quadTo(820, 276, 860, 243)
|
||||
expected.quadTo(900, 209, 900, 144)
|
||||
expected.quadTo(900, 79, 853, 40)
|
||||
expected.quadTo(807.242, 0.211, 713, 0.001)
|
||||
expected.lineTo(713, 0)
|
||||
expected.close()
|
||||
|
||||
assert expected == _round_path(result, round=lambda v: round(v, 3))
|
Loading…
x
Reference in New Issue
Block a user