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 _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 _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 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 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 map_viewbox_to_font_emsquare(view_box: Rect, upem: int) -> Affine2D: x_scale, y_scale = _scale_viewbox_to_emsquare(view_box, upem) # flip y axis y_scale = -y_scale # shift so things are in the right place dx, dy = _shift_origin_0_0(view_box, x_scale, y_scale) dy = dy + upem return Affine2D(x_scale, 0, 0, y_scale, dx, dy)
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 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 _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 _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
return ufo def _test_file(filename): return os.path.join(os.path.dirname(__file__), filename) def _nsvg(filename): return SVG.parse(_test_file(filename)).topicosvg() @pytest.mark.parametrize( "view_box, upem, expected_transform", [ # same upem, flip y ("0 0 1024 1024", 1024, Affine2D(1, 0, 0, -1, 0, 1024)), # noto emoji norm. scale, flip y ("0 0 128 128", 1024, Affine2D(8, 0, 0, -8, 0, 1024)), # noto emoji emoji_u26be.svg viewBox. Scale, flip y and translate ("-151 297 128 128", 1024, Affine2D(8, 0, 0, -8, 1208, 3400)), # made up example. Scale, translate, flip y ( "10 11 20 21", 100, Affine2D(a=5.0, b=0, c=0, d=-4.761905, e=-50.0, f=152.380952), ), ], ) def test_transform(view_box, upem, expected_transform): svg_str = ('<svg version="1.1"' ' xmlns="http://www.w3.org/2000/svg"'
def _nsvg(filename): return SVG.parse(_test_file(filename)).topicosvg() def _pprint(thing): stream = io.StringIO() pprint.pprint(thing, indent=2, stream=stream) return stream.getvalue() @pytest.mark.parametrize( "view_box, upem, width, ascender, descender, expected_transform, expected_width", [ # same upem, flip y ("0 0 1024 1024", 1024, 1024, 1024, 0, Affine2D(1, 0, 0, -1, 0, 1024), 1024), # noto emoji norm. scale, flip y ("0 0 128 128", 1024, 1024, 1024, 0, Affine2D(8, 0, 0, -8, 0, 1024), 1024), # noto emoji emoji_u26be.svg viewBox. Scale, flip y and translate ( "-151 297 128 128", 1024, 1024, 1024, 0, Affine2D(8, 0, 0, -8, 1208, 3400), 1024, ), # made up example. Scale, translate, flip y, center horizontally (
def gettransform(self) -> Affine2D: return Affine2D(*self.transform)
), # path observed in wild to normalize but not compute affine_between # caused by failure to normalize equivalent d attributes in affine_between ( SVGPath(fill="#99AAB5", d="M18 12H2 c-1.104 0-2 .896-2 2h20c0-1.104-.896-2-2-2z"), SVGPath(fill="#99AAB5", d="M34 12H18c-1.104 0-2 .896-2 2h20c0-1.104-.896-2-2-2z"), Affine2D.identity().translate(16, 0), 0.01, ), # Triangles facing one another, same size ( SVGPath(d="m60,64 -50,-32 0,30 z"), SVGPath(d="m68,64 50,-32 0,30 z"), Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), 0.01, ), # Triangles, different rotation, different size ( SVGPath(d="m50,100 -48,-75 81,0 z"), SVGPath(d="m70,64 50,-32 0,54 z"), Affine2D( a=-0.0, b=0.6667, c=-0.6667, d=-0.0, e=136.6667, f=30.6667), 0.01, ), # TODO triangles, one point stretched not aligned with X or Y # A square and a rect; different scale for each axis ( SVGRect(x=10, y=10, width=50, height=50), SVGRect(x=70, y=20, width=20, height=100),
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