diff --git a/src/nanoemoji/color_glyph.py b/src/nanoemoji/color_glyph.py index 96a72f72..4a042c48 100644 --- a/src/nanoemoji/color_glyph.py +++ b/src/nanoemoji/color_glyph.py @@ -49,7 +49,7 @@ import pathops -def _scale_viewbox_to_font_metrics( +def scale_viewbox_to_font_metrics( view_box: Rect, ascender: int, descender: int, width: int ): assert descender <= 0 @@ -71,7 +71,7 @@ def map_viewbox_to_font_space( ) -> Affine2D: return Affine2D.compose_ltr( [ - _scale_viewbox_to_font_metrics(view_box, ascender, descender, width), + 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, @@ -85,7 +85,7 @@ def map_viewbox_to_otsvg_space( ) -> Affine2D: return Affine2D.compose_ltr( [ - _scale_viewbox_to_font_metrics(view_box, ascender, descender, width), + 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, diff --git a/src/nanoemoji/maximum_color.py b/src/nanoemoji/maximum_color.py index 8f1cc89a..355ab1a7 100644 --- a/src/nanoemoji/maximum_color.py +++ b/src/nanoemoji/maximum_color.py @@ -65,6 +65,7 @@ class WriteFontInputs(NamedTuple): glyphmap_file: Path config_file: Path + part_file: Path @property def table_tag(self) -> str: @@ -88,7 +89,11 @@ def color_format(self) -> str: @classmethod def for_tag(cls, table_tag: str) -> "WriteFontInputs": basename = table_tag.strip() - return cls(Path(basename + ".glyphmap"), Path(basename + ".toml")) + return cls( + Path(basename + ".glyphmap"), + Path(basename + ".toml"), + master_part_file_dest(), + ) def _vector_color_table(font: ttLib.TTFont) -> str: @@ -121,6 +126,14 @@ def picosvg_dest(input_svg: Path) -> Path: return picosvg_dir() / input_svg.name +def part_file_dest(picosvg_file: Path) -> Path: + return picosvg_file.with_suffix(".parts.json") + + +def master_part_file_dest() -> Path: + return Path("parts-merged.json") + + def bitmap_dir() -> Path: return build_dir() / "bitmap" @@ -163,7 +176,7 @@ def _write_preamble(nw: NinjaWriter): module_rule( nw, "write_font", - f"--glyphmap_file $glyphmap_file --config_file $config_file --output_file $out", + f"--config_file $config_file --glyphmap_file $glyphmap_file --part_file $part_file --output_file $out", ) nw.newline() @@ -173,6 +186,22 @@ def _write_preamble(nw: NinjaWriter): ) nw.newline() + module_rule( + nw, + "write_part_file", + f"--reuse_tolerance $reuse_tolerance --wh $wh --output_file $out $in", + ) + nw.newline() + + module_rule( + nw, + "write_combined_part_files", + f"--output_file $out @$out.rsp", + rspfile="$out.rsp", + rspfile_content="$in", + ) + nw.newline() + # set height only, let width scale proportionally res = config.load().bitmap_resolution nw.rule( @@ -237,6 +266,30 @@ def _picosvgs(nw: NinjaWriter, svg_files: List[Path]) -> List[Path]: return picosvgs +def _part_file( + nw: NinjaWriter, font_config: config.FontConfig, picosvg_files: List[Path] +) -> Path: + part_files = [part_file_dest(p) for p in picosvg_files] + for picosvg_file, part_file in zip(picosvg_files, part_files): + nw.build( + part_file, + "write_part_file", + picosvg_file, + variables={ + "reuse_tolerance": font_config.reuse_tolerance, + "wh": font_config.ascender - font_config.descender, + }, + ) + + nw.build( + master_part_file_dest(), + "write_combined_part_files", + sorted(part_files), + ) + + return master_part_file_dest() + + def _generate_additional_color_table( nw: NinjaWriter, input_font: Path, @@ -283,7 +336,10 @@ def _generate_additional_color_table( def _generate_svg_from_colr( - nw: NinjaWriter, input_font: Path, font: ttLib.TTFont + nw: NinjaWriter, + font_config: config.FontConfig, + input_font: Path, + font: ttLib.TTFont, ) -> Tuple[Path, List[Path]]: # generate svgs svg_files = [ @@ -294,6 +350,8 @@ def _generate_svg_from_colr( # create and merge an SVG table picosvgs = _picosvgs(nw, svg_files) + part_file = _part_file(nw, font_config, picosvgs) + output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "SVG ", input_font ) @@ -301,7 +359,10 @@ def _generate_svg_from_colr( def _generate_colr_from_svg( - nw: NinjaWriter, input_font: Path, font: ttLib.TTFont + nw: NinjaWriter, + font_config: config.FontConfig, + input_font: Path, + font: ttLib.TTFont, ) -> Tuple[Path, List[Path]]: # extract the svgs svg_files = [ @@ -312,6 +373,8 @@ def _generate_colr_from_svg( # create and merge a COLR table picosvgs = _picosvgs(nw, svg_files) + part_file = _part_file(nw, font_config, picosvgs) + output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "COLR", input_font ) @@ -371,7 +434,8 @@ def _run(argv): input_file = Path(argv[1]).resolve() # we need a non-relative path assert input_file.is_file() font = ttLib.TTFont(input_file) - final_output = Path(config.load().output_file) + font_config = config.load() + final_output = Path(font_config.output_file) assert ( input_file.resolve() != (build_dir() / final_output).resolve() ), "In == Out is bad" @@ -391,9 +455,13 @@ def _run(argv): # generate the missing vector table if color_table == "COLR": - wip_file, picosvg_files = _generate_svg_from_colr(nw, wip_file, font) + wip_file, picosvg_files = _generate_svg_from_colr( + nw, font_config, wip_file, font + ) else: - wip_file, picosvg_files = _generate_colr_from_svg(nw, wip_file, font) + wip_file, picosvg_files = _generate_colr_from_svg( + nw, font_config, wip_file, font + ) if FLAGS.bitmaps: wip_file = _generate_cbdt(nw, input_file, font, wip_file, picosvg_files) diff --git a/src/nanoemoji/nanoemoji.py b/src/nanoemoji/nanoemoji.py index 41f7211e..55a7b9d9 100644 --- a/src/nanoemoji/nanoemoji.py +++ b/src/nanoemoji/nanoemoji.py @@ -206,10 +206,19 @@ def write_preamble(nw): ) nw.newline() + module_rule( + nw, + "write_combined_part_files", + f"--output_file $out @$out.rsp", + rspfile="$out.rsp", + rspfile_content="$in", + ) + nw.newline() + module_rule( nw, "write_font", - f"--config_file $config_file --fea_file $fea_file --glyphmap_file $glyphmap_file $in", + f"--config_file $config_file --fea_file $fea_file --glyphmap_file $glyphmap_file --part_file $part_file $in", ) module_rule( @@ -252,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 --wh $wh --output_file $out $in", ) nw.newline() @@ -344,31 +353,45 @@ def diff_png_dest(input_svg: Path) -> Path: return _dest_for_src(diff_png_dest, diff_bitmap_dir(), input_svg, ".png") +def master_part_file_dest() -> Path: + return Path("parts-merged.json") + + 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) nw.build(dest, rule_name, rel_build(svg_file)) + part_dest = part_file_dest(dest) nw.build( - part_file_dest(dest), + part_dest, "write_part_file", dest, - variables={"reuse_tolerance": reuse_tolerance}, + variables={ + "reuse_tolerance": font_config.reuse_tolerance, + "wh": font_config.ascender - font_config.descender, + }, ) + picosvgs.add(dest) + part_files.add(part_dest) + return (picosvgs, part_files) + def write_bitmap_builds( bitmap_builds: Set[Path], @@ -525,6 +548,7 @@ def _variables_for_font_build( "config_file": rel_build(config_file), "fea_file": rel_build(_fea_file(font_config)), "glyphmap_file": rel_build(_glyphmap_file(font_config, master)), + "part_file": master_part_file_dest(), } @@ -637,20 +661,28 @@ def _run(argv): for master in font_config.masters: write_glyphmap_build(nw, font_config, master) - picosvg_builds = set() + picosvg_builds = set() # svgs for which we already made a picosvg + part_files = set() for font_config in font_configs: for master in font_config.masters: if font_config.has_picosvgs: - write_picosvg_builds( + _, parts = write_picosvg_builds( picosvg_builds, nw, - font_config.clip_to_viewbox, - font_config.reuse_tolerance, + font_config, master, ) + part_files |= parts nw.newline() - bitmap_builds = set() + # Write a combined part file (potentially empty) + nw.build( + master_part_file_dest(), + "write_combined_part_files", + sorted(part_files), + ) + + bitmap_builds = set() # svgs for which we already made a bitmap for font_config in font_configs: if font_config.has_bitmaps: assert not font_config.is_vf @@ -663,8 +695,8 @@ def _run(argv): ) nw.newline() - zopflipng_builds = set() - pngquant_builds = set() + zopflipng_builds = set() # svgs for which we already made a zopflipng + pngquant_builds = set() # svgs for which we already made a pngquant for font_config in font_configs: if not font_config.has_bitmaps or not ( font_config.use_zopflipng or font_config.use_pngquant diff --git a/src/nanoemoji/parts.py b/src/nanoemoji/parts.py index 12f15561..fd27c025 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -11,19 +11,44 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +"""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 nanoemoji.color_glyph import scale_viewbox_to_font_metrics from pathlib import Path +from picosvg.geometric_types import Rect from picosvg.svg import SVG -from picosvg.svg_reuse import normalize +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 -from typing import Iterable, List, MutableMapping, NewType, Set, Tuple, Union +from typing import ( + Iterable, + List, + MutableMapping, + NamedTuple, + NewType, + Optional, + Set, + Tuple, + Union, +) + +PathSource = Union[SVG, "ReusableParts"] -PathSource = Union[SVGShape, Iterable[SVGShape], "ReuseableParts"] + +_DEFAULT_ROUND_NDIGITS = 3 @lru_cache(maxsize=1) @@ -56,78 +81,219 @@ def _is_iterable_of(thing, desired_type) -> bool: ShapeSet = NewType("ShapeSet", Set[Shape]) +class ReuseResult(NamedTuple): + transform: Affine2D + shape: Shape + + +@lru_cache(maxsize=512) +def _bbox_area(shape: Shape) -> float: + bbox = SVGPath(d=shape).bounding_box() + return bbox.w * bbox.h + + +def _round(shape: SVGShape) -> SVGPath: + return shape.as_path().round_floats(_DEFAULT_ROUND_NDIGITS) + + +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 from compute_donors() to more explicitly model the add/use cycle + + @dataclasses.dataclass -class ReuseableParts: +class ReusableParts: version: Tuple[int, int, int] = (1, 0, 0) + view_box: Rect = Rect(0, 0, 1, 1) reuse_tolerance: float = dataclasses.field(default_factory=_default_tolerence) shape_sets: MutableMapping[NormalizedShape, ShapeSet] = dataclasses.field( default_factory=dict ) + _donor_cache: MutableMapping[NormalizedShape, Optional[Shape]] = dataclasses.field( + default_factory=dict + ) + + def normalize(self, path: str) -> NormalizedShape: + if self.reuse_tolerance != -1: + # normalize handles it's own rounding + # 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 def _add_norm_path(self, norm: NormalizedShape, shape: Shape): if norm not in self.shape_sets: self.shape_sets[norm] = ShapeSet(set()) self.shape_sets[norm].add(shape) + self._donor_cache.pop(norm, None) def _add(self, shape: Shape): - norm = NormalizedShape(shape) - if self.reuse_tolerance != -1: - norm = NormalizedShape(normalize(SVGPath(d=shape), self.reuse_tolerance).d) + norm = self.normalize(shape) self._add_norm_path(norm, shape) def add(self, source: PathSource): - if isinstance(source, ReuseableParts): - for normalized, shape_set in source.shape_sets.items(): - for shape in shape_set: - self._add_norm_path(normalized, shape) + """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) + shapes = tuple( + reduce(lambda a, c: a | c, source.shape_sets.values(), set()) + ) + elif isinstance(source, SVG): + source.checkpicosvg() + source_box = source.view_box() + transform = scale_viewbox_to_font_metrics( + self.view_box, source_box.h, 0, source_box.w + ) + shapes = tuple(s.as_path() for s in source.shapes()) else: - if not _is_iterable_of(source, SVGShape): - source = (source,) - for a_source in source: - if not isinstance(a_source, SVGShape): - raise ValueError(f"Illegal source {type(a_source)}") - svg_shape: SVGShape = a_source # pytype: disable=attribute-error - self._add(Shape(svg_shape.as_path().d)) + 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 + + # try to select a donor that can fulfil every member of the set + # the input shape is in the set so if found we can get from donor => input + # shrinking a big thing is more likely to result in small #s that fit into + # more compact PaintTransform variants so try biggest first + + # 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") + # A fancier implementation would factor in the # of occurences and the cost + # based on which shape is selected as donor if there are many possibilities. + + svg_paths = sorted( + self.shape_sets[norm], key=lambda s: (_bbox_area(s), s), reverse=True + ) + svg_paths = [SVGPath(d=s) for s in svg_paths] + + for svg_path in svg_paths: + if all( + affine_between(svg_path, svg_path2, self.reuse_tolerance) is not None + for svg_path2 in svg_paths + ): + # Do NOT use as_shape; these paths already passed through it + self._donor_cache[norm] = Shape(svg_path.d) + break + + def compute_donors(self): + self._donor_cache.clear() + for norm in self.shape_sets: + self._compute_donor(norm) + + def is_reused(self, shape: SVGPath) -> bool: + shape = as_shape(shape) + norm = self.normalize(shape) + if norm not in self.shape_sets: + return False + if len(self.shape_sets[norm]) < 2: + return False + if norm not in self._donor_cache: + self._compute_donor(norm) + return shape == self._donor_cache[norm] # this shape provides! + + def try_reuse(self, shape: SVGPath) -> Optional[ReuseResult]: + """Returns the shape and transform to use to build the input shape.""" + shape = as_shape(shape) + if self.reuse_tolerance == -1: + return ReuseResult(Affine2D.identity(), shape) + + norm = self.normalize(shape) + + # The whole point is to pre-add, doing it on the fly reduces reuse + if norm not in self.shape_sets: + print(self.to_json()) + raise ValueError( + f"You MUST pre-add your shapes. No set matches normalization {norm} for {shape}." + ) + + if shape not in self.shape_sets[norm]: + print(self.to_json()) + raise ValueError(f"You MUST pre-add your shapes. {shape} is new to us.") + + if norm not in self._donor_cache: + assert ( + shape in self.shape_sets[norm] + ), f"The input shape must be in the group" + self._compute_donor(norm) + + donor = self._donor_cache[norm] + if donor is None: + # bail out, no solution! + return None + + affine = affine_between( + SVGPath(d=donor), SVGPath(d=shape), self.reuse_tolerance + ) + assert ( + affine is not None + ), f"Should only get here with a solution. Epic fail on {donor}, {shape.d}" + return ReuseResult(affine, donor) def to_json(self): json_dict = { "version": ".".join(str(v) for v in self.version), "reuse_tolerance": self.reuse_tolerance, + "view_box": " ".join(str(int(v)) for v in self.view_box), "shape_sets": [ - {"normalized": n, "shapes": list(s)} for n, s in self.shape_sets.items() + { + "normalized": n, + "shapes": list(s), + "donor": self._donor_cache.get(n, ""), + } + for n, s in self.shape_sets.items() ], } return json.dumps(json_dict, indent=2) @classmethod - def fromstring(cls, string) -> "ReuseableParts": - first = string.strip()[0] - parts = cls() - if first == "<": - svg = SVG.fromstring(string).topicosvg() - for shape in svg.shapes(): - parts.add(SVGPath(d=shape.as_path().d)) - 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.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")}) - if shape_set_json: - raise ValueError(f"Unconsumed input {shape_set_json}") - parts.shape_sets[norm] = shapes - 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) -> "ReuseableParts": + 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")) diff --git a/src/nanoemoji/write_combined_part_files.py b/src/nanoemoji/write_combined_part_files.py new file mode 100644 index 00000000..8645f3f6 --- /dev/null +++ b/src/nanoemoji/write_combined_part_files.py @@ -0,0 +1,50 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Combines N part files to 1""" + + +from absl import app +from absl import flags +from nanoemoji.parts import ReusableParts +from nanoemoji import util +from pathlib import Path + + +FLAGS = flags.FLAGS + + +def main(argv): + input_files = util.expand_ninja_response_files(argv[1:]) + + combined_parts = ReusableParts() + 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 = util.only({p.view_box for p in individual_parts}) + + for parts in individual_parts: + combined_parts.add(parts) + + combined_parts.compute_donors() # precompute for later use + + with util.file_printer(FLAGS.output_file) as print: + print(combined_parts.to_json()) + + +if __name__ == "__main__": + app.run(main) diff --git a/src/nanoemoji/write_font.py b/src/nanoemoji/write_font.py index 21711e6d..919735d5 100644 --- a/src/nanoemoji/write_font.py +++ b/src/nanoemoji/write_font.py @@ -51,6 +51,7 @@ PaintGlyph, PaintSolid, ) +from nanoemoji.parts import ReusableParts from nanoemoji.png import PNG from nanoemoji.svg import make_svg_table from nanoemoji.svg_path import draw_svg_path @@ -86,6 +87,7 @@ flags.DEFINE_string("config_file", None, "Config filename.") flags.DEFINE_string("glyphmap_file", None, "Glyphmap filename.") +flags.DEFINE_string("part_file", None, "Reusable parts filename.") # A GlyphMapping plus an SVG, typically a picosvg, and/or a PNG @@ -824,6 +826,10 @@ def main(argv): inputs = list(_inputs(font_config, glyphmap.parse_csv(FLAGS.glyphmap_file))) + reusable_parts = ReusableParts() + if FLAGS.part_file: + reusable_parts = ReusableParts.loadjson(Path(FLAGS.part_file)) + if not inputs: sys.exit("Please provide at least one svg filename") ufo, ttfont = _generate_color_font(font_config, inputs) diff --git a/src/nanoemoji/write_part_file.py b/src/nanoemoji/write_part_file.py index c1cd9f86..ef1f9227 100644 --- a/src/nanoemoji/write_part_file.py +++ b/src/nanoemoji/write_part_file.py @@ -12,37 +12,44 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Generates a part file from 1..N input part sources. - -Part sources can be: - -1. Other part files -2. Svg files - -Or any mix thereof. +"""Generates a part file from 1 source, typically an svg file. """ from absl import app from absl import flags -from functools import reduce -from nanoemoji.parts import ReuseableParts +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 +flags.DEFINE_integer("wh", None, "The width and height to use.") +flags.DEFINE_bool("compute_donors", False, "Whether to compute donors.") + + def main(argv): - parts = [ReuseableParts.load(Path(part_file)) for part_file in argv[1:]] - if not parts: - raise ValueError("Specify at least one input") - parts = reduce(lambda a, c: a.add(c), parts[1:], parts[0]) + if len(argv) != 2: + raise ValueError("Specify exactly one input") + + view_box = Rect(0, 0, FLAGS.wh, FLAGS.wh) + 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() with util.file_printer(FLAGS.output_file) as print: print(parts.to_json()) if __name__ == "__main__": + flags.mark_flag_as_required("wh") + 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 @@ +<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <circle cx="5" cy="5" r="2" /> +</svg> 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 @@ +<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> + <circle cx="50" cy="50" r="20" /> +</svg> diff --git a/tests/conftest.py b/tests/conftest.py index 5ef6e282..48ca1a82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,6 @@ def _cleanup_temporary_dirs(request): cleanup_temp_dirs() else: print( - f"NOT cleaning up {len(active_temp_dirs())} temp dirs to ease troubleshooting" + f"NOT cleaning up {len(active_temp_dirs())} temp dirs ({active_temp_dirs()}) to ease troubleshooting" ) forget_temp_dirs() diff --git a/tests/maximum_color_test.py b/tests/maximum_color_test.py index 51e42560..ee0349fc 100644 --- a/tests/maximum_color_test.py +++ b/tests/maximum_color_test.py @@ -24,16 +24,6 @@ from typing import Tuple -@pytest.fixture(scope="module", autouse=True) -def _cleanup_temporary_dirs(): - # The mkdtemp() docs say the user is responsible for deleting the directory - # and its contents when done with it. So we use an autouse fixture that - # automatically removes all the temp dirs at the end of the test module - yield - # teardown happens after the 'yield' - cleanup_temp_dirs() - - def _build_initial_font(color_format: str) -> Path: tmp_dir = run_nanoemoji( ( diff --git a/tests/nanoemoji_test.py b/tests/nanoemoji_test.py index 3ee9de43..8ae1e81d 100644 --- a/tests/nanoemoji_test.py +++ b/tests/nanoemoji_test.py @@ -68,9 +68,10 @@ def test_build_static_font_default_config_cli_svg_list(): def _build_and_check_ttx(config_overrides, svgs, expected_ttx): config_file = mkdtemp() / "config.toml" - font_config, _ = color_font_config( + font_config, glyph_inputs = color_font_config( config_overrides, svgs, tmp_dir=config_file.parent ) + del glyph_inputs config.write(config_file, font_config) print(config_file, font_config) diff --git a/tests/parts_test.py b/tests/parts_test.py index fa379390..74833b0c 100644 --- a/tests/parts_test.py +++ b/tests/parts_test.py @@ -12,10 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -from nanoemoji.parts import ReuseableParts -from picosvg.svg_types import SVGCircle, SVGRect +from nanoemoji.parts import ReusableParts +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 @@ -33,43 +40,75 @@ def _cleanup_temporary_dirs(): # TODO we get pointless precision, e.g. 1.2000000000000002 -def check_num_shapes(parts: ReuseableParts, expected_shape_sets: int): - assert len(parts.shape_sets) == expected_shape_sets, ",".join( - sorted(str(p) for p in parts.shape_sets.keys()) - ) +def _svg_commands(path: str) -> str: + print(path) + svg_cmds = "".join(svg_meta.cmds()) + return re.sub(f"[^{svg_cmds}]+", "", path) -def test_collects_normalized_shapes(): - shapes = ( - SVGRect(width=2, height=1), - SVGRect(width=4, height=2), - SVGCircle(r=2), +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()) ) - parts = ReuseableParts() - parts.add(shapes) - check_num_shapes(parts, 2) +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_from_svg(): - parts = ReuseableParts.load(locate_test_file("rect.svg")) +def test_add_svg(): + parts = _from_svg( + """ + <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <rect x="2" y="2" width="6" height="2" fill="blue" /> + <rect x="4" y="4" width="6" height="2" fill="blue" opacity="0.8" /> + </svg> + """ + ) check_num_shapes(parts, 1) -def test_merge(): - shapes1 = (SVGRect(width=2, height=1),) - shapes2 = ( - SVGRect(width=4, height=2), - SVGCircle(r=2), +def test_collects_normalized_shapes(): + parts = _from_svg( + """ + <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <rect width="2" height="1"/> + <rect width="4" height="2" y="1.5"/> + <circle cx="5" cy="5" r="2"/> + </svg> + """ ) - p1 = ReuseableParts() - p1.add(shapes1) + check_num_shapes(parts, 2) + + +def test_simple_merge(): + p1 = _from_svg( + """ + <svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> + <rect width="2" height="1"/> + </svg> + """ + ) check_num_shapes(p1, 1) - p2 = ReuseableParts() - p2.add(shapes2) + p2 = _from_svg( + """ + <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <rect width="4" height="2" y="1.5"/> + <circle r="2"/> + </svg> + """ + ) check_num_shapes(p2, 2) p1.add(p2) @@ -77,12 +116,141 @@ def test_merge(): def test_file_io(): - parts = ReuseableParts() - parts.add(ReuseableParts.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 == ReuseableParts.load(tmp_file) + 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 +# picosvg. This just checks we use those capabilities in the expected manner. +@pytest.mark.parametrize( + "svg", + [ + SVG.fromstring( + """ + <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <rect width="2" height="1"/> + <rect width="4" height="2" y="1.5"/> + </svg> + """ + ).topicosvg(), + # https://github.com/googlefonts/nanoemoji/issues/415 arc normalization + SVG.fromstring( + """ + <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <circle r="1"/> + <circle r="2"/> + </svg> + """ + ).topicosvg(), + ], +) +def test_reuse_finds_single_donor(svg): + 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()] + assert all(ri is not None for ri in maybe_reuses), "All shapes should have results" + scale_up = { + ri for ri in maybe_reuses if not all(v <= 1.0 for v in ri.transform.getscale()) + } + assert not scale_up, f"Should prefer to scale big to little {scale_up}" + assert ( + len({ri.shape for ri in maybe_reuses}) == 1 + ), f"{maybe_reuses} should all reuse the same shape" + + +# Feed in two identical svgs, just one of them multiplies viewbox and coords by 10 +def test_reuse_with_inconsistent_square_viewbox(): + little = locate_test_file("rect.svg") + big = locate_test_file("rect_10x.svg") + + r1 = _from_svg(little) + assert r1.view_box == Rect(0, 0, 10, 10) + r1.add(_from_svg(big)) + r1.compute_donors() + + r2 = _from_svg(big) + assert r2.view_box == Rect(0, 0, 100, 100) + r2.add(_from_svg(little)) + r2.compute_donors() + + check_num_shapes(r1, 1) + check_num_shapes(r2, 1) + assert only(r1.shape_sets.values()) == { + "M2,2 L8,2 L8,4 L2,4 L2,2 Z", + "M4,4 L10,4 L10,6 L4,6 L4,4 Z", + }, "There should be 2 (not 4) shapes after scaled merge. r1 should use the little viewbox." + assert only(r2.shape_sets.values()) == { + "M20,20 L80,20 L80,40 L20,40 L20,20 Z", + "M40,40 L100,40 L100,60 L40,60 L40,40 Z", + }, "There should be 2 (not 4) shapes after scaled merge. r2 should use the big viewbox." + + +def test_arcs_become_cubics(): + parts = _from_svg( + """ + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> + <defs/> + <path d="M2,0 A2 2 0 1 1 -2,0 A2 2 0 1 1 2,0 Z"/> + </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}" + + +def _start_at_origin(path): + cmd, args = next(iter(path)) + assert cmd == "M" + x, y = args + return path.move(-x, -y) + + +# SVGs with varied width that contains squares should push squares +# into the part store, not get mangled into rectangles. +def test_squares_stay_squares(): + parts = ReusableParts(view_box=Rect(0, 0, 10, 10)) + + parts.add(SVG.parse(locate_test_file("square_vbox_narrow.svg"))) + parts.add(SVG.parse(locate_test_file("square_vbox_square.svg"))) + parts.add(SVG.parse(locate_test_file("square_vbox_narrow.svg"))) + + # Every square should have normalized the same + assert len(parts.shape_sets) == 1, parts.to_json() + + paths = only(parts.shape_sets.values()) + + paths = [_start_at_origin(SVGPath(d=p)).relative(inplace=True) for p in paths] + assert {p.d for p in paths} == { + "M0,0 l3,0 l0,3 l-3,0 l0,-3 z" + }, "The square should remain 3x3; converted to relative and starting at 0,0 they should be identical" diff --git a/tests/rect_10.svg b/tests/rect_10.svg new file mode 100644 index 00000000..120ca76a --- /dev/null +++ b/tests/rect_10.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + <rect x="4.5" y="1" width="1" height="8" /> +</svg> diff --git a/tests/rect_1000.svg b/tests/rect_1000.svg new file mode 100644 index 00000000..116cf050 --- /dev/null +++ b/tests/rect_1000.svg @@ -0,0 +1,4 @@ +<svg viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <rect x="0" y="0" width="1000" height="1000" /> +</svg> diff --git a/tests/rect_10x.svg b/tests/rect_10x.svg new file mode 100644 index 00000000..4fa827a6 --- /dev/null +++ b/tests/rect_10x.svg @@ -0,0 +1,5 @@ +<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <rect x="20" y="20" width="60" height="20" fill="blue" /> + <rect x="40" y="40" width="60" height="20" fill="blue" opacity="0.8" /> +</svg> diff --git a/tests/rect_from_colr_1.svg b/tests/rect_from_colr_1.svg index 7596a90e..51acd717 100644 --- a/tests/rect_from_colr_1.svg +++ b/tests/rect_from_colr_1.svg @@ -2,4 +2,4 @@ <defs/> <path fill="blue" d="M2,2 L2,4 L8,4 L8,2 Z"/> <path fill="blue" opacity="0.8" transform="translate(2, 2)" d="M2,2 L2,4 L8,4 L8,2 Z"/> -</svg> +</svg> \ No newline at end of file