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 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 _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_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 _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 _decompose_uniform_transform(transform: Affine2D) -> Tuple[Affine2D, Affine2D]: scale, remaining_transform = transform.decompose_scale() s = max(*scale.getscale()) # most transforms will contain a Y-flip component as result of mapping from SVG to # font coordinate space. Here we keep this negative Y sign as part of the uniform # transform since it does not affect the circle-ness, and also makes so that the # font-mapped gradient geometry is more likely to be in the +x,+y quadrant like # the path geometry it is applied to. uniform_scale = Affine2D(s, 0, 0, copysign(s, transform.d), 0, 0) remaining_transform = Affine2D.compose_ltr( (uniform_scale.inverse(), scale, remaining_transform) ) translate, remaining_transform = remaining_transform.decompose_translation() # round away very small float-math noise, so we get clean 0s and 1s for the special # case of identity matrix which implies no wrapping transform remaining_transform = remaining_transform.round(9) logging.debug( "Decomposing %r:\n\tscale: %r\n\ttranslate: %r\n\tremaining: %r", transform, uniform_scale, translate, remaining_transform, ) uniform_transform = Affine2D.compose_ltr((uniform_scale, translate)) return uniform_transform, remaining_transform
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_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 _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 map_viewbox_to_font_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: return Affine2D.compose_ltr( [ _scale_viewbox_to_font_metrics(view_box, ascender, descender, width), # flip y axis and shift so things are in the right place Affine2D(1, 0, 0, -1, 0, ascender), user_transform, ] )
def map_viewbox_to_otsvg_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: return Affine2D.compose_ltr( [ _scale_viewbox_to_font_metrics(view_box, ascender, descender, width), # shift things in the [+x,-y] quadrant where OT-SVG expects them Affine2D(1, 0, 0, 1, 0, -ascender), user_transform, ] )
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 _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 _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 _scale_viewbox_to_font_metrics( view_box: Rect, ascender: int, descender: int, width: int ): assert descender <= 0 # scale height to (ascender - descender) scale = (ascender - descender) / view_box.h # shift so width is centered dx = (width - scale * view_box.w) / 2 return Affine2D.compose_ltr( ( # first normalize viewbox origin Affine2D(1, 0, 0, 1, -view_box.x, -view_box.y), Affine2D(scale, 0, 0, scale, dx, 0), ) )
def _get_gradient_transform(grad_el, shape_bbox, view_box, upem) -> Affine2D: transform = map_viewbox_to_font_emsquare(view_box, upem) gradient_units = grad_el.attrib.get("gradientUnits", "objectBoundingBox") if gradient_units == "objectBoundingBox": bbox_space = Rect(0, 0, 1, 1) bbox_transform = Affine2D.rect_to_rect(bbox_space, shape_bbox) transform = Affine2D.product(bbox_transform, transform) if "gradientTransform" in grad_el.attrib: gradient_transform = Affine2D.fromstring( grad_el.attrib["gradientTransform"]) transform = Affine2D.product(gradient_transform, transform) return transform
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 normalize_flag_aspect(svg, viewbox_size, width, height, right_margin, top_margin): apply_viewbox_preserve_aspect_ratio(svg) current_box = _bbox(tuple(s.bounding_box() for s in svg.shapes())) # Try to keep overall proportions for the flags that are considerably # narrower or wider than the standard aspect ratio. aspect = current_box.w / current_box.h aspect /= STD_ASPECT aspect = sqrt(aspect) # Discount the effect if 0.9 <= aspect <= 1.1: aspect = 1.0 else: print("Non-standard aspect ratio:", aspect) xmin = _x_aspect(right_margin, aspect, viewbox_size) ymin = _y_aspect(top_margin, aspect, viewbox_size) xmax = _x_aspect(right_margin + width, aspect, viewbox_size) ymax = _y_aspect(top_margin + height, aspect, viewbox_size) new_box = Rect(xmin, ymin, xmax - xmin, ymax - ymin) affine = Affine2D.rect_to_rect(current_box, new_box) _picosvg_transform(svg, affine) square_viewbox = Rect(0, 0, viewbox_size, viewbox_size) svg.svg_root.attrib["viewBox"] = " ".join(ntos(v) for v in square_viewbox) for attr_name in ("width", "height"): if attr_name in svg.svg_root.attrib: del svg.svg_root.attrib[attr_name]
def map_viewbox_to_otsvg_emsquare(view_box: Rect, upem: int) -> Affine2D: x_scale, y_scale = _scale_viewbox_to_emsquare(view_box, upem) dx, dy = _shift_origin_0_0(view_box, x_scale, y_scale) # shift so things are in the right place dy = dy - upem return Affine2D(x_scale, 0, 0, y_scale, dx, dy)
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]) .skew(-radians(self.xSkewAngle), radians(self.ySkewAngle)) .translate(-self.center[0], -self.center[1]) )
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 _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 apply_viewbox_preserve_aspect_ratio(svg): """If viewport != viewBox apply the resulting transform and remove viewBox. Takes 'preserveAspectRatio' into account. E.g. The Qatar flag (QA.svg) needs this treatment. """ svg_root = svg.svg_root width = svg_root.attrib.get("width") height = svg_root.attrib.get("height") if width is not None and height is not None and "viewBox" in svg_root.attrib: # ignore absolute length units; we're only interested in the relative size # of viewport vs viewbox here width = SVG_UNITS_RE.sub("", width) height = SVG_UNITS_RE.sub("", height) viewport = Rect(0, 0, float(width), float(height)) viewbox = svg.view_box() if viewport != viewbox: transform = Affine2D.rect_to_rect( viewbox, viewport, svg_root.attrib.get("preserveAspectRatio", "xMidYMid"), ) _picosvg_transform(svg, transform) del svg_root.attrib["viewBox"] if "preserveAspectRatio" in svg_root.attrib: del svg_root.attrib["preserveAspectRatio"]
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 _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 test_addComponent_decompose_with_transform(): pen = SVGPathPen(glyphSet={"a": DummyGlyph()}) pen.addComponent("a", Affine2D(2, 0, 0, 2, 0, 0)) assert pen.path.d == ( "M0,0 L0,20 L20,20 L20,0 Z " "M0,30 C0,40 20,40 20,30 Z " "M0,-10 Q0,-16 3,-18 Q6,-20 10,-20 Q14,-20 17,-18 Q20,-16 20,-10")
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")