def _tidy_use_elements(svg: SVG): use_els = sorted(svg.xpath("//use"), key=_use_href) targets = {} for use_el in use_els: ref = _use_href(use_el) reused_el = svg.xpath_one(f'//svg:*[@id="{ref}"]') targets[ref] = reused_el duplicate_attrs = { attr_name for attr_name, attr_value in use_el.attrib.items() if attr_name in reused_el.attrib and attr_value == reused_el.attrib.get(attr_name) } for duplicate_attr in duplicate_attrs: del use_el.attrib[duplicate_attr] # If all <use> have the same paint attr migrate it from use to target for ref, uses in groupby(use_els, key=_use_href): uses = list(uses) target = targets[ref] for attr_name in sorted(_PAINT_ATTRIB_APPLY_PAINT_MAY_SET): values = [ use.attrib[attr_name] for use in uses if attr_name in use.attrib ] unique_values = set(values) if len(values) == len(uses) and len(unique_values) == 1: target.attrib[attr_name] = values[0] for use in uses: del use.attrib[attr_name]
def _paint_glyph( debug_hint: str, config: FontConfig, picosvg: SVG, context: SVGTraverseContext, glyph_width: int, ) -> Paint: shape = context.shape() if shape.fill.startswith("url("): fill_el = picosvg.resolve_url(shape.fill, "*") try: glyph_paint = _GRADIENT_INFO[etree.QName(fill_el).localname]( config, fill_el, shape.bounding_box(), picosvg.view_box(), glyph_width, shape.opacity, ) except ValueError as e: raise ValueError( f"parse failed for {debug_hint}, {etree.tostring(fill_el)[:128]}" ) from e else: glyph_paint = PaintSolid( color=Color.fromstring(shape.fill, alpha=shape.opacity) ) return PaintGlyph(glyph=shape.as_path().d, paint=glyph_paint)
def _run(argv): try: input_file = argv[1] except IndexError: input_file = None if input_file: svg = SVG.parse(input_file).topicosvg() else: svg = SVG.fromstring(sys.stdin.read()).topicosvg() if FLAGS.clip_to_viewbox: svg.clip_to_viewbox(inplace=True) tree = svg.toetree() # lxml really likes to retain whitespace for e in tree.iter("*"): e.text = _reduce_text(e.text) e.tail = _reduce_text(e.tail) output = etree.tostring(tree, pretty_print=True).decode("utf-8") if FLAGS.output_file == "-": print(output) else: with open(FLAGS.output_file, "w") as f: f.write(output)
def test_shapes_to_paths(shape: str, expected_path: str): actual = SVG.fromstring( svg_string(shape)).shapes_to_paths(inplace=True).toetree() expected_result = SVG.fromstring( svg_string(f"<path {expected_path}/>")).toetree() print(f"A: {pretty_print(actual)}") print(f"E: {pretty_print(expected_result)}") assert etree.tostring(actual) == etree.tostring(expected_result)
def test_svg_to_colr_to_svg(svg_in, expected_svg_out, config_overrides): config, glyph_inputs = test_helper.color_font_config( config_overrides, (svg_in, ), ) _, ttfont = write_font._generate_color_font(config, glyph_inputs) svg_before = SVG.parse(str(test_helper.locate_test_file(svg_in))) svgs_from_font = tuple( colr_to_svg(svg_before.view_box(), ttfont, rounding_ndigits=3).values()) assert len(svgs_from_font) == 1 svg_expected = SVG.parse( str(test_helper.locate_test_file(expected_svg_out))) test_helper.svg_diff(svgs_from_font[0], svg_expected)
def _rawsvg_docs( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph]) -> Sequence[Tuple[str, int, int]]: doc_list = [] for color_glyph in color_glyphs: svg = ( SVG.parse(color_glyph.filename) # all the scaling and positioning happens in "transform" below .remove_attributes(("width", "height", "viewBox"), inplace=True) # Firefox likes to render blank if present .remove_attributes(("enable-background", ), inplace=True)) g = etree.Element( "g", { # Map gid => svg doc "id": f"glyph{color_glyph.glyph_id}", # map viewBox to OT-SVG space (+x,-y) "transform": _svg_matrix( color_glyph.transform_for_otsvg_space()), }, ) # move all the elements under the new group g.extend(svg.svg_root) svg.svg_root.append(g) doc_list.append(( svg.tostring(pretty_print=config.pretty_print), color_glyph.glyph_id, color_glyph.glyph_id, )) return doc_list
def _migrate_to_defs( svg: SVG, reused_el: etree.Element, reuse_cache: ReuseCache, reuse_result: ReuseResult, ): svg_defs = svg.xpath_one("//svg:defs") if reused_el in svg_defs: return # nop tag = etree.QName(reused_el.tag).localname assert tag == "path", f"expected 'path', found '{tag}'" svg_use = etree.Element("use", nsmap=svg.svg_root.nsmap) svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" # if reused_el hasn't been given a parent yet just let the <use> replace it # otherwise move it from current to new parent if reused_el.getparent() is None: reuse_cache.glyph_elements[reuse_result.glyph_name] = svg_use else: reused_el.addnext(svg_use) svg_defs.append(reused_el) # append moves # sorted for diff stability for attr_name in sorted(_attrib_apply_paint_uses(reused_el)): svg_use.attrib[attr_name] = reused_el.attrib.pop(attr_name) return svg_use
def _picosvg_docs( config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph]) -> Sequence[Tuple[str, int, int]]: reuse_cache = ReuseCache(config.reuse_tolerance, GlyphReuseCache(config.reuse_tolerance)) reuse_groups = _glyph_groups(config, color_glyphs, reuse_cache) color_glyph_order = [c.glyph_name for c in color_glyphs] color_glyphs = {c.glyph_name: c for c in color_glyphs} _ensure_groups_grouped_in_glyph_order(color_glyphs, color_glyph_order, ttfont, reuse_groups) doc_list = [] for group in reuse_groups: reuse_cache.gradient_ids = {} # don't share gradients across groups # establish base svg, defs root = etree.Element( f"{{{svg_meta.svgns()}}}svg", {"version": "1.1"}, nsmap={ None: svg_meta.svgns(), "xlink": svg_meta.xlinkns() }, ) defs = etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) svg = SVG(root) for color_glyph in (color_glyphs[g] for g in group): _add_glyph(svg, color_glyph, reuse_cache) # tidy use elements, they may emerge from _add_glyph with unnecessary attributes _tidy_use_elements(svg) # sort <defs> by @id to increase diff stability defs[:] = sorted(defs, key=lambda e: e.attrib["id"]) # strip <defs/> if empty if len(defs) == 0: root.remove(defs) gids = tuple(color_glyphs[g].glyph_id for g in group) doc_list.append((svg.tostring(pretty_print=config.pretty_print), min(gids), max(gids))) return doc_list
def _colr_v0_to_svgs(view_box: Rect, ttfont: ttLib.TTFont) -> Dict[str, SVG]: glyph_set = ttfont.getGlyphSet() return { g: SVG.fromstring( etree.tostring( _colr_v0_glyph_to_svg(ttfont, glyph_set, view_box, g))) for g in ttfont["COLR"].ColorLayers }
def _colr_v1_to_svgs(view_box: Rect, ttfont: ttLib.TTFont) -> Dict[str, SVG]: glyph_set = ttfont.getGlyphSet() return { g.BaseGlyph: SVG.fromstring( etree.tostring( _colr_v1_glyph_to_svg(ttfont, glyph_set, view_box, g))) for g in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord }
def _inputs( glyph_mappings: Sequence[GlyphMapping], ) -> Generator[InputGlyph, None, None]: for g in glyph_mappings: try: picosvg = SVG.parse(str(g.svg_file)) except etree.ParseError as e: raise IOError(f"Unable to parse {g.svg_file}") from e yield InputGlyph(g.svg_file, g.codepoints, g.glyph_name, picosvg)
def test_transform(view_box, upem, expected_transform): svg_str = ('<svg version="1.1"' ' xmlns="http://www.w3.org/2000/svg"' f' viewBox="{view_box}"' "/>") color_glyph = ColorGlyph.create(_ufo(upem), "duck", 1, [0x0042], SVG.fromstring(svg_str)) assert color_glyph.transform_for_font_space() == pytest.approx( expected_transform)
def main(): try: input_file = sys.argv[1] except IndexError: input_file = None if input_file: svg = SVG.parse(input_file).topicosvg() else: svg = SVG.fromstring(sys.stdin.read()).topicosvg() tree = svg.toetree() # lxml really likes to retain whitespace for e in tree.iter("*"): e.text = _reduce_text(e.text) e.tail = _reduce_text(e.tail) print(etree.tostring(tree, pretty_print=True).decode("utf-8"))
def test_apply_gradient_translation(gradient_string, expected_result): svg = SVG.fromstring( svg_string(gradient_string))._apply_gradient_translation() el = svg.xpath_one("//svg:linearGradient | //svg:radialGradient") for node in svg.svg_root.getiterator(): node.tag = etree.QName(node).localname etree.cleanup_namespaces(svg.svg_root) assert etree.tostring(el).decode("utf-8") == expected_result
def test_common_attrib(shape, expected_fields): svg = SVG.fromstring(shape) field_values = dataclasses.asdict(svg.shapes()[0]) for field_name, expected_value in expected_fields.items(): assert field_values.get(field_name, "") == expected_value, field_name svg = svg.shapes_to_paths() field_values = dataclasses.asdict(svg.shapes()[0]) for field_name, expected_value in expected_fields.items(): assert field_values.get(field_name, "") == expected_value, field_name
def _paint( debug_hint: str, config: FontConfig, picosvg: SVG, shape: SVGPath, glyph_width: int ) -> Paint: if shape.fill.startswith("url("): el = picosvg.resolve_url(shape.fill, "*") try: return _GRADIENT_INFO[etree.QName(el).localname]( config, el, shape.bounding_box(), picosvg.view_box(), glyph_width, shape.opacity, ) except ValueError as e: raise ValueError( f"parse failed for {debug_hint}, {etree.tostring(el)[:128]}" ) from e return PaintSolid(color=Color.fromstring(shape.fill, alpha=shape.opacity))
def _inputs(codepoints: Mapping[str, Tuple[int, ...]], svg_files: Iterable[str]) -> Generator[InputGlyph, None, None]: for svg_file in svg_files: rgi = codepoints.get(os.path.basename(svg_file), None) if not rgi: raise ValueError(f"No codepoint sequence for {svg_file}") try: picosvg = SVG.parse(svg_file) except etree.ParseError as e: raise IOError(f"Unable to parse {svg_file}") from e yield InputGlyph(svg_file, rgi, picosvg)
def create( font_config: FontConfig, ufo: ufoLib2.Font, filename: str, glyph_id: int, glyph_name: str, codepoints: Tuple[int, ...], svg: SVG, ) -> "ColorGlyph": logging.debug(" ColorGlyph for %s (%s)", filename, codepoints) base_glyph = ufo.newGlyph(glyph_name) # non-square aspect ratio == proportional width; square == monospace view_box = svg.view_box() if view_box is not None: base_glyph.width = _color_glyph_advance_width(view_box, font_config) else: base_glyph.width = font_config.width # Setup direct access to the glyph if possible if len(codepoints) == 1: base_glyph.unicode = next(iter(codepoints)) # Grab the transform + (color, glyph) layers unless they aren't to be touched # or cannot possibly paint painted_layers = () if not font_config.transform.is_degenerate(): if font_config.has_picosvgs: painted_layers = tuple( _painted_layers( filename, font_config, svg, base_glyph.width, ) ) return ColorGlyph( ufo, filename, glyph_name, glyph_id, codepoints, painted_layers, svg, font_config.transform, )
def test_transform_and_width(view_box, upem, width, ascender, descender, expected_transform, expected_width): svg_str = ('<svg version="1.1"' ' xmlns="http://www.w3.org/2000/svg"' f' viewBox="{view_box}"' "><defs/></svg>") config = FontConfig(upem=upem, width=width, ascender=ascender, descender=descender).validate() ufo = _ufo(config) color_glyph = ColorGlyph.create(config, ufo, "duck", 1, "glyph_name", [0x0042], SVG.fromstring(svg_str)) assert color_glyph.transform_for_font_space() == pytest.approx( expected_transform) assert ufo[color_glyph.glyph_name].width == expected_width
def _rawsvg_docs( ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph]) -> Sequence[Tuple[str, int, int]]: doc_list = [] for color_glyph in color_glyphs: svg = ( SVG.parse(color_glyph.filename) # dumb sizing isn't useful .remove_attributes(("width", "height"), inplace=True) # Firefox likes to render blank if present .remove_attributes(("enable-background", ), inplace=True) # Map gid => svg doc .set_attributes((("id", f"glyph{color_glyph.glyph_id}"), ))) svg.svg_root.attrib[ "transform"] = f"translate(0, {-color_glyph.ufo.info.unitsPerEm})" doc_list.append( (svg.tostring(), color_glyph.glyph_id, color_glyph.glyph_id)) return doc_list
def main(argv): if len(argv) > 2: sys.exit("Expected Only 1 non-flag Argument.") symbol = Symbol() pico = SVG.parse(argv[1]).topicosvg() main_svg = pico.xpath_one("//svg:svg") symbol.write_icon( _REQUIRED_SYMBOL, svgLib.SVGPath.fromstring(pico.tostring()), SVGPathPen(None), Rect( 0, 0, parse_float(main_svg.get("width")), parse_float(main_svg.get("height")), ), ) symbol.drop_empty_icons() symbol.write_to(FLAGS.out)
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 _picosvg_docs( ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph]) -> Sequence[Tuple[str, int, int]]: reuse_groups = _glyph_groups(color_glyphs) color_glyphs = {c.glyph_name: c for c in color_glyphs} _ensure_groups_grouped_in_glyph_order(color_glyphs, ttfont, reuse_groups) doc_list = [] reuse_cache = ReuseCache() layers = {} # reusable layers for group in reuse_groups: # establish base svg, defs svg = SVG.fromstring( r'<svg version="1.1" xmlns="http://www.w3.org/2000/svg"><defs/></svg>' ) svg_defs = svg.xpath_one("//svg:defs") for color_glyph in (color_glyphs[g] for g in group): _add_unique_gradients(svg_defs, color_glyph, reuse_cache) _add_glyph(svg, color_glyph, reuse_cache) gids = tuple(color_glyphs[g].glyph_id for g in group) doc_list.append((svg.tostring(), min(gids), max(gids))) return doc_list
def test_default_for_blank(svg_content, expected_result): assert tuple(SVG.fromstring( svg_string(svg_content)).shapes()) == expected_result
def test_tolerance(svg_string, expected_result): assert round(SVG.fromstring(svg_string).tolerance, 4) == expected_result
def test_remove_attributes(svg_string, names, expected_result): assert (SVG.fromstring(svg_string).remove_attributes(names).tostring() ) == expected_result
def test_iter(shape, expected_cmds): svg_path = SVG.fromstring(svg_string(shape)).shapes_to_paths().shapes()[0] actual_cmds = [t for t in svg_path] print(f"A: {actual_cmds}") print(f"E: {expected_cmds}") assert actual_cmds == expected_cmds
def _nsvg(filename): return SVG.parse(_test_file(filename)).topicosvg()
def test_viewbox(svg_string, expected_result): assert SVG.fromstring(svg_string).view_box() == expected_result
def _new_symbol(): return SVG.parse( os.path.join(os.path.dirname(__file__), "symbol_template.svg"))