def _apply_paint( svg_defs: etree.Element, el: etree.Element, paint: Paint, upem_to_vbox: Affine2D, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): # If you modify the attributes _apply_paint can set also modify _PAINT_ATTRIB_APPLY_PAINT_MAY_SET if isinstance(paint, PaintSolid): _apply_solid_paint(el, paint) elif isinstance(paint, (PaintLinearGradient, PaintRadialGradient)): # Gradient paint coordinates are in UPEM space, we want them in SVG viewBox # so that they match the SVGPath.d coordinates (that we copy unmodified). paint = _map_gradient_coordinates(paint, upem_to_vbox) # Likewise transforms refer to UPEM so they must be adjusted for SVG if transform != Affine2D.identity(): transform = Affine2D.compose_ltr( (upem_to_vbox.inverse(), transform, upem_to_vbox)) _apply_gradient_paint(svg_defs, el, paint, reuse_cache, transform) elif is_transform(paint): transform @= paint.gettransform() child = paint.paint # pytype: disable=attribute-error _apply_paint(svg_defs, el, child, upem_to_vbox, reuse_cache, transform) else: raise NotImplementedError(type(paint))
def _apply_gradient_ot_paint( svg_defs: etree.Element, svg_path: etree.Element, ttfont: ttLib.TTFont, font_to_vbox: Affine2D, ot_paint: otTables.Paint, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): paint = _gradient_paint(ttfont, ot_paint) # For radial gradients we want to keep cirlces as such, so we must decompose into # a uniform scale+translate plus a remainder to encode as gradientTransform. # Whereas for linear gradients, we can simply apply the whole combined transform to # start/end points and omit gradientTransform attribute. coord_transform = Affine2D.compose_ltr((transform, font_to_vbox)) remaining_transform = Affine2D.identity() if paint.format == PaintRadialGradient.format: coord_transform, remaining_transform = _decompose_uniform_transform( coord_transform) paint = _map_gradient_coordinates(paint, coord_transform) _apply_gradient_paint(svg_defs, svg_path, paint, reuse_cache, transform=remaining_transform)
def apply_transforms(self, inplace=False): """Naively transforms to shapes and removes the transform attribute. Naive: just applies any transform on a parent element. """ if not inplace: svg = SVG(copy.deepcopy(self.svg_root)) svg.apply_transforms(inplace=True) return svg self._update_etree() # figure out the sequence of transforms, if any, for each shape new_shapes = [] for idx, (el, (shape, )) in enumerate(self._elements()): transform = Affine2D.identity() while el is not None: if "transform" in el.attrib: transform = Affine2D.product( transform, Affine2D.fromstring(el.attrib["transform"])) el = el.getparent() if transform == Affine2D.identity(): continue new_shapes.append((idx, shape.apply_transform(transform))) for el_idx, new_shape in new_shapes: el, _ = self.elements[el_idx] self._set_element(el_idx, el, (new_shape, )) # destroy all transform attributes self.remove_attributes(["transform"], xpath="//svg:*[@transform]", inplace=True) return self
def _apply_gradient_common_parts( gradient: etree.Element, paint: _GradientPaint, transform: Affine2D = Affine2D.identity(), ): gradient.attrib["gradientUnits"] = "userSpaceOnUse" for stop in paint.stops: stop_el = etree.SubElement(gradient, "stop") stop_el.attrib["offset"] = _ntos(stop.stopOffset) stop_el.attrib["stop-color"] = stop.color.opaque().to_string() if stop.color.alpha != 1.0: stop_el.attrib["stop-opacity"] = _ntos(stop.color.alpha) if paint.extend != Extend.PAD: gradient.attrib["spreadMethod"] = paint.extend.name.lower() transform = transform.round(_DEFAULT_ROUND_NDIGITS) if transform != Affine2D.identity(): # Safari has a bug which makes it reject a gradient if gradientTransform # contains an 'involutory matrix' (i.e. matrix whose inverse equals itself, # such that M @ M == Identity, e.g. reflection), hence the following hack: # https://github.com/googlefonts/nanoemoji/issues/268 # https://en.wikipedia.org/wiki/Involutory_matrix # TODO: Remove once the bug gets fixed if transform @ transform == Affine2D.identity(): transform = transform._replace(a=transform.a + 0.00001) assert transform.inverse() != transform gradient.attrib["gradientTransform"] = transform.tostring()
def affine_between(s1: SVGShape, s2: SVGShape, tolerance: int = DEFAULT_TOLERANCE) -> Optional[Affine2D]: """Returns the Affine2D to change s1 into s2 or None if no solution was found. Implementation starting *very* basic, can improve over time. """ s1 = dataclasses.replace(s1, id="") s2 = dataclasses.replace(s2, id="") if s1.almost_equals(s2, tolerance): return Affine2D.identity() s1 = s1.as_path() s2 = s2.as_path() s1x, s1y = _first_move(s1) s2x, s2y = _first_move(s2) dx = s2x - s1x dy = s2y - s1y s1.move(dx, dy, inplace=True) if s1.almost_equals(s2, tolerance): return Affine2D.identity().translate(dx, dy) return None
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))
def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D: affine = Affine2D.identity() # rotate initial to have the same angle as target (may have different magnitude) angle = _angle(target) - _angle(initial) affine = Affine2D.identity().rotate(angle) vec = affine.map_vector(initial) # scale to target magnitude s = 0 if vec.norm() != 0: s = target.norm() / vec.norm() affine = Affine2D.compose_ltr((affine, Affine2D.identity().scale(s, s))) return affine
def _apply_gradient_translation(self, inplace=False): if not inplace: svg = SVG(copy.deepcopy(self.svg_root)) svg._apply_gradient_translation(inplace=True) return svg for el in self._select_gradients(): gradient = _GRADIENT_CLASSES[strip_ns(el.tag)].from_element( el, self.view_box()) a, b, c, d, e, f = (round(v, _GRADIENT_TRANSFORM_NDIGITS) for v in gradient.gradientTransform) affine = Affine2D(a, b, c, d, e, f) # no translate? nop! if (e, f) == (0, 0): continue # split translation from rest of the transform and apply to gradient coords translate, affine_prime = affine.decompose_translation() for x_attr, y_attr in _GRADIENT_COORDS[strip_ns(el.tag)]: # if at default just ignore if x_attr not in el.attrib and y_attr not in el.attrib: continue x = getattr(gradient, x_attr) y = getattr(gradient, y_attr) x_prime, y_prime = translate.map_point((x, y)) el.attrib[x_attr] = ntos( round(x_prime, _GRADIENT_TRANSFORM_NDIGITS)) el.attrib[y_attr] = ntos( round(y_prime, _GRADIENT_TRANSFORM_NDIGITS)) if affine_prime != Affine2D.identity(): el.attrib["gradientTransform"] = ( "matrix(" + " ".join(ntos(v) for v in affine_prime) + ")") else: del el.attrib["gradientTransform"]
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 normalize(shape: SVGShape, tolerance: float) -> SVGShape: """Build a version of shape that will compare == to other shapes even if offset, scaled, rotated, etc. Intended use is to normalize multiple shapes to identify opportunity for reuse.""" path = _affine_friendly(dataclasses.replace(shape, id="")) # Make path relative, with first coord at 0,0 x, y = _first_move(path) path.move(-x, -y, inplace=True) # Normlize vector 1 to [1 0]; eliminates rotation and uniform scaling vec1 = _nth_vector(path, 1) # ignore M 0,0 affine1 = _affine_vec2vec(vec1, Vector(1, 0)) path.walk(lambda *args: _affine_callback(affine1, *args)) # Scale first y movement to 1.0 vecy = _first_y(_vectors(path), tolerance) if vecy and not almost_equal(vecy.y, 1.0): affine2 = Affine2D.identity().scale(1, 1 / vecy.y) path.walk(lambda *args: _affine_callback(affine2, *args)) # TODO: what if shapes are the same but different start point # TODO: what if shapes are the same but different drawing cmds # This DOES happen in Noto; extent unclear path.round_multiple(tolerance, inplace=True) return path
def _define_linear_gradient( svg_defs: etree.Element, paint: PaintLinearGradient, transform: Affine2D = Affine2D.identity(), ) -> str: gradient = etree.SubElement(svg_defs, "linearGradient") gradient_id = gradient.attrib["id"] = f"g{len(svg_defs)}" p0, p1, p2 = paint.p0, paint.p1, paint.p2 # P2 allows to rotate the linear gradient independently of the end points P0 and P1. # Below we compute P3 which is the orthogonal projection of P1 onto a line passing # through P0 and perpendicular to the "normal" or "rotation vector" from P0 and P2. # The vector P3-P0 is the "effective" linear gradient vector after this rotation. # When vector P2-P0 is perpendicular to the gradient vector P1-P0, then P3 # (projection of P1 onto perpendicular to normal) is == P1 itself thus no rotation. # When P2 is collinear to the P1-P0 gradient vector, then this projected P3 == P0 # and the gradient degenerates to a solid paint (the last color stop). p3 = p0 + (p1 - p0).projection((p2 - p0).perpendicular()) x1, y1 = p0 x2, y2 = p3 gradient.attrib["x1"] = _ntos(x1) gradient.attrib["y1"] = _ntos(y1) gradient.attrib["x2"] = _ntos(x2) gradient.attrib["y2"] = _ntos(y2) _apply_gradient_common_parts(gradient, paint, transform) return gradient_id
def correct_out_of_range_radii(self) -> "EllipticalArc": # Check if the radii are big enough to draw the arc, scale radii if not. # http://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii if self.is_straight_line() or self.is_zero_length(): return self mid_point_distance = (self.start_point - self.end_point) * 0.5 # SVG rotation is expressed in degrees, whereas Affin2D.rotate uses radians angle = radians(self.rotation) point_transform = Affine2D.identity().rotate(-angle) transformed_mid_point = point_transform.map_vector(mid_point_distance) rx = self.rx ry = self.ry square_rx = rx * rx square_ry = ry * ry square_x = transformed_mid_point.x * transformed_mid_point.x square_y = transformed_mid_point.y * transformed_mid_point.y radii_scale = square_x / square_rx + square_y / square_ry if radii_scale > 1: rx *= sqrt(radii_scale) ry *= sqrt(radii_scale) return self._replace(rx=rx, ry=ry) return self
def gettransform(self) -> Affine2D: return ( Affine2D.identity() .translate(self.center[0], self.center[1]) .scale(self.scale) .translate(-self.center[0], -self.center[1]) )
def test_addComponent_decompose(): pen = SVGPathPen(glyphSet={"a": DummyGlyph()}) pen.addComponent("a", Affine2D.identity()) assert pen.path.d == ( "M0,0 L0,10 L10,10 L10,0 Z " "M0,15 C0,20 10,20 10,15 Z " "M0,-5 Q0,-8 1.5,-9 Q3,-10 5,-10 Q7,-10 8.5,-9 Q10,-8 10,-5")
def _transform_gradients(self, warp): for el in self._select_gradients(): gradient = _GRADIENT_CLASSES[strip_ns(el.tag)].from_element( el, self.view_box()) mid_pt = _gradient_mid_point(gradient) mid_pt = gradient.gradientTransform.map_point(mid_pt) translate = Affine2D.identity().translate(*warp.vec(mid_pt)) mid_pt = translate.map_point(mid_pt) gradient_transform = Affine2D.compose_ltr( (gradient.gradientTransform, translate, warp.skewY(mid_pt.x))) if gradient_transform != Affine2D.identity(): el.attrib["gradientTransform"] = gradient_transform.tostring() elif "gradientTransform" in el.attrib: del el.attrib["gradientTransform"] return self
def _transformed_glyph_bounds( ufo: ufoLib2.Font, glyph_name: str, transform: Affine2D) -> Optional[Tuple[float, float, float, float]]: glyph = ufo[glyph_name] pen = bounds_pen = ControlBoundsPen(ufo) if not transform.almost_equals(Affine2D.identity()): pen = TransformPen(bounds_pen, transform) glyph.draw(pen) return bounds_pen.bounds
def _transform(self, map_fn): if not self._has_viewbox_for_transform(): return Affine2D.identity() return map_fn( self.svg.view_box(), self.ufo.info.ascender, self.ufo.info.descender, self.ufo[self.glyph_name].width, self.user_transform, )
def _update_paint_glyph(paint): if paint.format != PaintGlyph.format: return paint if glyph_cache.is_known_glyph(paint.glyph): return paint assert paint.glyph.startswith( "M"), f"{paint.glyph} doesn't look like a path" path_in_font_space = (SVGPath( d=paint.glyph).apply_transform(svg_units_to_font_units).d) reuse_result = glyph_cache.try_reuse(path_in_font_space) if reuse_result is not None: # TODO: when is it more compact to use a new transforming glyph? child_transform = Affine2D.identity() child_paint = paint.paint if is_transform(child_paint): child_transform = child_paint.gettransform() child_paint = child_paint.paint # sanity check: GlyphReuseCache.try_reuse would return None if overflowed assert fixed_safe(*reuse_result.transform) overflows = False # TODO: handle gradient anywhere in subtree, not only as direct child of # PaintGlyph or PaintTransform if is_gradient(child_paint): # We have a gradient so we need to reverse the effect of the # reuse_result.transform. First we try to apply the combined transform # to the gradient's geometry; but this may overflow OT integer bounds, # in which case we pass through gradient unscaled transform = Affine2D.compose_ltr( (child_transform, reuse_result.transform.inverse())) # skip reuse if combined transform overflows OT int bounds overflows = not fixed_safe(*transform) if not overflows: try: child_paint = child_paint.apply_transform(transform) except OverflowError: child_paint = transformed(transform, child_paint) if not overflows: return transformed( reuse_result.transform, PaintGlyph( glyph=reuse_result.glyph_name, paint=child_paint, ), ) glyph = _create_glyph(color_glyph, paint, path_in_font_space) glyph_cache.add_glyph(glyph.name, path_in_font_space) return dataclasses.replace(paint, glyph=glyph.name)
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 _define_gradient( svg_defs: etree.Element, paint: _GradientPaint, transform: Affine2D = Affine2D.identity(), ) -> str: if isinstance(paint, PaintLinearGradient): return _define_linear_gradient(svg_defs, paint, transform) elif isinstance(paint, PaintRadialGradient): return _define_radial_gradient(svg_defs, paint, transform) else: raise TypeError(type(paint))
def _inherit_matrix_multiply(attrib, child, attr_name): group_transform = Affine2D.fromstring(attrib[attr_name]) if attr_name in child.attrib: transform = Affine2D.fromstring(child.attrib[attr_name]) transform = Affine2D.product(transform, group_transform) else: transform = group_transform if transform != Affine2D.identity(): child.attrib[attr_name] = transform.tostring() else: del child.attrib[attr_name]
def _radial_gradient_paint( svg_defs: etree.Element, svg_path: etree.Element, ttfont: ttLib.TTFont, view_box: Rect, stops: Sequence[_ColorStop], extend: Extend, c0: Point, c1: Point, r0: int, r1: int, transform: Affine2D, ): # map centres and radii from UPEM to SVG space upem_to_vbox = _emsquare_to_viewbox(ttfont["head"].unitsPerEm, view_box) c0 = upem_to_vbox.map_point(c0) c1 = upem_to_vbox.map_point(c1) # _emsquare_to_viewbox guarantees view_box is square so scaling radii is ok r0 = upem_to_vbox.map_point((r0, 0)).x r1 = upem_to_vbox.map_point((r1, 0)).x # COLRv1 centre points aren't affected by the gradient Affine2x2, whereas in SVG # gradientTransform applies to everything; to prevent that, we must also map # the centres with the inverse of gradientTransform, so they won't move. inverse_transform = transform.inverse() fx, fy = inverse_transform.map_point(c0) cx, cy = inverse_transform.map_point(c1) gradient = etree.SubElement(svg_defs, "radialGradient") gradient_id = gradient.attrib["id"] = f"g{len(svg_defs)}" gradient.attrib["gradientUnits"] = "userSpaceOnUse" gradient.attrib["fx"] = _ntos(fx) gradient.attrib["fy"] = _ntos(fy) gradient.attrib["fr"] = _ntos(r0) gradient.attrib["cx"] = _ntos(cx) gradient.attrib["cy"] = _ntos(cy) gradient.attrib["r"] = _ntos(r1) if transform != Affine2D.identity(): gradient.attrib["gradientTransform"] = _svg_matrix(transform) if extend != Extend.PAD: gradient.attrib["spreadMethod"] = extend.name.lower() palette = ttfont["CPAL"].palettes[0] for stop in stops: stop_el = etree.SubElement(gradient, "stop") stop_el.attrib["offset"] = _ntos(stop.offset) cpal_color = palette[stop.palette_index] svg_color, svg_opacity = _svg_color_and_opacity(cpal_color, stop.alpha) stop_el.attrib["stop-color"] = svg_color if svg_opacity: stop_el.attrib["stop-opacity"] = svg_opacity svg_path.attrib["fill"] = f"url(#{gradient_id})"
def _create_use_element(svg: SVG, parent_el: etree.Element, reuse_result: ReuseResult) -> etree.Element: svg_use = etree.SubElement(parent_el, "use", nsmap=svg.svg_root.nsmap) svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" transform = reuse_result.transform tx, ty = transform.gettranslate() if tx: svg_use.attrib["x"] = _ntos(tx) if ty: svg_use.attrib["y"] = _ntos(ty) transform = transform.translate(-tx, -ty) if transform != Affine2D.identity(): svg_use.attrib["transform"] = _svg_matrix(transform) return svg_use
def _about_paint(paint): hashable = colr_builder._paint_tuple(paint) if hashable in visited: return visited.add(hashable) count_by_type[paint.getFormatName()] += 1 if paint.getFormatName() == 'PaintTransform': tr = paint.Transform transform = (tr.xx, tr.yx, tr.xy, tr.yy, tr.dx, tr.dy) # just scale and/or translate? if tr.xy == 0 and tr.yx == 0: # Relationship between scale and translate leads to div 0? if ((tr.xx == 1) != (tr.dx == 0)) or ((tr.yy == 1) != (tr.dy == 0)): count_by_type[paint.getFormatName() + "::weird_scale"] += 1 elif f2dot14_safe(tr.xx, tr.yy): cx = cy = 0 if tr.dx != 0: cx = tr.dx / (1 - tr.xx) if tr.dy != 0: cy = tr.dy / (1 - tr.yy) if int16_safe(cx, cy): if tr.dx == 0 and tr.dy == 0: count_by_type[paint.getFormatName() + "::scale_origin"] += 1 else: count_by_type[paint.getFormatName() + "::scale_around"] += 1 unique_dropped_affines.add(transform) else: count_by_type[paint.getFormatName() + "::scale_around_non_int"] += 1 else: count_by_type[paint.getFormatName() + "::large_scale"] += 1 else: translate, other = Affine2D(*transform).decompose_translation() if _common_angle(*other[:4]): if translate.almost_equals(Affine2D.identity()): count_by_type[paint.getFormatName() + "::pure_rotate"] += 1 else: count_by_type[paint.getFormatName() + "::move_rotate"] += 1 elif (tr.dx, tr.dy) == (0, 0): count_by_type[paint.getFormatName() + "::inexplicable_2x2"] += 1 else: count_by_type[paint.getFormatName() + "::inexplicable_2x3"] += 1
def _picosvg_transform(self, affine): for idx, (el, (shape, )) in enumerate(self._elements()): self.elements[idx] = (el, (shape.apply_transform(affine), )) for el in self._select_gradients(): gradient = _GRADIENT_CLASSES[strip_ns(el.tag)].from_element( el, self.view_box()) gradient_transform = Affine2D.compose_ltr( (gradient.gradientTransform, affine)) if gradient_transform != Affine2D.identity(): el.attrib["gradientTransform"] = gradient_transform.tostring() elif "gradientTransform" in el.attrib: del el.attrib["gradientTransform"] return self
def _unnest_svg(self, svg: etree.Element, parent_width: float, parent_height: float) -> Tuple[etree.Element, ...]: x = float(svg.attrib.get("x", 0)) y = float(svg.attrib.get("y", 0)) width = float(svg.attrib.get("width", parent_width)) height = float(svg.attrib.get("height", parent_height)) viewport = viewbox = Rect(x, y, width, height) if "viewBox" in svg.attrib: viewbox = parse_view_box(svg.attrib["viewBox"]) # first recurse to un-nest any nested nested SVGs self._swap_elements((el, self._unnest_svg(el, viewbox.w, viewbox.h)) for el in self._iter_nested_svgs(svg)) g = etree.Element(f"{{{svgns()}}}g") g.extend(svg) if viewport != viewbox: preserve_aspect_ratio = svg.attrib.get("preserveAspectRatio", "xMidYMid") transform = Affine2D.rect_to_rect(viewbox, viewport, preserve_aspect_ratio) else: transform = Affine2D.identity().translate(x, y) if "transform" in svg.attrib: transform = Affine2D.compose_ltr( (transform, Affine2D.fromstring(svg.attrib["transform"]))) if transform != Affine2D.identity(): g.attrib["transform"] = transform.tostring() # TODO Define a viewport-sized clipPath once transform+clip-path issue is fixed # https://github.com/googlefonts/picosvg/issues/200 return (g, )
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 breadth_first(self) -> Generator[PaintTraverseContext, None, None]: frontier = [PaintTraverseContext((), self, Affine2D.identity())] while frontier: context = frontier.pop(0) yield context transform = context.transform paint_transform = context.paint.gettransform() transform = Affine2D.compose_ltr( ( transform, paint_transform, ) ) for paint in context.paint.children(): frontier.append( PaintTraverseContext( context.path + (context.paint,), paint, transform ) )
def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): # each glyph gets a group of its very own svg_g = svg.append_to("/svg:svg", etree.Element("g")) svg_g.attrib["id"] = f"glyph{color_glyph.glyph_id}" # https://github.com/googlefonts/nanoemoji/issues/58: group needs transform svg_g.attrib["transform"] = _svg_matrix( color_glyph.transform_for_otsvg_space()) # copy the shapes into our svg for painted_layer in color_glyph.as_painted_layers(): view_box = color_glyph.picosvg.view_box() if view_box is None: raise ValueError(f"{color_glyph.filename} must declare view box") reuse_key = _inter_glyph_reuse_key(view_box, painted_layer) if reuse_key not in reuse_cache.shapes: el = to_element(painted_layer.path) match = regex.match(r"url\(#([^)]+)*\)", el.attrib.get("fill", "")) if match: el.attrib[ "fill"] = f"url(#{reuse_cache.old_to_new_id.get(match.group(1), match.group(1))})" svg_g.append(el) reuse_cache.shapes[reuse_key] = el for reuse in painted_layer.reuses: _ensure_has_id(el) svg_use = etree.SubElement(svg_g, "use") svg_use.attrib["href"] = f'#{el.attrib["id"]}' tx, ty = reuse.gettranslate() if tx: svg_use.attrib["x"] = _ntos(tx) if ty: svg_use.attrib["y"] = _ntos(ty) transform = reuse.translate(-tx, -ty) if transform != Affine2D.identity(): # TODO apply scale and rotation. Just slap a transform on the <use>? raise NotImplementedError( "TODO apply scale & rotation to use") else: el = reuse_cache.shapes[reuse_key] _ensure_has_id(el) svg_use = etree.SubElement(svg_g, "use") svg_use.attrib["href"] = f'#{el.attrib["id"]}'
def _colr0_layers(color_glyph: ColorGlyph, root: Paint, palette: Sequence[Color]): # COLRv0: write out each PaintGlyph we see in it's first color # If we see a transformed glyph generate a component # Results for complex structures will be suboptimal :) ufo = color_glyph.ufo layers = [] for context in root.breadth_first(): if context.paint.format != PaintGlyph.format: # pytype: disable=attribute-error continue paint_glyph: PaintGlyph = (context.paint) # pytype: disable=annotation-type-mismatch color = next(paint_glyph.colors()) glyph_name = paint_glyph.glyph if context.transform != Affine2D.identity(): glyph_name = _create_transformed_glyph(color_glyph, paint_glyph, context.transform).name layers.append((glyph_name, palette.index(color))) return layers