diff --git a/src/nanoemoji/color_glyph.py b/src/nanoemoji/color_glyph.py index 05c2d399..6dec3c54 100644 --- a/src/nanoemoji/color_glyph.py +++ b/src/nanoemoji/color_glyph.py @@ -475,29 +475,6 @@ def _has_viewbox_for_transform(self) -> bool: ) return view_box is not None - 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_glyph.width, - self.user_transform, - ) - - def transform_for_otsvg_space(self): - return self._transform(map_viewbox_to_otsvg_space) - - def transform_for_font_space(self): - print() - print( - self.svg_filename, - self.svg.view_box(), - self._transform(map_viewbox_to_font_space), - ) - return self._transform(map_viewbox_to_font_space) - @property def ufo_glyph(self) -> UfoGlyph: return self.ufo[self.ufo_glyph_name] diff --git a/src/nanoemoji/glyph_reuse.py b/src/nanoemoji/glyph_reuse.py index cfb1b1f9..df748c93 100644 --- a/src/nanoemoji/glyph_reuse.py +++ b/src/nanoemoji/glyph_reuse.py @@ -45,7 +45,6 @@ def try_reuse(self, path: str, path_view_box: Rect) -> ReuseResult: assert path[0].upper() == "M", path path = SVGPath(d=path) - path_view_box = ReusableParts.view_box_for((path_view_box,)) if path_view_box != self._reusable_parts.view_box: print(path, path_view_box, self._reusable_parts.view_box) path = path.apply_transform( diff --git a/src/nanoemoji/nanoemoji.py b/src/nanoemoji/nanoemoji.py index 1292ee89..12cd88e3 100644 --- a/src/nanoemoji/nanoemoji.py +++ b/src/nanoemoji/nanoemoji.py @@ -261,7 +261,7 @@ def write_preamble(nw): module_rule( nw, "write_part_file", - f"--reuse_tolerance $reuse_tolerance --output_file $out $in", + f"--reuse_tolerance $reuse_tolerance --upem $upem --output_file $out $in", ) nw.newline() @@ -360,19 +360,18 @@ def master_part_file_dest() -> Path: def write_picosvg_builds( picosvg_builds: Set[Path], nw: NinjaWriter, - clipped: bool, - reuse_tolerance: float, + font_config: FontConfig, master: MasterConfig, ) -> Tuple[Set[Path], Set[Path]]: rule_name = "picosvg_unclipped" - if clipped: + if font_config.clip_to_viewbox: rule_name = "picosvg_clipped" picosvgs = set() part_files = set() for svg_file in master.sources: svg_file = abspath(svg_file) - dest = picosvg_dest(clipped, svg_file) + dest = picosvg_dest(font_config.clip_to_viewbox, svg_file) if svg_file in picosvg_builds: continue picosvg_builds.add(svg_file) @@ -383,7 +382,7 @@ def write_picosvg_builds( part_dest, "write_part_file", dest, - variables={"reuse_tolerance": reuse_tolerance}, + variables={"reuse_tolerance": font_config.reuse_tolerance, "upem": font_config.upem}, ) picosvgs.add(dest) @@ -667,8 +666,7 @@ def _run(argv): _, parts = write_picosvg_builds( picosvg_builds, nw, - font_config.clip_to_viewbox, - font_config.reuse_tolerance, + font_config, master, ) part_files |= parts diff --git a/src/nanoemoji/parts.py b/src/nanoemoji/parts.py index 5f0e3a0b..f78b5612 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -14,15 +14,20 @@ """A cache of reusable parts, esp paths, for whatever purpose you see fit. Intended to be used as a building block for glyph reuse. + +We always apply nop transforms to ensure any command type flips, such as arcs +to cubics, occur. This ensures that if we merge a part file with no transform +with one that has transformation the command types still align. """ import dataclasses -from functools import lru_cache +from functools import lru_cache, partial, reduce import json from nanoemoji.config import FontConfig from pathlib import Path from picosvg.geometric_types import Rect from picosvg.svg import SVG +from picosvg.svg_meta import cmd_coords from picosvg.svg_reuse import affine_between, normalize from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath, SVGShape @@ -86,15 +91,17 @@ def _bbox_area(shape: Shape) -> float: return bbox.w * bbox.h -def _round(path: SVGShape) -> SVGPath: - return path.as_path().round_floats(_DEFAULT_ROUND_NDIGITS) +def _round(shape: SVGShape) -> SVGPath: + return shape.as_path().round_floats(_DEFAULT_ROUND_NDIGITS) -def as_shape(shape: SVGPath) -> Shape: - return Shape(_round(shape).d) +def as_shape(path: SVGPath) -> Shape: + # apply a nop transform because some things still change, like arcs to cubics + path = path.apply_transform(Affine2D.identity()) + return Shape(_round(path).d) -# TODO: create a parts builder and a frozen parts to more explicitly model the add/use cycle +# TODO: create a parts builder and a frozen parts from compute_donors() to more explicitly model the add/use cycle @dataclasses.dataclass @@ -112,7 +119,13 @@ class ReusableParts: def normalize(self, path: str) -> NormalizedShape: if self.reuse_tolerance != -1: # normalize handles it's own rounding - norm = NormalizedShape(normalize(SVGPath(d=path), self.reuse_tolerance).d) + # apply a nop transform because some things still change, like arcs to cubics + norm = NormalizedShape( + normalize( + SVGPath(d=path).apply_transform(Affine2D.identity()), + self.reuse_tolerance, + ).d + ) else: norm = NormalizedShape(path) return norm @@ -131,24 +144,23 @@ def add(self, source: PathSource): """Combine two sets of parts. Source shapes will be scaled to dest viewbox.""" if isinstance(source, ReusableParts): transform = Affine2D.rect_to_rect(source.view_box, self.view_box) - for normalized, shape_set in source.shape_sets.items(): - for shape in shape_set: - if transform != Affine2D.identity(): - shape = as_shape(SVGPath(d=shape).apply_transform(transform)) - self._add_norm_path(normalized, shape) + shapes = tuple( + reduce(lambda a, c: a | c, source.shape_sets.values(), set()) + ) elif isinstance(source, SVG): source.checkpicosvg() - transform = Affine2D.rect_to_rect( - ReusableParts.view_box_for((source.view_box(),)), self.view_box - ) - for svg_shape in source.shapes(): - svg_shape = svg_shape.as_path() - if transform != Affine2D.identity(): - svg_shape = svg_shape.apply_transform(transform) - self._add(as_shape(svg_shape)) + transform = Affine2D.rect_to_rect(source.view_box(), self.view_box) + shapes = tuple(s.as_path() for s in source.shapes()) else: raise ValueError(f"Unknown part source: {type(source)}") + for shape in shapes: + if isinstance(shape, str): + shape = SVGPath(d=shape) + if transform != Affine2D.identity(): + shape = shape.apply_transform(transform) + self._add(as_shape(shape)) + def _compute_donor(self, norm: NormalizedShape): self._donor_cache[norm] = None # no solution @@ -157,7 +169,7 @@ def _compute_donor(self, norm: NormalizedShape): # shrinking a big thing is more likely to result in small #s that fit into # more compact PaintTransform variants so try biggest first - # TODO there are cases where this picks a suboptimal transform, e.g. a 2x3 + # NOTE there are cases where this picks a suboptimal transform, e.g. a 2x3 # downscale be used when a scale uniform around center upscale might work # Ex SVGPath(d="M8,13 A3 3 0 1 1 2,13 A3 3 0 1 1 8,13 Z") # SVGPath(d="M11,5 A2 2 0 1 1 7,5 A2 2 0 1 1 11,5 Z") @@ -249,52 +261,38 @@ def to_json(self): return json.dumps(json_dict, indent=2) @classmethod - def fromstring(cls, string) -> "ReusableParts": - first = string.strip()[0] - parts = cls() - if first == "<": - svg = SVG.fromstring(string).topicosvg() - parts.view_box = ReusableParts.view_box_for((svg.view_box(),)) - for svg_shape in svg.shapes(): - parts._add(as_shape(svg_shape)) - elif first == "{": - json_dict = json.loads(string) - parts.version = tuple(int(v) for v in json_dict.pop("version").split(".")) - assert parts.version == (1, 0, 0), f"Bad version {parts.version}" - parts.view_box = Rect( - *(int(v) for v in json_dict.pop("view_box").split(" ")) - ) - assert parts.view_box[:2] == ( - 0, - 0, - ), f"Must be a viewbox from 0,0 {parts.view_box}" - parts.reuse_tolerance = float(json_dict.pop("reuse_tolerance")) - for shape_set_json in json_dict.pop("shape_sets"): - norm = NormalizedShape(shape_set_json.pop("normalized")) - shapes = ShapeSet({Shape(s) for s in shape_set_json.pop("shapes")}) - donor = shape_set_json.pop("donor") - if donor and donor not in shapes: - raise ValueError("Donor must be in group") - if shape_set_json: - raise ValueError(f"Unconsumed input {shape_set_json}") - parts.shape_sets[norm] = shapes - if donor != "": - parts._donor_cache[norm] = donor - if json_dict: - raise ValueError(f"Unconsumed input {json_dict}") - - else: - raise ValueError(f"Unrecognized start sequence {string[:16]}") + def from_json(cls, string: str) -> "ReusableParts": + json_dict = json.loads(string) + parts = ReusableParts() + parts.version = tuple(int(v) for v in json_dict.pop("version").split(".")) + assert parts.version == (1, 0, 0), f"Bad version {parts.version}" + parts.view_box = Rect( + *(int(v) for v in json_dict.pop("view_box").split(" ")) + ) + assert parts.view_box[:2] == ( + 0, + 0, + ), f"Must be a viewbox from 0,0 {parts.view_box}" + parts.reuse_tolerance = float(json_dict.pop("reuse_tolerance")) + for shape_set_json in json_dict.pop("shape_sets"): + norm = NormalizedShape(shape_set_json.pop("normalized")) + shapes = ShapeSet({Shape(s) for s in shape_set_json.pop("shapes")}) + donor = shape_set_json.pop("donor") + if donor and donor not in shapes: + raise ValueError("Donor must be in group") + if shape_set_json: + raise ValueError(f"Unconsumed input {shape_set_json}") + parts.shape_sets[norm] = shapes + if donor != "": + parts._donor_cache[norm] = donor + if json_dict: + raise ValueError(f"Unconsumed input {json_dict}") return parts @classmethod - def load(cls, input_file: Path) -> "ReusableParts": + def loadjson(cls, input_file: Path) -> "ReusableParts": ext = input_file.suffix.lower() - if ext not in {".svg", ".json"}: + if ext != ".json": raise ValueError(f"Unknown format {input_file}") - return cls.fromstring(input_file.read_text(encoding="utf-8")) + return cls.from_json(input_file.read_text(encoding="utf-8")) - @staticmethod - def view_box_for(view_boxes: Iterable[Rect]) -> Rect: - max_h = max((v.h for v in view_boxes)) - return Rect(0, 0, max_h, max_h) diff --git a/src/nanoemoji/svg.py b/src/nanoemoji/svg.py index 6dd0001e..52a36551 100644 --- a/src/nanoemoji/svg.py +++ b/src/nanoemoji/svg.py @@ -21,7 +21,7 @@ from functools import reduce from lxml import etree # pytype: disable=import-error from nanoemoji.colors import Color -from nanoemoji.color_glyph import ColorGlyph +from nanoemoji.color_glyph import map_viewbox_to_otsvg_space, ColorGlyph from nanoemoji.config import FontConfig from nanoemoji.disjoint_set import DisjointSet from nanoemoji.glyph_reuse import GlyphReuseCache @@ -457,7 +457,27 @@ def _create_use_element( return svg_use -def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): +def _font_units_to_svg_units(view_box: Rect, config: FontConfig, glyph_width: int) -> Affine2D: + return map_viewbox_to_otsvg_space( + view_box, + config.ascender, + config.descender, + glyph_width, + config.transform, + ) + + +def _svg_units_to_font_units(view_box: Rect, config: FontConfig, glyph_width: int) -> Affine2D: + return map_viewbox_to_otsvg_space( + view_box, + config.ascender, + config.descender, + glyph_width, + config.transform, + ) + + +def _add_glyph(config: FontConfig, svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): svg_defs = svg.xpath_one("//svg:defs") # each glyph gets a group of its very own @@ -469,11 +489,11 @@ def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): raise ValueError(f"{color_glyph.svg_filename} must declare view box") # https://github.com/googlefonts/nanoemoji/issues/58: group needs transform - transform = color_glyph.transform_for_otsvg_space() + transform = _font_units_to_svg_units(reuse_cache.glyph_cache.view_box(), config, color_glyph.ufo_glyph.width) if not transform.almost_equals(Affine2D.identity()): svg_g.attrib["transform"] = _svg_matrix(transform) - vbox_to_upem = color_glyph.transform_for_font_space() + vbox_to_upem = _svg_units_to_font_units(reuse_cache.glyph_cache.view_box(), config, color_glyph.ufo_glyph.width) upem_to_vbox = vbox_to_upem.inverse() # copy the shapes into our svg @@ -726,7 +746,7 @@ def _picosvg_docs( for color_glyph in (color_glyphs[g] for g in group): if color_glyph.painted_layers: - _add_glyph(svg, color_glyph, reuse_cache) + _add_glyph(config, svg, color_glyph, reuse_cache) # tidy use elements, they may emerge from _add_glyph with unnecessary attributes _tidy_use_elements(svg) @@ -767,7 +787,7 @@ def _rawsvg_docs( # 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()), + "transform": _svg_matrix(_font_units_to_svg_units(color_glyph.svg.view_box(), config, color_glyph.ufo_glyph.width)), }, ) # move all the elements under the new group diff --git a/src/nanoemoji/write_combined_part_files.py b/src/nanoemoji/write_combined_part_files.py index b20917ac..76b83882 100644 --- a/src/nanoemoji/write_combined_part_files.py +++ b/src/nanoemoji/write_combined_part_files.py @@ -29,14 +29,14 @@ def main(argv): input_files = util.expand_ninja_response_files(argv[1:]) combined_parts = ReusableParts() - individual_parts = [ReusableParts.load(Path(p)) for p in input_files] + individual_parts = [ReusableParts.loadjson(Path(p)) for p in input_files] if individual_parts: combined_parts.version = util.only({p.version for p in individual_parts}) combined_parts.reuse_tolerance = util.only( {p.reuse_tolerance for p in individual_parts} ) - combined_parts.view_box = ReusableParts.view_box_for( - (p.view_box for p in individual_parts) + combined_parts.view_box = util.only( + {p.view_box for p in individual_parts} ) for parts in individual_parts: diff --git a/src/nanoemoji/write_font.py b/src/nanoemoji/write_font.py index e18a61e0..af76b54d 100644 --- a/src/nanoemoji/write_font.py +++ b/src/nanoemoji/write_font.py @@ -272,6 +272,10 @@ def _create_glyph(color_glyph: ColorGlyph, path_in_font_space: str) -> Glyph: ufo = color_glyph.ufo draw_svg_path(SVGPath(d=path_in_font_space), glyph.getPen()) ufo.glyphOrder += [glyph.name] + print(glyph) + for contour in glyph.contours: + for i, pt in enumerate(contour.points): + print(" ", f"{i}:", pt) return glyph @@ -289,19 +293,23 @@ def _svg_transform_in_font_space( ) +def _svg_units_to_font_units(glyph_cache: GlyphReuseCache, config: FontConfig, glyph_width: int) -> Affine2D: + return map_viewbox_to_font_space( + glyph_cache.view_box(), + config.ascender, + config.descender, + glyph_width, + config.transform, + ) + + def _create_glyph_for_svg_path( config: FontConfig, color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache, path_in_svg_space: str, ) -> Glyph: - svg_units_to_font_units = map_viewbox_to_font_space( - glyph_cache.view_box(), - config.ascender, - config.descender, - color_glyph.ufo_glyph.width, - config.transform, - ) + svg_units_to_font_units = _svg_units_to_font_units(glyph_cache, config, color_glyph.ufo_glyph.width) path_in_font_space = ( SVGPath(d=path_in_svg_space).apply_transform(svg_units_to_font_units).d ) @@ -368,6 +376,8 @@ def _create_glyphs_for_svg_paths( assert paint.glyph.startswith("M"), f"{paint.glyph} doesn't look like a path" path_in_svg_space = paint.glyph + print("_create_glyphs_for_svg_paths") + maybe_reuse = glyph_cache.try_reuse(path_in_svg_space, color_glyph.svg.view_box()) # if we have a glyph for the target already, use that @@ -378,7 +388,7 @@ def _create_glyphs_for_svg_paths( else: # TODO: when is it more compact to use a new transforming glyph? # otherwise, create a glyph for the target and use it - print("create glyph for", maybe_reuse.shape) + print(" ", "create glyph for", maybe_reuse.shape) glyph = _create_glyph_for_svg_path( config, color_glyph, glyph_cache, maybe_reuse.shape ) @@ -387,10 +397,14 @@ def _create_glyphs_for_svg_paths( if not maybe_reuse.transform.almost_equals(Affine2D.identity()): # TODO: when is it more compact to use a new transforming glyph? - svg_units_to_font_units = color_glyph.transform_for_font_space() + svg_units_to_font_units = _svg_units_to_font_units(glyph_cache, config, color_glyph.ufo_glyph.width) reuse_transform = _svg_transform_in_font_space( svg_units_to_font_units, maybe_reuse.transform ) + + print(" ", "reuse, maybe_reuse.transform", maybe_reuse.transform) + print(" ", "reuse, svg_units_to_font_units", svg_units_to_font_units) + print(" ", "reuse, reuse_transform", reuse_transform) # assert fixed_safe(*reuse_transform), f"{color_glyph.svg_filename} {color_glyph.ufo_glyph_name} fixed unsafe {reuse_transform} to reuse {maybe_reuse.shape} for {path_in_svg_space}" # might need to adjust a gradient @@ -468,7 +482,6 @@ def _glyf_ufo( "%s %s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, - color_glyph.transform_for_font_space(), ) parent_glyph = color_glyph.ufo_glyph @@ -527,6 +540,7 @@ def _create_transformed_glyph( glyph = _init_glyph(color_glyph) glyph.components.append(Component(baseGlyph=paint.glyph, transformation=transform)) color_glyph.ufo.glyphOrder += [glyph.name] + print("_create_transformed_glyph", glyph.name, transform) return glyph @@ -688,10 +702,9 @@ def _colr_ufo( quantization = round(config.upem * 0.02) for i, color_glyph in enumerate(color_glyphs): logging.debug( - "%s %s %s", + "%s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, - color_glyph.transform_for_font_space(), ) # generate glyphs for PaintGlyph's and assign glyph names @@ -793,6 +806,7 @@ def _generate_color_font( config: FontConfig, reusable_parts: ReusableParts, inputs: Iterable[InputGlyph] ): """Make a UFO and optionally a TTFont from svgs.""" + print("_generate_color_font", "upem", config.upem) ufo = _ufo(config) _ensure_codepoints_will_have_glyphs(ufo, inputs) @@ -905,7 +919,7 @@ def main(argv): reusable_parts = ReusableParts() if FLAGS.part_file: - reusable_parts = ReusableParts.load(Path(FLAGS.part_file)) + reusable_parts = ReusableParts.loadjson(Path(FLAGS.part_file)) ufo, ttfont = _generate_color_font(font_config, reusable_parts, inputs) _write(ufo, ttfont, font_config.output_file) diff --git a/src/nanoemoji/write_part_file.py b/src/nanoemoji/write_part_file.py index a00a86b9..f92209da 100644 --- a/src/nanoemoji/write_part_file.py +++ b/src/nanoemoji/write_part_file.py @@ -21,6 +21,8 @@ from nanoemoji.parts import ReusableParts from nanoemoji import util from pathlib import Path +from picosvg.geometric_types import Rect +from picosvg.svg import SVG FLAGS = flags.FLAGS @@ -32,7 +34,12 @@ def main(argv): if len(argv) != 2: raise ValueError("Specify exactly one input") - parts = ReusableParts.load(Path(argv[1])) + + view_box = Rect(0, 0, FLAGS.upem, FLAGS.upem) + parts = ReusableParts(view_box=view_box, reuse_tolerance=FLAGS.reuse_tolerance) + + svg = SVG.parse(Path(argv[1])) + parts.add(svg) if FLAGS.compute_donors: parts.compute_donors() @@ -42,4 +49,6 @@ def main(argv): if __name__ == "__main__": + flags.mark_flag_as_required("upem") + flags.mark_flag_as_required("reuse_tolerance") app.run(main) diff --git a/tests/circle.svg b/tests/circle.svg new file mode 100644 index 00000000..1c39dab6 --- /dev/null +++ b/tests/circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/circle_10x.svg b/tests/circle_10x.svg new file mode 100644 index 00000000..11c9c7b9 --- /dev/null +++ b/tests/circle_10x.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/clocks_picosvg.ttx b/tests/clocks_picosvg.ttx index c79c3a5c..02c200f9 100644 --- a/tests/clocks_picosvg.ttx +++ b/tests/clocks_picosvg.ttx @@ -67,36 +67,36 @@ - - - - + + + + - + - - - - - - - + + + + + + + - + - - - - - - - + + + + + + + diff --git a/tests/clocks_rects_picosvg.ttx b/tests/clocks_rects_picosvg.ttx index d0c1548f..41987f3c 100644 --- a/tests/clocks_rects_picosvg.ttx +++ b/tests/clocks_rects_picosvg.ttx @@ -79,7 +79,7 @@ - + diff --git a/tests/parts_test.py b/tests/parts_test.py index afdcd1b2..c8cef3b2 100644 --- a/tests/parts_test.py +++ b/tests/parts_test.py @@ -16,10 +16,13 @@ from nanoemoji.util import only from picosvg.geometric_types import Rect from picosvg.svg import SVG +from picosvg import svg_meta from picosvg.svg_types import SVGCircle, SVGPath, SVGRect from picosvg.svg_reuse import affine_between +from pathlib import Path import pprint import pytest +import re from test_helper import cleanup_temp_dirs, locate_test_file, mkdtemp @@ -37,14 +40,45 @@ def _cleanup_temporary_dirs(): # TODO we get pointless precision, e.g. 1.2000000000000002 +def _svg_commands(path: str) -> str: + print(path) + svg_cmds = "".join(svg_meta.cmds()) + return re.sub(f"[^{svg_cmds}]+", "", path) + + def check_num_shapes(parts: ReusableParts, expected_shape_sets: int): assert len(parts.shape_sets) == expected_shape_sets, ",".join( sorted(str(p) for p in parts.shape_sets.keys()) ) +def _from_svg(svg, view_box=None) -> ReusableParts: + if isinstance(svg, str): + svg = SVG.fromstring(svg) + elif isinstance(svg, Path): + svg = SVG.parse(svg) + if view_box is None: + view_box = svg.view_box() + parts = ReusableParts(view_box=view_box) + parts.add(svg) + return parts + + +def test_add_svg(): + parts = _from_svg( + """ + + + + + """ + ) + check_num_shapes(parts, 1) + + def test_collects_normalized_shapes(): - parts = ReusableParts.fromstring( + parts = _from_svg( """ @@ -57,13 +91,8 @@ def test_collects_normalized_shapes(): check_num_shapes(parts, 2) -def test_from_svg(): - parts = ReusableParts.load(locate_test_file("rect.svg")) - check_num_shapes(parts, 1) - - -def test_merge(): - p1 = ReusableParts.fromstring( +def test_simple_merge(): + p1 = _from_svg( """ @@ -72,7 +101,7 @@ def test_merge(): ) check_num_shapes(p1, 1) - p2 = ReusableParts.fromstring( + p2 = _from_svg( """ @@ -87,15 +116,14 @@ def test_merge(): def test_file_io(): - parts = ReusableParts() - parts.add(ReusableParts.load(locate_test_file("rect.svg"))) + parts = _from_svg(locate_test_file("rect.svg")) check_num_shapes(parts, 1) tmp_dir = mkdtemp() tmp_file = tmp_dir / "rect.json" tmp_file.write_text(parts.to_json()) - assert parts == ReusableParts.load(tmp_file), parts.to_json() + assert parts == ReusableParts.loadjson(tmp_file), parts.to_json() # Note that this is not meant to be the primary test of reuse, that's in @@ -123,7 +151,7 @@ def test_file_io(): ], ) def test_reuse_finds_single_donor(svg): - parts = ReusableParts.fromstring(svg.tostring()) + parts = _from_svg(svg.tostring()) # There should be one shape used to create all the others maybe_reuses = [parts.try_reuse(s.as_path()) for s in svg.shapes()] @@ -142,14 +170,14 @@ def test_reuse_with_inconsistent_square_viewbox(): little = locate_test_file("rect.svg") big = locate_test_file("rect_10x.svg") - r1 = ReusableParts.load(little) + r1 = _from_svg(little) assert r1.view_box == Rect(0, 0, 10, 10) - r1.add(ReusableParts.load(big)) + r1.add(_from_svg(big)) r1.compute_donors() - r2 = ReusableParts.load(big) + r2 = _from_svg(big) assert r2.view_box == Rect(0, 0, 100, 100) - r2.add(ReusableParts.load(little)) + r2.add(_from_svg(little)) r2.compute_donors() check_num_shapes(r1, 1) @@ -174,10 +202,7 @@ def test_reuse_with_inconsistent_width_viewbox(): ) svgs = tuple(SVG.parse(locate_test_file(svg)) for svg in svgs) - parts = ReusableParts( - view_box=ReusableParts.view_box_for((s.view_box() for s in svgs)) - ) - assert parts.view_box == Rect(0, 0, 10, 10) + parts = ReusableParts(view_box=Rect(0, 0, 10, 10)) for svg in svgs: parts.add(svg) @@ -185,3 +210,40 @@ def test_reuse_with_inconsistent_width_viewbox(): parts.compute_donors() assert len(parts.shape_sets) == 1, parts.to_json() assert only(parts._donor_cache.values()) is not None, "Expected reuse" + + +def test_arcs_become_cubics(): + parts = _from_svg( + """ + + + + + """ + ) + + norm, path = only(parts.shape_sets.items()) + path = only(path) + assert (_svg_commands(norm), _svg_commands(path)) == ( + "Mccccz", + "MCCCCZ", + ), f"Wrong command types\nnorm {norm}\npath {path}" + + +# scaling turns arcs into cubics +# we need them to reuse regardless +def test_scaled_merge_arcs_to_cubics(): + parts = _from_svg(locate_test_file("circle_10x.svg")) + part2 = _from_svg(locate_test_file("circle.svg")) + assert parts.view_box == Rect(0, 0, 100, 100) + assert part2.view_box == Rect(0, 0, 10, 10) + parts.add(part2) + + assert len(parts.shape_sets) == 1, parts.to_json() + norm, paths = only(parts.shape_sets.items()) + path_cmds = tuple(_svg_commands(p) for p in paths) + assert (_svg_commands(norm),) + path_cmds == ( + "Mccccz", + "MCCCCZ", + "MCCCCZ", + ), f"Path damaged\nnorm {norm}\npaths {paths}" diff --git a/tests/rect_picosvg.ttx b/tests/rect_picosvg.ttx index d8d28601..5807227c 100644 --- a/tests/rect_picosvg.ttx +++ b/tests/rect_picosvg.ttx @@ -61,10 +61,10 @@ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M4,4 L10,4 L10,6 L4,6 L4,4 Z" id="e000.0" fill="blue"/> + <path d="M40,40 L100,40 L100,60 L40,60 L40,40 Z" id="e000.0" fill="blue"/> </defs> - <g id="glyph2" transform="matrix(10 0 0 10 0 -100)"> - <use xlink:href="#e000.0" x="-2" y="-2"/> + <g id="glyph2" transform="translate(0, -100)"> + <use xlink:href="#e000.0" x="-20" y="-20"/> <use xlink:href="#e000.0" opacity="0.8"/> </g> </svg> diff --git a/tests/reused_shape_2_picosvg.ttx b/tests/reused_shape_2_picosvg.ttx index 32a57f01..3f70c11b 100644 --- a/tests/reused_shape_2_picosvg.ttx +++ b/tests/reused_shape_2_picosvg.ttx @@ -61,12 +61,12 @@ <svgDoc endGlyphID="2" startGlyphID="2"> <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M200,100 A100 100 0 1 1 0,100 A100 100 0 1 1 200,100 Z" id="e000.0"/> + <path d="M74.545,71.818 C74.545,83.868 67.829,93.636 59.545,93.636 C51.261,93.636 44.545,83.868 44.545,71.818 C44.545,59.768 51.261,50 59.545,50 C67.829,50 74.545,59.768 74.545,71.818 Z" id="e000.1" fill="#7E4418"/> </defs> <g id="glyph2" transform="matrix(0.5 0 0 0.5 0 -100)"> - <use xlink:href="#e000.0" fill="#F49924"/> - <use xlink:href="#e000.0" transform="matrix(0.15 0 0 0.218 44.545 50)" fill="#7E4418"/> - <use xlink:href="#e000.0" transform="matrix(0.15 0 0 0.218 125.455 50)" fill="#7E4418"/> + <path d="M200,100 C200,155.228 155.228,200 100,200 C44.772,200 0,155.228 0,100 C0,44.772 44.772,0 100,0 C155.228,0 200,44.772 200,100 Z" fill="#F49924"/> + <use xlink:href="#e000.1"/> + <use xlink:href="#e000.1" x="80.91"/> </g> </svg> diff --git a/tests/reused_shape_with_gradient_svg.ttx b/tests/reused_shape_with_gradient_svg.ttx index 59142021..10051ae8 100644 --- a/tests/reused_shape_with_gradient_svg.ttx +++ b/tests/reused_shape_with_gradient_svg.ttx @@ -61,7 +61,7 @@ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M77.56,63.99 A13.56 13.56 0 1 1 50.44,63.99 A13.56 13.56 0 1 1 77.56,63.99 Z" id="e000.0"/> + <path d="M77.56,63.99 C77.56,71.479 71.489,77.55 64,77.55 C56.511,77.55 50.44,71.479 50.44,63.99 C50.44,56.501 56.511,50.43 64,50.43 C71.489,50.43 77.56,56.501 77.56,63.99 Z" id="e000.0"/> <radialGradient id="g1" cx="59.958" cy="61.112" r="13.562" gradientUnits="userSpaceOnUse"> <stop offset="0.014" stop-color="#FFEB3B"/> <stop offset="0.626" stop-color="#FCCD31"/> diff --git a/tests/smiley_cheeks_gradient_svg.ttx b/tests/smiley_cheeks_gradient_svg.ttx index c9c0c745..9db082aa 100644 --- a/tests/smiley_cheeks_gradient_svg.ttx +++ b/tests/smiley_cheeks_gradient_svg.ttx @@ -61,7 +61,7 @@ <svgDoc endGlyphID="2" startGlyphID="2"> <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M44.75,69.83 A17.5 17.5 0 1 1 9.75,69.83 A17.5 17.5 0 1 1 44.75,69.83 Z" id="e000.0" fill="url(#g1)"/> + <path d="M44.75,69.83 C44.75,79.495 36.915,87.33 27.25,87.33 C17.585,87.33 9.75,79.495 9.75,69.83 C9.75,60.165 17.585,52.33 27.25,52.33 C36.915,52.33 44.75,60.165 44.75,69.83 Z" id="e000.0" fill="url(#g1)"/> <radialGradient id="g1" cx="27.251" cy="73.507" r="19.038" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 0.95 0 0)"> <stop offset="0" stop-color="#ED7770" stop-opacity="0.8"/> <stop offset="0.9" stop-color="#ED7770" stop-opacity="0"/> diff --git a/tests/test_helper.py b/tests/test_helper.py index c0aba9b1..23023c16 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -29,6 +29,7 @@ from nanoemoji.parts import ReusableParts from nanoemoji.png import PNG from pathlib import Path +from picosvg.geometric_types import Rect from picosvg.svg import SVG import pytest import shutil @@ -134,24 +135,26 @@ def color_font_config( ) ] - parts = reusable_parts(font_config.reuse_tolerance, glyph_inputs) + parts = reusable_parts(font_config.upem, font_config.reuse_tolerance, glyph_inputs) return (font_config, parts, glyph_inputs) def reusable_parts( - reuse_tolerance: float, glyph_inputs: Iterable[write_font.InputGlyph] + upem: int, reuse_tolerance: float, glyph_inputs: Iterable[write_font.InputGlyph] ) -> ReusableParts: glyph_inputs = [g for g in glyph_inputs if g.svg] - if not glyph_inputs: - return ReusableParts(reuse_tolerance=reuse_tolerance) - parts = ReusableParts( reuse_tolerance=reuse_tolerance, - view_box=ReusableParts.view_box_for((g.svg.view_box() for g in glyph_inputs)), + view_box=Rect(0, 0, upem, upem), ) + for glyph_input in glyph_inputs: parts.add(glyph_input.svg) + + # TEMPORARY + print(parts.to_json()) + return parts diff --git a/tests/transformed_gradient_reuse.ttx b/tests/transformed_gradient_reuse.ttx index bb8f2dfa..c7b13450 100644 --- a/tests/transformed_gradient_reuse.ttx +++ b/tests/transformed_gradient_reuse.ttx @@ -7,7 +7,6 @@ <GlyphID id="1" name=".space"/> <GlyphID id="2" name="e000"/> <GlyphID id="3" name="e000.0"/> - <GlyphID id="4" name="e000.1"/> </GlyphOrder> <hmtx> @@ -15,7 +14,6 @@ <mtx name=".space" width="100" lsb="0"/> <mtx name="e000" width="100" lsb="0"/> <mtx name="e000.0" width="100" lsb="8"/> - <mtx name="e000.1" width="100" lsb="33"/> </hmtx> <cmap> @@ -81,24 +79,6 @@ <instructions/> </TTGlyph> - <TTGlyph name="e000.1" xMin="33" yMin="65" xMax="46" yMax="79"> - <contour> - <pt x="46" y="72" on="1"/> - <pt x="46" y="75" on="0"/> - <pt x="42" y="79" on="0"/> - <pt x="39" y="79" on="1"/> - <pt x="37" y="79" on="0"/> - <pt x="33" y="75" on="0"/> - <pt x="33" y="72" on="1"/> - <pt x="33" y="69" on="0"/> - <pt x="37" y="65" on="0"/> - <pt x="39" y="65" on="1"/> - <pt x="42" y="65" on="0"/> - <pt x="46" y="69" on="0"/> - </contour> - <instructions/> - </TTGlyph> - </glyf> <COLR> @@ -146,34 +126,44 @@ </Paint> <Glyph value="e000.0"/> </Paint> - <Paint index="1" Format="10"><!-- PaintGlyph --> - <Paint Format="16"><!-- PaintScale --> - <Paint Format="6"><!-- PaintRadialGradient --> - <ColorLine> - <Extend value="pad"/> - <!-- StopCount=2 --> - <ColorStop index="0"> - <StopOffset value="0.0"/> - <PaletteIndex value="0"/> - <Alpha value="0.8"/> - </ColorStop> - <ColorStop index="1"> - <StopOffset value="0.9"/> - <PaletteIndex value="0"/> - <Alpha value="0.0"/> - </ColorStop> - </ColorLine> - <x0 value="39"/> - <y0 value="76"/> - <r0 value="0"/> - <x1 value="39"/> - <y1 value="76"/> - <r1 value="7"/> + <Paint index="1" Format="12"><!-- PaintTransform --> + <Paint Format="10"><!-- PaintGlyph --> + <Paint Format="16"><!-- PaintScale --> + <Paint Format="6"><!-- PaintRadialGradient --> + <ColorLine> + <Extend value="pad"/> + <!-- StopCount=2 --> + <ColorStop index="0"> + <StopOffset value="0.0"/> + <PaletteIndex value="0"/> + <Alpha value="0.8"/> + </ColorStop> + <ColorStop index="1"> + <StopOffset value="0.9"/> + <PaletteIndex value="0"/> + <Alpha value="0.0"/> + </ColorStop> + </ColorLine> + <x0 value="21"/> + <y0 value="46"/> + <r0 value="0"/> + <x1 value="21"/> + <y1 value="46"/> + <r1 value="15"/> + </Paint> + <scaleX value="1.0"/> + <scaleY value="0.94995"/> </Paint> - <scaleX value="1.0"/> - <scaleY value="0.94995"/> + <Glyph value="e000.0"/> </Paint> - <Glyph value="e000.1"/> + <Transform> + <xx value="0.5"/> + <yx value="0.0"/> + <xy value="0.0"/> + <yy value="0.5"/> + <dx value="28.71094"/> + <dy value="50.0"/> + </Transform> </Paint> </LayerList> <ClipList Format="1"> diff --git a/tests/write_font_test.py b/tests/write_font_test.py index 9d52543c..9f382b4a 100644 --- a/tests/write_font_test.py +++ b/tests/write_font_test.py @@ -596,6 +596,27 @@ def test_square_varied_hmetrics(): ), f"n+1 should double, fails at {i}; {glyph_widths}\n{test_helper.ttx(font)}" +# scaling was at one point causing lookup in part file to fail +# TODO this doesn't reproduce the problem :( +def test_inconsistent_viewbox(): + # rect 1000 will cause part merge to scale + svgs = ( + "circle.svg", + "rect_1000.svg", + ) + + config, parts, glyph_inputs = test_helper.color_font_config({ + "upem": 1024, + "ascender": 950, + "descender": -250, + "width": 1275, + }, svgs) + parts.compute_donors() + + # just compiling without error will suffice :) + write_font._generate_color_font(config, parts, glyph_inputs) + + def test_reuse_with_inconsistent_viewbox(): svgs = ( "rect.svg",