def _parse_linear_gradient(grad_el, shape_bbox, view_box, upem): width, height = _get_gradient_units_relative_scale(grad_el, view_box) x1 = _number_or_percentage(grad_el.attrib.get("x1", "0%"), width) y1 = _number_or_percentage(grad_el.attrib.get("y1", "0%"), height) x2 = _number_or_percentage(grad_el.attrib.get("x2", "100%"), width) y2 = _number_or_percentage(grad_el.attrib.get("y2", "0%"), height) p0 = Point(x1, y1) p1 = Point(x2, y2) # compute the vector n perpendicular to vector v from P1 to P0 v = p0 - p1 n = v.perpendicular() transform = _get_gradient_transform(grad_el, shape_bbox, view_box, upem) # apply transformations to points and perpendicular vector p0 = transform.map_point(p0) p1 = transform.map_point(p1) n = transform.map_vector(n) # P2 is equal to P1 translated by the orthogonal projection of the transformed # vector v' (from P1' to P0') onto the transformed vector n'; before the # transform the vector n is perpendicular to v, so the projection of v onto n # is zero and P2 == P1; if the transform has a skew or a scale that doesn't # preserve aspect ratio, the projection of v' onto n' is non-zero and P2 != P1 v = p0 - p1 p2 = p1 + v.projection(n) return {"p0": p0, "p1": p1, "p2": p2}
def cubic_deriv(p0, p1, p2, p3): # derivative is a quad return ( Point(3 * (p1[0] - p0[0]), 3 * (p1[1] - p0[1])), Point(3 * (p2[0] - p1[0]), 3 * (p2[1] - p1[1])), Point(3 * (p3[0] - p2[0]), 3 * (p3[1] - p2[1])), )
def _gradient_paint(ttfont: ttLib.TTFont, ot_paint: otTables.Paint) -> _GradientPaint: stops = tuple( ColorStop( stop.StopOffset, _color(ttfont, stop.PaletteIndex, stop.Alpha), ) for stop in ot_paint.ColorLine.ColorStop) extend = Extend((ot_paint.ColorLine.Extend, )) if ot_paint.Format == PaintLinearGradient.format: return PaintLinearGradient( stops=stops, extend=extend, p0=Point(ot_paint.x0, ot_paint.y0), p1=Point(ot_paint.x1, ot_paint.y1), p2=Point(ot_paint.x2, ot_paint.y2), ) elif ot_paint.Format == PaintRadialGradient.format: return PaintRadialGradient( stops=stops, extend=extend, c0=Point(ot_paint.x0, ot_paint.y0), c1=Point(ot_paint.x1, ot_paint.y1), r0=ot_paint.r0, r1=ot_paint.r1, ) else: raise ValueError( f"Expected one of Paint formats {_GRADIENT_PAINT_FORMATS}; " f"found {ot_paint.Format}")
def _parse_radial_gradient(grad_el, shape_bbox, view_box, upem): width, height = _get_gradient_units_relative_scale(grad_el, view_box) cx = _number_or_percentage(grad_el.attrib.get("cx", "50%"), width) cy = _number_or_percentage(grad_el.attrib.get("cy", "50%"), height) r = _number_or_percentage(grad_el.attrib.get("r", "50%"), width) raw_fx = grad_el.attrib.get("fx") fx = _number_or_percentage(raw_fx, width) if raw_fx is not None else cx raw_fy = grad_el.attrib.get("fy") fy = _number_or_percentage(raw_fy, height) if raw_fy is not None else cy fr = _number_or_percentage(grad_el.attrib.get("fr", "0%"), width) c0 = Point(fx, fy) r0 = fr c1 = Point(cx, cy) r1 = r transform = _get_gradient_transform(grad_el, shape_bbox, view_box, upem) # The optional Affine2x2 matrix of COLRv1.RadialGradient is used to transform # the circles into ellipses "around their centres": i.e. centres coordinates # are _not_ transformed by it. Thus we apply the full transform to them. c0 = transform.map_point(c0) c1 = transform.map_point(c1) # As for the circle radii (which are affected by Affine2x2), we only scale them # by the maximum of the (absolute) scale or skew. # Then in Affine2x2 we only store a "fraction" of the original transform, i.e. # multiplied by the inverse of the scale that we've already applied to the radii. # Especially when gradientUnits="objectBoundingBox", where circle positions and # radii are expressed using small floats in the range [0..1], this pre-scaling # helps reducing the inevitable rounding errors that arise from storing these # values as integers in COLRv1 tables. s = max(abs(v) for v in transform[:4]) rscale = Affine2D(s, 0, 0, s, 0, 0) r0 = rscale.map_vector((r0, 0)).x r1 = rscale.map_vector((r1, 0)).x affine2x2 = Affine2D.product(rscale.inverse(), transform) gradient = { "c0": c0, "c1": c1, "r0": r0, "r1": r1, "affine2x2": (affine2x2[:4] if affine2x2 != Affine2D.identity() else None), } # TODO handle degenerate cases, fallback to solid, w/e return gradient
def cubic_tangent(t, p0, p1, p2, p3) -> Vector: # Returns the unit vector defining the cubic bezier curve's direction at t if t == 0.0: tangent = Point(*p1) - Point(*p0) elif t == 1.0: tangent = Point(*p3) - Point(*p2) else: tangent = Vector(*cubic_deriv_pos(t, p0, p1, p2, p3)) tangent = tangent.unit() if tangent is not None: return tangent return Vector()
class PaintLinearGradient(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintLinearGradient) extend: Extend = Extend.PAD stops: Tuple[ColorStop, ...] = tuple() p0: Point = Point() p1: Point = Point() p2: Point = None # if normal undefined, default to P1 rotated 90° cc'wise def __post_init__(self): if self.p2 is None: p0, p1 = Point(*self.p0), Point(*self.p1) # use object.__setattr__ as the dataclass is frozen object.__setattr__(self, "p2", p0 + (p1 - p0).perpendicular()) def colors(self): for stop in self.stops: yield stop.color def to_ufo_paint(self, colors): return { "Format": self.format, "ColorLine": _ufoColorLine(self, colors), "x0": self.p0[0], "y0": self.p0[1], "x1": self.p1[0], "y1": self.p1[1], "x2": self.p2[0], "y2": self.p2[1], } def check_overflows(self) -> "PaintLinearGradient": for i in range(3): attr_name = f"p{i}" value = getattr(self, attr_name) for j in range(2): if not (MIN_INT16 <= value[j] <= MAX_INT16): raise OverflowError( f"{self.__class__.__name__}.{attr_name}[{j}] ({value[j]}) is " f"out of bounds: [{MIN_INT16}...{MAX_INT16}]" ) return self def apply_transform(self, transform: Affine2D) -> Paint: return dataclasses.replace( self, p0=transform.map_point(self.p0), p1=transform.map_point(self.p1), p2=transform.map_point(self.p2), ).check_overflows()
def transformed(transform: Affine2D, target: Paint) -> Paint: if transform == Affine2D.identity(): return target sx, b, c, sy, dx, dy = transform # Int16 translation? if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy) == transform: if int16_safe(dx, dy): return PaintTranslate(paint=target, dx=dx, dy=dy) # Scale? # If all we have are scale and translation this is pure scaling # If b,c are present this is some sort of rotation or skew if (sx, sy) != (1, 1) and (b, c) == (0, 0) and f2dot14_safe(sx, sy): if (dx, dy) == (0, 0): if almost_equal(sx, sy): return PaintScaleUniform(paint=target, scale=sx) else: return PaintScale(paint=target, scaleX=sx, scaleY=sy) else: # translated scaling is the same as scale around a non-origin center # If you trace through translate, scale, translate you get: # dx = (1 - sx) * cx # cx = dx / (1 - sx) # undefined if sx == 1; if so dx should == 0 # so, as long as (1==sx) == (0 == dx) we're good if (1 == sx) == (0 == dx) and (1 == sy) == (0 == dy): cx = 0 if sx != 1: cx = dx / (1 - sx) cy = 0 if sy != 1: cy = dy / (1 - sy) if int16_safe(cx, cy): if almost_equal(sx, sy): return PaintScaleUniformAroundCenter( paint=target, scale=sx, center=Point(cx, cy) ) else: return PaintScaleAroundCenter( paint=target, scaleX=sx, scaleY=sy, center=Point(cx, cy) ) # TODO optimize rotations # TODO optimize, skew, rotate around center return PaintTransform(paint=target, transform=tuple(transform))
class PaintScaleAroundCenter(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintScaleAroundCenter) paint: Paint scaleX: float = 1.0 scaleY: float = 1.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "scaleX": self.scaleX, "scaleY": self.scaleY, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .scale(self.scaleX, self.scaleY) .translate(-self.center[0], -self.center[1]) )
class PaintRotateAroundCenter(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintRotateAroundCenter) paint: Paint angle: float = 0.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "angle": self.angle, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return Affine2D.identity().rotate( radians(self.angle), self.center[0], self.center[1] )
class PaintSkewAroundCenter(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintSkewAroundCenter) paint: Paint xSkewAngle: float = 0.0 ySkewAngle: float = 0.0 center: Point = Point() def colors(self): yield from self.paint.colors() def to_ufo_paint(self, colors): paint = { "Format": self.format, "Paint": self.paint.to_ufo_paint(colors), "xSkewAngle": self.xSkewAngle, "ySkewAngle": self.ySkewAngle, "centerX": self.center[0], "centerY": self.center[1], } return paint def children(self) -> Iterable[Paint]: return (self.paint,) def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .skew(-radians(self.xSkewAngle), radians(self.ySkewAngle)) .translate(-self.center[0], -self.center[1]) )
def arc_to_cubic_callback(subpath_start, curr_pos, cmd, args, *_): del subpath_start if cmd not in {"a", "A"}: # no work to do return ((cmd, args), ) (rx, ry, x_rotation, large, sweep, end_x, end_y) = args if cmd == "a": end_x += curr_pos.x end_y += curr_pos.y end_pt = Point(end_x, end_y) result = [] for p1, p2, target in arc_to_cubic(curr_pos, rx, ry, x_rotation, large, sweep, end_pt): x, y = target if p1 is not None: assert p2 is not None x1, y1 = p1 x2, y2 = p2 result.append(("C", (x1, y1, x2, y2, x, y))) else: result.append(("L", (x, y))) return tuple(result)
def _arc_to_cubic(arc: EllipticalArc) -> Iterator[Tuple[Point, Point, Point]]: arc = arc.correct_out_of_range_radii() arc_params = arc.end_to_center_parametrization() point_transform = ( Affine2D.identity() .translate(arc_params.center_point.x, arc_params.center_point.y) .rotate(radians(arc.rotation)) .scale(arc.rx, arc.ry) ) # Some results of atan2 on some platform implementations are not exact # enough. So that we get more cubic curves than expected here. Adding 0.001f # reduces the count of sgements to the correct count. num_segments = int(ceil(fabs(arc_params.theta_arc / (PI_OVER_TWO + 0.001)))) for i in range(num_segments): start_theta = arc_params.theta1 + i * arc_params.theta_arc / num_segments end_theta = arc_params.theta1 + (i + 1) * arc_params.theta_arc / num_segments t = (4 / 3) * tan(0.25 * (end_theta - start_theta)) if not isfinite(t): return sin_start_theta = sin(start_theta) cos_start_theta = cos(start_theta) sin_end_theta = sin(end_theta) cos_end_theta = cos(end_theta) point1 = Point( cos_start_theta - t * sin_start_theta, sin_start_theta + t * cos_start_theta ) end_point = Point(cos_end_theta, sin_end_theta) point2 = end_point + Vector(t * sin_end_theta, -t * cos_end_theta) point1 = point_transform.map_point(point1) point2 = point_transform.map_point(point2) # by definition, the last bezier's end point == the arc end point # by directly taking the end point we avoid floating point imprecision if i == num_segments - 1: end_point = arc.end_point else: end_point = point_transform.map_point(end_point) yield point1, point2, end_point
def test_point_subtraction_and_addition(): p0 = Point(1, 3) p1 = Point(-2, 4) v = p1 - p0 assert isinstance(v, Vector) assert v.x == -3 assert v.y == 1 p2 = p1 - v assert isinstance(p2, Point) assert p2 == p0 p3 = p0 + v assert isinstance(p3, Point) assert p3 == p1
def _cubic_callback(subpath_start, curr_xy, cmd, args, prev_xy, prev_cmd, prev_args): if cmd.upper() == "M": return ((cmd, args), ) # Convert to cubic if needed if cmd.upper() == "Z": # a line back to subpath start ... unless we are there already if curr_xy == subpath_start: return () cmd = "L" args = subpath_start if cmd == "L": # line from curr_xy to args assert len(args) == 2 end_xy = args # cubic ctl points 1/3rd and 2/3rds along cmd = "C" args = ( *line_pos(0.33, curr_xy, end_xy), *line_pos(0.66, curr_xy, end_xy), *end_xy, ) if cmd == "Q": assert len(args) == 4 cmd = "C" p0 = Point(*curr_xy) p1 = Point(*args[0:2]) p2 = Point(*args[2:4]) args = (*(p0 + 2 / 3 * (p1 - p0)), *(p2 + 2 / 3 * (p1 - p2)), *p2) if cmd != "C": raise ValueError(f"How do you cubic {cmd}, {args}") assert len(args) == 6 return (("C", args), )
def arc_to_cubic( start_point: Tuple[float, float], rx: float, ry: float, rotation: float, large: int, sweep: int, end_point: Tuple[float, float], ) -> Iterator[Tuple[Optional[Point], Optional[Point], Point]]: """Convert arc to cubic(s). start/end point are (x,y) tuples with absolute coordinates. See https://skia.org/user/api/SkPath_Reference#SkPath_arcTo_4 Note in particular: SVG sweep-flag value is opposite the integer value of sweep; SVG sweep-flag uses 1 for clockwise, while kCW_Direction cast to int is zero. Yields 3-tuples of Points for each Cubic bezier, i.e. two off-curve points and one on-curve end point. If either rx or ry is 0, the arc is treated as a straight line joining the end points, and a (None, None, arc.end_point) tuple is yielded. Yields empty iterator if arc has zero length. """ if not isinstance(start_point, Point): start_point = Point(*start_point) if not isinstance(end_point, Point): end_point = Point(*end_point) arc = EllipticalArc(start_point, rx, ry, rotation, large, sweep, end_point) if arc.is_zero_length(): return elif arc.is_straight_line(): yield None, None, arc.end_point else: yield from _arc_to_cubic(arc)
def _is_almost_line(curve, flatness=1.0): # Returns True if the bezier curve is equivalent to a line. # A 'flat' bezier curve is one such that the sum of the distances between # consecutive control points equals the distance from start to end points. # That's because if a control point isn't on the line from start to end then # the length would exceed the direct path start => end. # The 'flatness' factor of 1.0 means exactly flat, anything greater than 1.0 # proportionately means flat "enough". Less than 1.0 means never flat (i.e. # keep all flat curves as curves, FWIW). points = [Point(*p) for p in curve] length = 0 for i in range(len(curve) - 1): length += (points[i + 1] - points[i]).norm() max_length = (points[-1] - points[0]).norm() return length <= flatness * max_length
def _next_pos(curr_pos, cmd, cmd_args): # update current position x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) new_x, new_y = curr_pos if cmd.isupper(): if x_coord_idxs: new_x = 0 if y_coord_idxs: new_y = 0 if x_coord_idxs: new_x += cmd_args[x_coord_idxs[-1]] if y_coord_idxs: new_y += cmd_args[y_coord_idxs[-1]] return Point(new_x, new_y)
def expand_shorthand_callback(_, curr_pos, cmd, args, prev_pos, prev_cmd, prev_args): short_to_long = {"S": "C", "T": "Q"} if not cmd.upper() in short_to_long: return ((cmd, args), ) if cmd.islower(): cmd, args = _relative_to_absolute(curr_pos, cmd, args) # if there is no prev, or a bad prev, control point coincident current new_cp = (curr_pos.x, curr_pos.y) if prev_cmd: if prev_cmd.islower(): prev_cmd, prev_args = _relative_to_absolute( prev_pos, prev_cmd, prev_args) if prev_cmd in short_to_long.values(): # reflect 2nd-last x,y pair over curr_pos and make it our first arg prev_cp = Point(prev_args[-4], prev_args[-3]) new_cp = (2 * curr_pos.x - prev_cp.x, 2 * curr_pos.y - prev_cp.y) return ((short_to_long[cmd], new_cp + args), )
def walk(self, callback): """Walk path and call callback to build potentially new commands. https://www.w3.org/TR/SVG11/paths.html def callback(subpath_start, curr_xy, cmd, args, prev_xy, prev_cmd, prev_args) prev_* None if there was no previous returns sequence of (new_cmd, new_args) that replace cmd, args """ curr_pos = Point() subpath_start_pos = curr_pos # where a z will take you new_cmds = [] # iteration gives us exploded commands for idx, (cmd, args) in enumerate(self): svg_meta.check_cmd(cmd, args) if idx == 0 and cmd == "m": cmd = "M" prev = (None, None, None) if new_cmds: prev = new_cmds[-1] for (new_cmd, new_cmd_args) in callback(subpath_start_pos, curr_pos, cmd, args, *prev): if new_cmd.lower() != "z": next_pos = _next_pos(curr_pos, new_cmd, new_cmd_args) else: next_pos = subpath_start_pos prev_pos, curr_pos = curr_pos, next_pos if new_cmd.upper() == "M": subpath_start_pos = curr_pos new_cmds.append((prev_pos, new_cmd, new_cmd_args)) self.d = "" for _, cmd, args in new_cmds: self._add_cmd(cmd, *args)
def test_add_vec(self): assert Vector(1, 2) + Vector(2, 3) == Vector(3, 5) assert Vector(1, 2) + Point(2, 3) == Point(3, 5) assert Point(2, 3) + Vector(1, 2) == Point(3, 5)
def _colr_v1_glyph_to_svg(ttfont: ttLib.TTFont, view_box: Rect, glyph: otTables.BaseGlyphRecord) -> etree.Element: glyph_set = ttfont.getGlyphSet() svg_root = _svg_root(view_box) defs = svg_root[0] for glyph_layer in glyph.LayerV1List.LayerV1Record: svg_path = etree.SubElement(svg_root, "path") # TODO care about variations, such as for alpha paint = glyph_layer.Paint if paint.Format == _PAINT_SOLID: _solid_paint(svg_path, ttfont, paint.Color.PaletteIndex, paint.Color.Alpha.value) elif paint.Format == _PAINT_LINEAR_GRADIENT: _linear_gradient_paint( defs, svg_path, ttfont, view_box, stops=[ _ColorStop( stop.StopOffset.value, stop.Color.PaletteIndex, stop.Color.Alpha.value, ) for stop in paint.ColorLine.ColorStop ], extend=Extend((paint.ColorLine.Extend.value, )), p0=Point(paint.x0.value, paint.y0.value), p1=Point(paint.x1.value, paint.y1.value), p2=Point(paint.x2.value, paint.y2.value), ) elif paint.Format == _PAINT_RADIAL_GRADIENT: _radial_gradient_paint( defs, svg_path, ttfont, view_box, stops=[ _ColorStop( stop.StopOffset.value, stop.Color.PaletteIndex, stop.Color.Alpha.value, ) for stop in paint.ColorLine.ColorStop ], extend=Extend((paint.ColorLine.Extend.value, )), c0=Point(paint.x0.value, paint.y0.value), c1=Point(paint.x1.value, paint.y1.value), r0=paint.r0.value, r1=paint.r1.value, transform=(Affine2D.identity() if not paint.Transform else Affine2D( paint.Transform.xx.value, paint.Transform.xy.value, paint.Transform.yx.value, paint.Transform.yy.value, 0, 0, )), ) _draw_svg_path(svg_path, view_box, ttfont, glyph_layer.LayerGlyph, glyph_set) return svg_root
def _mid_point(p1, p2): x1, y1 = p1 x2, y2 = p2 return Point(x1, y1) + (Point(x2, y2) - Point(x1, y1)) * 0.5
class PaintRadialGradient(Paint): format: ClassVar[int] = int(ot.PaintFormat.PaintRadialGradient) extend: Extend = Extend.PAD stops: Tuple[ColorStop, ...] = tuple() c0: Point = Point() c1: Point = Point() r0: float = 0.0 r1: float = 0.0 def colors(self): for stop in self.stops: yield stop.color def to_ufo_paint(self, colors): paint = { "Format": self.format, "ColorLine": _ufoColorLine(self, colors), "x0": self.c0[0], "y0": self.c0[1], "r0": self.r0, "x1": self.c1[0], "y1": self.c1[1], "r1": self.r1, } return paint def check_overflows(self) -> "PaintRadialGradient": int_bounds = { "c": (MIN_INT16, MAX_INT16), "r": (MIN_UINT16, MAX_UINT16), } attrs = [] for prefix in ("c", "r"): for i in range(2): attr_name = f"{prefix}{i}" value = getattr(self, attr_name) if prefix == "c": attrs.extend((f"{attr_name}[{j}]", value[j]) for j in range(2)) else: attrs.append((f"{attr_name}", value)) for attr_name, value in attrs: min_value, max_value = int_bounds[attr_name[0]] if not (min_value <= value <= max_value): raise OverflowError( f"{self.__class__.__name__}.{attr_name} ({value}) is " f"out of bounds: [{min_value}...{max_value}]" ) return self def apply_transform(self, transform: Affine2D) -> Paint: # if gradientUnits="objectBoundingBox" and the bbox is not square, or there's some # gradientTransform, we may end up with a transformation that does not keep the # aspect ratio of the gradient circles and turns them into ellipses, but CORLv1 # PaintRadialGradient by itself can only define circles. Thus we only apply the # uniform scale and translate components of the original transform to the circles, # then encode any remaining non-uniform transformation as a COLRv1 transform # that wraps the PaintRadialGradient (see further below). uniform_transform, remaining_transform = _decompose_uniform_transform(transform) c0 = uniform_transform.map_point(self.c0) c1 = uniform_transform.map_point(self.c1) sx, _ = uniform_transform.getscale() r0 = self.r0 * sx r1 = self.r1 * sx # TODO handle degenerate cases, fallback to solid, w/e return transformed( remaining_transform, dataclasses.replace(self, c0=c0, c1=c1, r0=r0, r1=r1).check_overflows(), )
def __post_init__(self): if self.p2 is None: p0, p1 = Point(*self.p0), Point(*self.p1) # use object.__setattr__ as the dataclass is frozen object.__setattr__(self, "p2", p0 + (p1 - p0).perpendicular())
def map_point(self, pt: Tuple[float, float]) -> Point: """Return Point (x, y) multiplied by Affine2D.""" x, y = pt return Point(self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f)