Merge pull request #2148 from fonttools/colr-fix-c0-almost-inside-c1

COLRv1: avoid abrupt change after rounding c0 when too near c1's perimeter
This commit is contained in:
Cosimo Lupo 2021-01-18 09:59:39 +00:00 committed by GitHub
commit a8d366e3b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 216 additions and 6 deletions

View File

@ -34,6 +34,7 @@ from fontTools.ttLib.tables.otTables import (
VariableInt,
)
from .errors import ColorLibError
from .geometry import round_start_circle_stable_containment
# TODO move type aliases to colorLib.types?
@ -328,9 +329,9 @@ def _split_color_glyphs_by_version(
def _to_variable_value(
value: _ScalarInput,
minValue: _Number,
maxValue: _Number,
cls: Type[VariableValue],
cls: Type[VariableValue] = VariableFloat,
minValue: Optional[_Number] = None,
maxValue: Optional[_Number] = None,
) -> VariableValue:
if not isinstance(value, cls):
try:
@ -339,9 +340,9 @@ def _to_variable_value(
value = cls(value)
else:
value = cls._make(it)
if value.value < minValue:
if minValue is not None and value.value < minValue:
raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}")
if value.value > maxValue:
if maxValue is not None and value.value > maxValue:
raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}")
return value
@ -526,7 +527,21 @@ class LayerV1ListBuilder:
ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient)
ot_paint.ColorLine = _to_color_line(colorLine)
for i, (x, y), r in [(0, c0, r0), (1, c1, r1)]:
# normalize input types (which may or may not specify a varIdx)
x0, y0 = _to_variable_value(c0[0]), _to_variable_value(c0[1])
r0 = _to_variable_value(r0)
x1, y1 = _to_variable_value(c1[0]), _to_variable_value(c1[1])
r1 = _to_variable_value(r1)
# avoid abrupt change after rounding when c0 is near c1's perimeter
c = round_start_circle_stable_containment(
(x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value
)
x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1])
r0 = r0._replace(value=c.radius)
for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))):
# rounding happens here as floats are converted to integers
setattr(ot_paint, f"x{i}", _to_variable_int16(x))
setattr(ot_paint, f"y{i}", _to_variable_int16(y))
setattr(ot_paint, f"r{i}", _to_variable_uint16(r))

View File

@ -0,0 +1,145 @@
"""Helpers for manipulating 2D points and vectors in COLR table."""
from math import copysign, cos, hypot, pi
from fontTools.misc.fixedTools import otRound
def _vector_between(origin, target):
return (target[0] - origin[0], target[1] - origin[1])
def _round_point(pt):
return (otRound(pt[0]), otRound(pt[1]))
def _unit_vector(vec):
length = hypot(*vec)
if length == 0:
return None
return (vec[0] / length, vec[1] / length)
# This is the same tolerance used by Skia's SkTwoPointConicalGradient.cpp to detect
# when a radial gradient's focal point lies on the end circle.
_NEARLY_ZERO = 1 / (1 << 12) # 0.000244140625
# The unit vector's X and Y components are respectively
# U = (cos(α), sin(α))
# where α is the angle between the unit vector and the positive x axis.
_UNIT_VECTOR_THRESHOLD = cos(3 / 8 * pi) # == sin(1/8 * pi) == 0.38268343236508984
def _rounding_offset(direction):
# Return 2-tuple of -/+ 1.0 or 0.0 approximately based on the direction vector.
# We divide the unit circle in 8 equal slices oriented towards the cardinal
# (N, E, S, W) and intermediate (NE, SE, SW, NW) directions. To each slice we
# map one of the possible cases: -1, 0, +1 for either X and Y coordinate.
# E.g. Return (+1.0, -1.0) if unit vector is oriented towards SE, or
# (-1.0, 0.0) if it's pointing West, etc.
uv = _unit_vector(direction)
if not uv:
return (0, 0)
result = []
for uv_component in uv:
if -_UNIT_VECTOR_THRESHOLD <= uv_component < _UNIT_VECTOR_THRESHOLD:
# unit vector component near 0: direction almost orthogonal to the
# direction of the current axis, thus keep coordinate unchanged
result.append(0)
else:
# nudge coord by +/- 1.0 in direction of unit vector
result.append(copysign(1.0, uv_component))
return tuple(result)
class Circle:
def __init__(self, centre, radius):
self.centre = centre
self.radius = radius
def __repr__(self):
return f"Circle(centre={self.centre}, radius={self.radius})"
def round(self):
return Circle(_round_point(self.centre), otRound(self.radius))
def inside(self, outer_circle):
dist = self.radius + hypot(*_vector_between(self.centre, outer_circle.centre))
return (
abs(outer_circle.radius - dist) <= _NEARLY_ZERO
or outer_circle.radius > dist
)
def concentric(self, other):
return self.centre == other.centre
def move(self, dx, dy):
self.centre = (self.centre[0] + dx, self.centre[1] + dy)
def round_start_circle_stable_containment(c0, r0, c1, r1):
"""Round start circle so that it stays inside/outside end circle after rounding.
The rounding of circle coordinates to integers may cause an abrupt change
if the start circle c0 is so close to the end circle c1's perimiter that
it ends up falling outside (or inside) as a result of the rounding.
To keep the gradient unchanged, we nudge it in the right direction.
See:
https://github.com/googlefonts/colr-gradients-spec/issues/204
https://github.com/googlefonts/picosvg/issues/158
"""
start, end = Circle(c0, r0), Circle(c1, r1)
inside_before_round = start.inside(end)
round_start = start.round()
round_end = end.round()
inside_after_round = round_start.inside(round_end)
if inside_before_round == inside_after_round:
return round_start
elif inside_after_round:
# start was outside before rounding: we need to push start away from end
direction = _vector_between(round_end.centre, round_start.centre)
radius_delta = +1.0
else:
# start was inside before rounding: we need to push start towards end
direction = _vector_between(round_start.centre, round_end.centre)
radius_delta = -1.0
dx, dy = _rounding_offset(direction)
# At most 2 iterations ought to be enough to converge. Before the loop, we
# know the start circle didn't keep containment after normal rounding; thus
# we continue adjusting by -/+ 1.0 until containment is restored.
# Normal rounding can at most move each coordinates -/+0.5; in the worst case
# both the start and end circle's centres and radii will be rounded in opposite
# directions, e.g. when they move along a 45 degree diagonal:
# c0 = (1.5, 1.5) ===> (2.0, 2.0)
# r0 = 0.5 ===> 1.0
# c1 = (0.499, 0.499) ===> (0.0, 0.0)
# r1 = 2.499 ===> 2.0
# In this example, the relative distance between the circles, calculated
# as r1 - (r0 + distance(c0, c1)) is initially 0.57437 (c0 is inside c1), and
# -1.82842 after rounding (c0 is now outside c1). Nudging c0 by -1.0 on both
# x and y axes moves it towards c1 by hypot(-1.0, -1.0) = 1.41421. Two of these
# moves cover twice that distance, which is enough to restore containment.
max_attempts = 2
for _ in range(max_attempts):
if round_start.concentric(round_end):
# can't move c0 towards c1 (they are the same), so we change the radius
round_start.radius += radius_delta
assert round_start.radius >= 0
else:
round_start.move(dx, dy)
if inside_before_round == round_start.inside(round_end):
break
else: # likely a bug
raise AssertionError(
f"Rounding circle {start} "
f"{'inside' if inside_before_round else 'outside'} "
f"{end} failed after {max_attempts} attempts!"
)
return round_start

View File

@ -1,6 +1,7 @@
from fontTools.ttLib import newTable
from fontTools.ttLib.tables import otTables as ot
from fontTools.colorLib import builder
from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle
from fontTools.colorLib.builder import LayerV1ListBuilder
from fontTools.colorLib.errors import ColorLibError
import pytest
@ -1055,3 +1056,52 @@ class BuildCOLRTest(object):
assert hasattr(colr, "table")
assert isinstance(colr.table, ot.COLR)
assert colr.table.VarStore is None
class TrickyRadialGradientTest:
@staticmethod
def circle_inside_circle(c0, r0, c1, r1, rounded=False):
if rounded:
return Circle(c0, r0).round().inside(Circle(c1, r1).round())
else:
return Circle(c0, r0).inside(Circle(c1, r1))
def round_start_circle(self, c0, r0, c1, r1, inside=True):
assert self.circle_inside_circle(c0, r0, c1, r1) is inside
assert self.circle_inside_circle(c0, r0, c1, r1, rounded=True) is not inside
r = round_start_circle_stable_containment(c0, r0, c1, r1)
assert (
self.circle_inside_circle(r.centre, r.radius, c1, r1, rounded=True)
is inside
)
return r.centre, r.radius
def test_noto_emoji_mosquito_u1f99f(self):
# https://github.com/googlefonts/picosvg/issues/158
c0 = (385.23508, 70.56727999999998)
r0 = 0
c1 = (642.99108, 104.70327999999995)
r1 = 260.0072
assert self.round_start_circle(c0, r0, c1, r1, inside=True) == ((386, 71), 0)
@pytest.mark.parametrize(
"c0, r0, c1, r1, inside, expected",
[
# inside before round, outside after round
((1.4, 0), 0, (2.6, 0), 1.3, True, ((2, 0), 0)),
((1, 0), 0.6, (2.8, 0), 2.45, True, ((2, 0), 1)),
((6.49, 6.49), 0, (0.49, 0.49), 8.49, True, ((5, 5), 0)),
# outside before round, inside after round
((0, 0), 0, (2, 0), 1.5, False, ((-1, 0), 0)),
((0, -0.5), 0, (0, -2.5), 1.5, False, ((0, 1), 0)),
# the following ones require two nudges to round correctly
((0.5, 0), 0, (9.4, 0), 8.8, False, ((-1, 0), 0)),
((1.5, 1.5), 0, (0.49, 0.49), 1.49, True, ((0, 0), 0)),
# limit case when circle almost exactly overlap
((0.5000001, 0), 0.5000001, (0.499999, 0), 0.4999999, True, ((0, 0), 0)),
# concentrical circles, r0 > r1
((0, 0), 1.49, (0, 0), 1, False, ((0, 0), 2)),
],
)
def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected):
assert self.round_start_circle(c0, r0, c1, r1, inside) == expected