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_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_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 _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_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 _get_gradient_transform( config: FontConfig, grad_el: etree.Element, shape_bbox: Rect, view_box: Rect, glyph_width: int, ) -> Affine2D: transform = map_viewbox_to_font_space( view_box, config.ascender, config.descender, glyph_width, config.transform ) 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.compose_ltr((bbox_transform, transform)) if "gradientTransform" in grad_el.attrib: gradient_transform = Affine2D.fromstring(grad_el.attrib["gradientTransform"]) transform = Affine2D.compose_ltr((gradient_transform, transform)) return transform
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 _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 _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 _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 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 _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 _resolve_use(self, scope_el): attrib_not_copied = { "x", "y", "width", "height", "transform", _xlink_href_attr_name(), } # capture elements by id so even if we change it they remain stable el_by_id = {el.attrib["id"]: el for el in self.xpath(".//svg:*[@id]")} while True: swaps = [] use_els = list(self.xpath(".//svg:use", el=scope_el)) if not use_els: break for use_el in use_els: ref = use_el.attrib.get(_xlink_href_attr_name(), "") if not ref.startswith("#"): raise ValueError( f"Only use #fragment supported, reject {ref}") target = el_by_id.get(ref[1:], None) if target is None: raise ValueError(f"No element has id '{ref[1:]}'") new_el = copy.deepcopy(target) # leaving id's on <use> instantiated content is a path to duplicate ids for el in new_el.getiterator("*"): if "id" in el.attrib: del el.attrib["id"] group = etree.Element(f"{{{svgns()}}}g", nsmap=self.svg_root.nsmap) affine = Affine2D.identity().translate( float(use_el.attrib.get("x", 0)), float(use_el.attrib.get("y", 0))) if "transform" in use_el.attrib: affine = Affine2D.compose_ltr(( affine, Affine2D.fromstring(use_el.attrib["transform"]), )) if affine != Affine2D.identity(): group.attrib["transform"] = affine.tostring() for attr_name in use_el.attrib: if attr_name in attrib_not_copied: continue group.attrib[attr_name] = use_el.attrib[attr_name] if len(group.attrib): group.append(new_el) swaps.append((use_el, group)) else: swaps.append((use_el, new_el)) for old_el, new_el in swaps: old_el.getparent().replace(old_el, new_el)
def _colr_v1_paint_to_svg( ttfont: ttLib.TTFont, glyph_set: Mapping[str, Any], parent_el: etree.Element, svg_defs: etree.Element, font_to_vbox: Affine2D, ot_paint: otTables.Paint, reuse_cache: ReuseCache, transform: Affine2D = Affine2D.identity(), ): def descend(parent: etree.Element, paint: otTables.Paint): _colr_v1_paint_to_svg( ttfont, glyph_set, parent, svg_defs, font_to_vbox, paint, reuse_cache, transform=transform, ) if ot_paint.Format == PaintSolid.format: _apply_solid_ot_paint(parent_el, ttfont, ot_paint) elif ot_paint.Format in _GRADIENT_PAINT_FORMATS: _apply_gradient_ot_paint(svg_defs, parent_el, ttfont, font_to_vbox, ot_paint, reuse_cache, transform) elif ot_paint.Format == PaintGlyph.format: layer_glyph = ot_paint.Glyph svg_path = etree.SubElement(parent_el, "path") # This only occurs if path is reused; we could wire up use. But for now ... not. if transform != Affine2D.identity(): svg_transform = Affine2D.compose_ltr( (font_to_vbox.inverse(), transform, font_to_vbox)) svg_path.attrib["transform"] = _svg_matrix(svg_transform) # we must reset the current user space when setting the 'transform' # attribute on a <path>, since that already affects the gradients used # and we don't want the transform to be applied twice to gradients: # https://github.com/googlefonts/nanoemoji/issues/334 transform = Affine2D.identity() descend(svg_path, ot_paint.Paint) _draw_svg_path(svg_path, glyph_set, layer_glyph, font_to_vbox) elif is_transform(ot_paint.Format): paint = Paint.from_ot(ot_paint) transform @= paint.gettransform() descend(parent_el, ot_paint.Paint) elif ot_paint.Format == PaintColrLayers.format: layerList = ttfont["COLR"].table.LayerList.Paint assert layerList, "Paint layers without a layer list :(" for child_paint in layerList[ot_paint. FirstLayerIndex:ot_paint.FirstLayerIndex + ot_paint.NumLayers]: descend(parent_el, child_paint) elif ot_paint.Format == PaintComposite.format and ( ot_paint.CompositeMode == CompositeMode.SRC_IN and ot_paint.BackdropPaint.Format == PaintSolid.format): # Only simple group opacity for now color = _color( ttfont, ot_paint.BackdropPaint.PaletteIndex, ot_paint.BackdropPaint.Alpha, ) if color[:3] != (0, 0, 0): raise NotImplementedError(color) g = etree.SubElement(parent_el, "g") g.attrib["opacity"] = ntos(color.alpha) descend(g, ot_paint.SourcePaint) else: raise NotImplementedError(ot_paint.Format)
def affine_between(s1: SVGShape, s2: SVGShape, tolerance: float) -> Optional[Affine2D]: """Returns the Affine2D to change s1 into s2 or None if no solution was found. Intended use is to call this only when the normalized versions of the shapes are the same, in which case finding a solution is typical """ s1 = dataclasses.replace(s1, id="") s2 = dataclasses.replace(s2, id="") if s1.almost_equals(s2, tolerance): return Affine2D.identity() s1 = _affine_friendly(s1) s2 = _affine_friendly(s2) s1x, s1y = _first_move(s1) s2x, s2y = _first_move(s2) affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y) if _try_affine(affine, s1, s2, tolerance): return affine # Normalize first edge. # Fixes rotation, x-scale, and uniform scaling. s1_vec1 = _nth_vector(s1, 1) s2_vec1 = _nth_vector(s2, 1) s1_to_origin = Affine2D.identity().translate(-s1x, -s1y) s2_to_origin = Affine2D.identity().translate(-s2x, -s2y) s1_vec1_to_s2_vec1 = _affine_vec2vec(s1_vec1, s2_vec1) # Move to s2 start origin_to_s2 = Affine2D.identity().translate(s2x, s2y) affine = Affine2D.compose_ltr( (s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) if _try_affine(affine, s1, s2, tolerance): return _round(affine, s1, s2, tolerance) # Could be non-uniform scaling and/or mirroring # Scale first y movement (after matching up vec1) to match # Rotate first edge to lie on x axis s2_vec1_angle = _angle(s2_vec1) rotate_s2vec1_onto_x = Affine2D.identity().rotate(-s2_vec1_angle) rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle) affine = Affine2D.compose_ltr( (s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x)) s1_prime = _apply_affine(affine, s1) affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x)) s2_prime = _apply_affine(affine, s2) s1_vecy = _first_y(_vectors(s1_prime), tolerance) s2_vecy = _first_y(_vectors(s2_prime), tolerance) if s1_vecy and s2_vecy: affine = Affine2D.compose_ltr(( s1_to_origin, s1_vec1_to_s2_vec1, # lie vec1 along x axis rotate_s2vec1_onto_x, # scale first y-vectors to match; x-parts should already match Affine2D.identity().scale(1.0, s2_vecy.y / s1_vecy.y), # restore the rotation we removed rotate_s2vec1_off_x, # drop into final position origin_to_s2, )) if _try_affine(affine, s1, s2, tolerance): return _round(affine, s1, s2, tolerance) # If we still aren't the same give up return None