From 8373448d95885f3b2252e507935f723b598f7434 Mon Sep 17 00:00:00 2001 From: Rod S Date: Thu, 28 Apr 2022 20:00:12 -0700 Subject: [PATCH 1/4] Generate a parts file for each input svg and use it to compute reuse. --- src/nanoemoji/color_glyph.py | 22 +- src/nanoemoji/colr_to_svg.py | 75 +++-- src/nanoemoji/config.py | 2 +- src/nanoemoji/extract_svgs_from_otsvg.py | 1 - src/nanoemoji/generate_svgs_from_colr.py | 4 +- src/nanoemoji/glyph_reuse.py | 127 ++++---- src/nanoemoji/maximum_color.py | 79 ++++- src/nanoemoji/nanoemoji.py | 61 +++- src/nanoemoji/paint.py | 29 +- src/nanoemoji/parts.py | 257 ++++++++++++++--- src/nanoemoji/svg.py | 266 ++++++++++------- src/nanoemoji/svg_path.py | 4 + src/nanoemoji/write_combined_part_files.py | 52 ++++ src/nanoemoji/write_font.py | 283 ++++++++++++------ src/nanoemoji/write_part_file.py | 34 ++- tests/circle.svg | 3 + tests/circle_10x.svg | 3 + tests/clocks_colr_1.ttx | 20 +- tests/clocks_colr_1_noreuse.ttx | 289 +------------------ tests/clocks_rects_picosvg.ttx | 10 +- tests/conftest.py | 2 +- tests/glyph_reuse_test.py | 91 +++++- tests/group_opacity_reuse_picosvg.ttx | 9 +- tests/maximum_color_test.py | 10 - tests/nanoemoji_test.py | 3 +- tests/outside_viewbox_not_clipped_colr_1.ttx | 69 +++-- tests/parentless_reused_el.ttx | 8 +- tests/parts_test.py | 217 ++++++++++++-- tests/rect_10.svg | 3 + tests/rect_1000.svg | 4 + tests/rect_10x.svg | 5 + tests/rect_colr_0.ttx | 22 +- tests/rect_colr_1.ttx | 34 +-- tests/rect_from_colr_1.svg | 4 +- tests/rect_picosvg.ttx | 8 +- tests/rects_colr_1.ttx | 50 ++-- tests/reorder_glyphs_test.py | 4 +- tests/reuse_shape_varying_fill.ttx | 8 +- tests/reused_shape_2_picosvg.ttx | 9 + tests/reused_shape_glyf.ttx | 80 ++--- tests/reused_shape_with_gradient_colr.ttx | 4 +- tests/reused_shape_with_gradient_svg.ttx | 6 +- tests/smiley_cheeks_gradient_svg.ttx | 2 +- tests/svg_colr_svg_test.py | 13 +- tests/test_helper.py | 107 ++++--- tests/transformed_components_overlap.ttx | 66 ++--- tests/transformed_gradient_reuse.ttx | 2 +- tests/write_font_test.py | 100 +++++-- 48 files changed, 1609 insertions(+), 952 deletions(-) create mode 100644 src/nanoemoji/write_combined_part_files.py create mode 100644 tests/circle.svg create mode 100644 tests/circle_10x.svg create mode 100644 tests/rect_10.svg create mode 100644 tests/rect_1000.svg create mode 100644 tests/rect_10x.svg diff --git a/src/nanoemoji/color_glyph.py b/src/nanoemoji/color_glyph.py index 96a72f72..6dec3c54 100644 --- a/src/nanoemoji/color_glyph.py +++ b/src/nanoemoji/color_glyph.py @@ -69,7 +69,8 @@ def _scale_viewbox_to_font_metrics( def map_viewbox_to_font_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: - return Affine2D.compose_ltr( + # print("map_viewbox_to_font_space", view_box, ascender, descender, width, user_transform) + transform = 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 @@ -77,6 +78,8 @@ def map_viewbox_to_font_space( user_transform, ] ) + # print("map_viewbox_to_font_space", transform) + return transform # https://docs.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics @@ -472,23 +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): - 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/colr_to_svg.py b/src/nanoemoji/colr_to_svg.py index 9c514e9d..727a5796 100644 --- a/src/nanoemoji/colr_to_svg.py +++ b/src/nanoemoji/colr_to_svg.py @@ -31,6 +31,7 @@ is_transform, _decompose_uniform_transform, ) +from nanoemoji.parts import ReusableParts from nanoemoji.svg import ( _svg_matrix, _apply_solid_paint, @@ -182,11 +183,12 @@ def _apply_gradient_ot_paint( def _colr_v0_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttFont._TTGlyphSet, - view_box_callback: ViewboxCallback, + shape_view_box_callback: ViewboxCallback, + dest_view_box_callback: ViewboxCallback, glyph_name: str, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( - ttfont, view_box_callback, glyph_name + ttfont, shape_view_box_callback, dest_view_box_callback, glyph_name ) svg_root = _svg_root(view_box) for glyph_layer in ttfont["COLR"].ColorLayers[glyph_name]: @@ -323,28 +325,41 @@ def glyph_region(ttfont: ttLib.TTFont, glyph_name: str) -> Rect: def _view_box_and_transform( - ttfont: ttLib.TTFont, view_box_callback: ViewboxCallback, glyph_name: str + ttfont: ttLib.TTFont, + shape_view_box_callback: ViewboxCallback, + dest_view_box_callback: ViewboxCallback, + glyph_name: str, ) -> Tuple[Rect, Affine2D]: - view_box = view_box_callback(glyph_name) - assert view_box.w > 0, f"0-width viewBox for {glyph_name}?!" + dest_view_box = dest_view_box_callback(glyph_name) + assert dest_view_box.w > 0, f"0-width viewBox for {glyph_name}?!" - region = glyph_region(ttfont, glyph_name) - assert region.w > 0, f"0-width region for {glyph_name}?!" + parts_view_box = shape_view_box_callback(glyph_name) - font_to_vbox = map_font_space_to_viewbox(view_box, region) + width = ttfont["hmtx"][glyph_name][0] + if width == 0: + width = ttfont["glyf"][glyph_name].xMax + # TODO we lost user transform? + svg_units_to_font_units = color_glyph.map_viewbox_to_font_space( + parts_view_box, + ttfont["OS/2"].sTypoAscender, + ttfont["OS/2"].sTypoDescender, + width, + Affine2D.identity(), + ) - return (view_box, font_to_vbox) + return (dest_view_box, svg_units_to_font_units.inverse()) def _colr_v1_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttFont._TTGlyphSet, + shape_view_box_callback: ViewboxCallback, view_box_callback: ViewboxCallback, glyph: otTables.BaseGlyphRecord, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( - ttfont, view_box_callback, glyph.BaseGlyph + ttfont, shape_view_box_callback, view_box_callback, glyph.BaseGlyph ) svg_root = _svg_root(view_box) svg_defs = svg_root[0] @@ -358,7 +373,7 @@ def _colr_v1_glyph_to_svg( def _new_reuse_cache() -> ReuseCache: - return ReuseCache(0.1, GlyphReuseCache(0.1)) + return ReuseCache(GlyphReuseCache(ReusableParts(reuse_tolerance=0.1))) def colr_glyphs(font: ttLib.TTFont) -> Iterable[int]: @@ -374,13 +389,21 @@ def colr_glyphs(font: ttLib.TTFont) -> Iterable[int]: def _colr_v0_to_svgs( - view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont + shape_view_box_callback: ViewboxCallback, + dest_view_box_callback: ViewboxCallback, + 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_callback, g) + _colr_v0_glyph_to_svg( + ttfont, + glyph_set, + shape_view_box_callback, + dest_view_box_callback, + g, + ) ) ) for g in ttfont["COLR"].ColorLayers @@ -388,13 +411,21 @@ def _colr_v0_to_svgs( def _colr_v1_to_svgs( - view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont + shape_view_box_callback: ViewboxCallback, + dest_view_box_callback: ViewboxCallback, + 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_callback, g) + _colr_v1_glyph_to_svg( + ttfont, + glyph_set, + shape_view_box_callback, + dest_view_box_callback, + g, + ) ) ) for g in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord @@ -402,18 +433,24 @@ def _colr_v1_to_svgs( def colr_to_svg( - view_box_callback: ViewboxCallback, + shape_view_box_callback: ViewboxCallback, + dest_view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont, rounding_ndigits: Optional[int] = None, ) -> Dict[str, SVG]: - """For testing only, don't use for real!""" + """ + Creates a glyph name => SVG dict from a COLR table. + + shape_view_box_callback: function to get the space in which shapes for a glyph were defined, such as the parts view box. + dest_view_box_callback: function to get the view box of the destination + """ assert len(ttfont["CPAL"].palettes) == 1, "We assume one palette" colr_version = ttfont["COLR"].version if colr_version == 0: - svgs = _colr_v0_to_svgs(view_box_callback, ttfont) + svgs = _colr_v0_to_svgs(shape_view_box_callback, dest_view_box_callback, ttfont) elif colr_version == 1: - svgs = _colr_v1_to_svgs(view_box_callback, ttfont) + svgs = _colr_v1_to_svgs(shape_view_box_callback, dest_view_box_callback, ttfont) else: raise NotImplementedError(colr_version) diff --git a/src/nanoemoji/config.py b/src/nanoemoji/config.py index 0d18c0d5..66687215 100644 --- a/src/nanoemoji/config.py +++ b/src/nanoemoji/config.py @@ -158,7 +158,7 @@ class FontConfig(NamedTuple): transform: Affine2D = Affine2D.identity() version_major: int = 1 version_minor: int = 0 - reuse_tolerance: float = 0.1 + reuse_tolerance: float = 0.05 ignore_reuse_error: bool = True keep_glyph_names: bool = False clip_to_viewbox: bool = True diff --git a/src/nanoemoji/extract_svgs_from_otsvg.py b/src/nanoemoji/extract_svgs_from_otsvg.py index 7c186ea8..526c7df9 100644 --- a/src/nanoemoji/extract_svgs_from_otsvg.py +++ b/src/nanoemoji/extract_svgs_from_otsvg.py @@ -19,7 +19,6 @@ from fontTools import ttLib from lxml import etree from nanoemoji import codepoints -from nanoemoji.color_glyph import map_viewbox_to_otsvg_space from nanoemoji.extract_svgs import svg_glyphs from nanoemoji import util import os diff --git a/src/nanoemoji/generate_svgs_from_colr.py b/src/nanoemoji/generate_svgs_from_colr.py index ce6178f2..6175042e 100644 --- a/src/nanoemoji/generate_svgs_from_colr.py +++ b/src/nanoemoji/generate_svgs_from_colr.py @@ -53,7 +53,9 @@ def main(argv): assert "COLR" in font, f"No COLR table in {font_file}" logging.debug("Writing svgs from %s to %s", font_file, out_dir) - for glyph_name, svg in colr_to_svg(lambda gn: _view_box(font, gn), font).items(): + region_callback = lambda gn: _view_box(font, gn) + + for glyph_name, svg in colr_to_svg(region_callback, region_callback, font).items(): gid = font.getGlyphID(glyph_name) dest_file = out_dir / f"{gid:05d}.svg" with open(dest_file, "w") as f: diff --git a/src/nanoemoji/glyph_reuse.py b/src/nanoemoji/glyph_reuse.py index fd50019f..df748c93 100644 --- a/src/nanoemoji/glyph_reuse.py +++ b/src/nanoemoji/glyph_reuse.py @@ -16,76 +16,97 @@ from absl import logging -from picosvg.svg_reuse import normalize, affine_between +import dataclasses +from nanoemoji import parts +from nanoemoji.parts import ReuseResult, ReusableParts +from picosvg.geometric_types import Rect from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from typing import ( + MutableMapping, NamedTuple, Optional, + Set, ) from .fixed import fixed_safe -class ReuseResult(NamedTuple): - glyph_name: str - transform: Affine2D - - +@dataclasses.dataclass class GlyphReuseCache: - def __init__(self, reuse_tolerance: float): - self._reuse_tolerance = reuse_tolerance - self._known_glyphs = set() - self._reusable_paths = {} - - # normalize tries to remap first two significant vectors to [1 0], [0 1] - # reuse tolerence is relative to viewbox, which is typically much larger - # than the space normalize operates in. TODO: better default. - self._normalize_tolerance = self._reuse_tolerance / 10 - - def try_reuse(self, path: str) -> Optional[ReuseResult]: - """Try to reproduce path as the transformation of another glyph. + _reusable_parts: ReusableParts + _shape_to_glyph: MutableMapping[parts.Shape, str] = dataclasses.field( + default_factory=dict + ) + _glyph_to_shape: MutableMapping[str, parts.Shape] = dataclasses.field( + default_factory=dict + ) + + def try_reuse(self, path: str, path_view_box: Rect) -> ReuseResult: + assert path[0].upper() == "M", path + + path = SVGPath(d=path) + if path_view_box != self._reusable_parts.view_box: + print(path, path_view_box, self._reusable_parts.view_box) + path = path.apply_transform( + Affine2D.rect_to_rect(path_view_box, self._reusable_parts.view_box) + ) - Path is expected to be in font units. + maybe_reuse = self._reusable_parts.try_reuse(path) - Returns (glyph name, transform) if possible, None if not. - """ + # https://github.com/googlefonts/nanoemoji/issues/313 avoid out of bounds affines + if maybe_reuse is not None and not fixed_safe(*maybe_reuse.transform): + logging.warning( + "affine_between overflows Fixed: %s %s, %s", + path, + maybe_reuse.shape, + maybe_reuse.transform, + ) + maybe_reuse = None + if maybe_reuse is None: + maybe_reuse = ReuseResult(Affine2D.identity(), parts.as_shape(path)) + return maybe_reuse + + def set_glyph_for_path(self, glyph_name: str, path: str): + norm = self._reusable_parts.normalize(path) + assert norm in self._reusable_parts.shape_sets, f"No shape set for {path}" + shape = parts.as_shape(SVGPath(d=path)) assert ( - not path in self._known_glyphs - ), f"{path} isn't a path, it's a glyph name we've seen before" - assert path.startswith("M"), f"{path} doesn't look like a path" + shape in self._reusable_parts.shape_sets[norm] + ), f"Not present in shape set: {path}" - if self._reuse_tolerance == -1: - return None + if self._shape_to_glyph.get(shape, glyph_name) != glyph_name: + raise ValueError(f"{shape} cannot be associated with glyphs") + if self._glyph_to_shape.get(glyph_name, shape) != shape: + raise ValueError(f"{glyph_name} cannot be associated with multiple shapes") - norm_path = normalize(SVGPath(d=path), self._normalize_tolerance).d - if norm_path not in self._reusable_paths: - return None + self._shape_to_glyph[shape] = glyph_name + self._glyph_to_shape[glyph_name] = shape - glyph_name, glyph_path = self._reusable_paths[norm_path] - affine = affine_between( - SVGPath(d=glyph_path), SVGPath(d=path), self._reuse_tolerance - ) - if affine is None: - logging.warning("affine_between failed: %s %s ", glyph_path, path) - return None + def get_glyph_for_path(self, path: str) -> str: + return self._shape_to_glyph[parts.as_shape(SVGPath(d=path))] - # https://github.com/googlefonts/nanoemoji/issues/313 avoid out of bounds affines - if not fixed_safe(*affine): - logging.warning( - "affine_between overflows Fixed: %s %s, %s", glyph_path, path, affine - ) - return None + def forget_glyph_path_associations(self): + self._shape_to_glyph.clear() + self._glyph_to_shape.clear() - return ReuseResult(glyph_name, affine) + def consuming_glyphs(self, path: str) -> Set[str]: + norm = self._reusable_parts.normalize(path) + assert ( + norm in self._reusable_parts.shape_sets + ), f"{path} not associated with any parts!" + return { + self._shape_to_glyph[shape] + for shape in self._reusable_parts.shape_sets[norm] + } + + def is_known_glyph(self, glyph_name: str): + return glyph_name in self._glyph_to_shape - def add_glyph(self, glyph_name, glyph_path): - assert glyph_path.startswith("M"), f"{glyph_path} doesn't look like a path" - if self._reuse_tolerance != -1: - norm_path = normalize(SVGPath(d=glyph_path), self._normalize_tolerance).d - else: - norm_path = glyph_path - self._reusable_paths[norm_path] = (glyph_name, glyph_path) - self._known_glyphs.add(glyph_name) + def is_known_path(self, path: str): + return parts.as_shape(SVGPath(d=path)) in self._shape_to_glyph - def is_known_glyph(self, glyph_name): - return glyph_name in self._known_glyphs + def view_box(self) -> Rect: + """ + The box within which the shapes in this cache exist. + """ + return self._reusable_parts.view_box diff --git a/src/nanoemoji/maximum_color.py b/src/nanoemoji/maximum_color.py index 8f1cc89a..352bf2d4 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 --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,27 @@ def _picosvgs(nw: NinjaWriter, svg_files: List[Path]) -> List[Path]: return picosvgs +def _part_file( + nw: NinjaWriter, reuse_tolerance: float, 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": reuse_tolerance}, + ) + + 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 +333,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 +347,8 @@ def _generate_svg_from_colr( # create and merge an SVG table picosvgs = _picosvgs(nw, svg_files) + part_file = _part_file(nw, font_config.reuse_tolerance, picosvgs) + output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "SVG ", input_font ) @@ -301,7 +356,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 +370,8 @@ def _generate_colr_from_svg( # create and merge a COLR table picosvgs = _picosvgs(nw, svg_files) + part_file = _part_file(nw, font_config.reuse_tolerance, picosvgs) + output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "COLR", input_font ) @@ -371,7 +431,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 +452,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..12cd88e3 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 --upem $upem --output_file $out $in", ) nw.newline() @@ -344,31 +353,42 @@ 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, "upem": font_config.upem}, ) + picosvgs.add(dest) + part_files.add(part_dest) + return (picosvgs, part_files) + def write_bitmap_builds( bitmap_builds: Set[Path], @@ -525,6 +545,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 +658,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 +692,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/paint.py b/src/nanoemoji/paint.py index e9660310..1d684e02 100644 --- a/src/nanoemoji/paint.py +++ b/src/nanoemoji/paint.py @@ -26,6 +26,7 @@ from nanoemoji.fixed import ( int16_safe, f2dot14_safe, + fixed_safe, MIN_INT16, MAX_INT16, MIN_UINT16, @@ -761,14 +762,38 @@ def is_gradient(paint_or_format) -> bool: ) +def _int16_part(v) -> int: + if v < 0: + return max(v, MIN_INT16) + else: + return min(v, MAX_INT16) + + def transformed(transform: Affine2D, target: Paint) -> Paint: - if transform == Affine2D.identity(): + if transform.almost_equals(Affine2D.identity()): return target sx, b, c, sy, dx, dy = transform + # Large translations (dx=50k) can occur due to use of big shapes + # to produce small shapes. Make a modest effort to work around. + if fixed_safe(sx, b, c, sy) and not fixed_safe(dx, dy): + dxt = _int16_part(dx) + dyt = _int16_part(dy) + dx -= dxt + dy -= dyt + if fixed_safe(dx, dy): + return PaintTranslate( + paint=transformed(Affine2D(sx, b, c, sy, dx, dy), target), + dx=dxt, + dy=dyt, + ) + raise ValueError(f"Transform values are outlandishly large :( {transform}") + # Int16 translation? - if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy) == transform: + if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy).almost_equals( + transform + ): if int16_safe(dx, dy): return PaintTranslate(paint=target, dx=dx, dy=dy) diff --git a/src/nanoemoji/parts.py b/src/nanoemoji/parts.py index 12f15561..f78b5612 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -11,19 +11,43 @@ # 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 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 +80,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() + transform = Affine2D.rect_to_rect(source.view_box(), self.view_box) + 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/svg.py b/src/nanoemoji/svg.py index 30f85c4f..52a36551 100644 --- a/src/nanoemoji/svg.py +++ b/src/nanoemoji/svg.py @@ -21,10 +21,10 @@ 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, ReuseResult +from nanoemoji.glyph_reuse import GlyphReuseCache from nanoemoji.paint import ( _BasePaintTransform, CompositeMode, @@ -39,15 +39,17 @@ PaintColrLayers, is_transform, ) -from picosvg.geometric_types import Rect +from nanoemoji.parts import ReusableParts from nanoemoji.reorder_glyphs import reorder_glyphs -from picosvg.svg import to_element, SVG, SVGTraverseContext +from picosvg.geometric_types import Rect from picosvg import svg_meta +from picosvg.svg import to_element, SVG, SVGTraverseContext from picosvg.svg_reuse import normalize, affine_between -from picosvg.svg_transform import Affine2D +from picosvg.svg_transform import parse_svg_transform, Affine2D from picosvg.svg_types import SVGPath from typing import ( cast, + Iterable, Mapping, MutableMapping, NamedTuple, @@ -73,14 +75,10 @@ class GradientReuseKey(NamedTuple): @dataclasses.dataclass class ReuseCache: - reuse_tolerance: float glyph_cache: GlyphReuseCache glyph_elements: MutableMapping[str, etree.Element] = dataclasses.field( default_factory=dict ) - reuse_results: MutableMapping[str, ReuseResult] = dataclasses.field( - default_factory=dict - ) gradient_ids: MutableMapping[GradientReuseKey, str] = dataclasses.field( default_factory=dict ) @@ -88,17 +86,21 @@ class ReuseCache: def add_glyph( self, glyph_name: str, - reuse_result: Optional[ReuseResult], - context: SVGTraverseContext, + glyph_path: str, ): assert glyph_name not in self.glyph_elements, f"Second addition of {glyph_name}" - if not isinstance(context.paint, PaintGlyph): - raise ValueError(f"Not a PaintGlyph {context}") - if not reuse_result: - self.glyph_cache.add_glyph(glyph_name, context.paint.glyph) - else: - self.reuse_results[glyph_name] = reuse_result - self.glyph_elements[glyph_name] = to_element(SVGPath(d=context.paint.glyph)) + self.glyph_elements[glyph_name] = to_element(SVGPath(d=glyph_path)) + + def reuse_spans_glyphs(self, path: str) -> bool: + return ( + len( + { + _color_glyph_name(gn) + for gn in self.glyph_cache.consuming_glyphs(path) + } + ) + > 1 + ) def _ensure_has_id(el: etree.Element): @@ -120,11 +122,26 @@ def _color_glyph_name(glyph_name: str) -> str: return glyph_name[: glyph_name.rindex(".")] +def _paint_glyphs(color_glyph: ColorGlyph) -> Iterable[PaintGlyph]: + for root in color_glyph.painted_layers: + for context in root.breadth_first(): + # Group glyphs based on common shapes + if not isinstance(context.paint, PaintGlyph): + continue + yield cast(PaintGlyph, context.paint) + + def _glyph_groups( - config: FontConfig, color_glyphs: Sequence[ColorGlyph], reuse_cache: ReuseCache + config: FontConfig, + color_glyphs: Sequence[ColorGlyph], + reusable_parts: ReusableParts, ) -> Tuple[Tuple[str, ...]]: """Find glyphs that need to be kept together by union find.""" + # This cache is solely to help us group + glyph_cache = GlyphReuseCache(reusable_parts) + + # Make sure we keep together color glyphs that share shapes reuse_groups = DisjointSet() # ensure glyphs sharing shapes are in the same doc for color_glyph in color_glyphs: reuse_groups.make_set(color_glyph.ufo_glyph_name) @@ -135,17 +152,24 @@ def _glyph_groups( if not isinstance(context.paint, PaintGlyph): continue - glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) - reuse_result = reuse_cache.glyph_cache.try_reuse( - context.paint.glyph # pytype: disable=attribute-error + paint_glyph = cast(PaintGlyph, context.paint) + maybe_reuse = glyph_cache.try_reuse( + paint_glyph.glyph, color_glyph.svg.view_box() ) - reuse_cache.add_glyph(glyph_name, reuse_result, context) - if reuse_result: - # This entire color glyph and the one we share a shape with go in one svg doc + + if glyph_cache.is_known_path(maybe_reuse.shape): + # we've seen this exact path before, join the union with other consumers reuse_groups.union( color_glyph.ufo_glyph_name, - _color_glyph_name(reuse_result.glyph_name), + _color_glyph_name( + glyph_cache.get_glyph_for_path(maybe_reuse.shape) + ), ) + else: + # I claim this path in the name of myself! + # Use a path-specific name so each color glyph can register multiple paths + paint_glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) + glyph_cache.set_glyph_for_path(paint_glyph_name, maybe_reuse.shape) nth_paint_glyph += 1 @@ -384,7 +408,7 @@ def _migrate_to_defs( svg: SVG, reused_el: etree.Element, reuse_cache: ReuseCache, - reuse_result: ReuseResult, + glyph_name: str, ): svg_defs = svg.xpath_one("//svg:defs") @@ -395,13 +419,8 @@ def _migrate_to_defs( 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 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_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{glyph_name}" + reused_el.addnext(svg_use) svg_defs.append(reused_el) # append moves @@ -412,24 +431,53 @@ def _migrate_to_defs( return svg_use +def _transform(el: etree.Element, transform: Affine2D): + if transform.almost_equals(Affine2D.identity()): + return + + # offset-only use is nice and tidy + tx, ty = transform.gettranslate() + if el.tag == "use" and transform.translate(-tx, -ty).almost_equals( + Affine2D.identity() + ): + if tx: + el.attrib["x"] = _ntos(tx) + if ty: + el.attrib["y"] = _ntos(ty) + else: + el.attrib["transform"] = _svg_matrix(transform) + + def _create_use_element( - svg: SVG, parent_el: etree.Element, reuse_result: ReuseResult + svg: SVG, parent_el: etree.Element, glyph_name: str, transform: Affine2D ) -> etree.Element: svg_use = etree.SubElement(parent_el, "use", nsmap=svg.svg_root.nsmap) - svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" - transform = reuse_result.transform - tx, ty = transform.gettranslate() - if tx: - svg_use.attrib["x"] = _ntos(tx) - if ty: - svg_use.attrib["y"] = _ntos(ty) - transform = transform.translate(-tx, -ty) - if transform != Affine2D.identity(): - svg_use.attrib["transform"] = _svg_matrix(transform) + svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{glyph_name}" + _transform(svg_use, transform) 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 @@ -441,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 @@ -467,42 +515,50 @@ def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): path = path[:-1] if isinstance(context.paint, PaintGlyph): + paint = cast(PaintGlyph, context.paint) glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) - assert ( - glyph_name in reuse_cache.glyph_elements - ), f"Missing entry for {glyph_name}" - reuse_result = reuse_cache.reuse_results.get(glyph_name, None) + assert paint.glyph.startswith( + "M" + ), f"{paint.glyph} doesn't look like a path" + + maybe_reuse = reuse_cache.glyph_cache.try_reuse( + paint.glyph, color_glyph.svg.view_box() + ) - if reuse_result: - reused_glyph_name = reuse_result.glyph_name + # if we have a glyph for the shape already, use that + if reuse_cache.glyph_cache.is_known_path(maybe_reuse.shape): + reused_glyph_name = reuse_cache.glyph_cache.get_glyph_for_path( + maybe_reuse.shape + ) + svg_use = _create_use_element( + svg, parent_el, reused_glyph_name, maybe_reuse.transform + ) + + # the reused glyph will already exist, but it may need adjustment on grounds of reuse reused_el = reuse_cache.glyph_elements[reused_glyph_name] - reused_el_tag = etree.QName(reused_el.tag).localname - if reused_el_tag == "use": - # if reused_el is a it means _migrate_to_defs has already - # replaced a parent-less with a pointing to it, and - # has appended the reused path to . Assert that's the case - assert _use_href(reused_el) == reused_glyph_name - reused_el = svg.xpath_one( - f'//svg:defs/svg:path[@id="{reused_glyph_name}"]', - ) - elif reused_el_tag == "path": - # we need to refer to you, it's important you have identity - reused_el.attrib["id"] = reused_glyph_name - else: - raise AssertionError(reused_el_tag) + reused_el.attrib["id"] = reused_glyph_name # hard to target w/o id - svg_use = _create_use_element(svg, parent_el, reuse_result) + # In two cases, we need to push the reused element to the outer + # and replace its first occurence with a : + # 1) If reuse spans multiple glyphs, as Adobe Illustrator + # doesn't support direct references between glyphs: + # https://github.com/googlefonts/nanoemoji/issues/264 + # 2) If the reused_el has attributes cannot override + # https://github.com/googlefonts/nanoemoji/issues/337 + # We don't know if #1 holds so to make life simpler just always + # promote reused glyphs to defs + _migrate_to_defs(svg, reused_el, reuse_cache, reused_glyph_name) # We must apply the inverse of the reuse transform to the children # paints to discount its effect on them, since these refer to the # original pre-reuse paths. _apply_paint expects 'transform' to be - # in UPEM space, whereas reuse_result.transform is in SVG space, so + # in UPEM space, whereas maybe_reuse.transform is in SVG space, so # we remap the (inverse of the) latter from SVG to UPEM. inverse_reuse_transform = Affine2D.compose_ltr( ( upem_to_vbox, - reuse_result.transform.inverse(), + maybe_reuse.transform.inverse(), upem_to_vbox.inverse(), ) ) @@ -510,26 +566,19 @@ def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): _apply_paint( svg_defs, svg_use, - context.paint.paint, # pytype: disable=attribute-error + paint.paint, upem_to_vbox, reuse_cache, inverse_reuse_transform, ) - - # In two cases, we need to push the reused element to the outer - # and replace its first occurence with a : - # 1) If reuse spans multiple glyphs, as Adobe Illustrator - # doesn't support direct references between glyphs: - # https://github.com/googlefonts/nanoemoji/issues/264 - # 2) If the reused_el has attributes cannot override - # https://github.com/googlefonts/nanoemoji/issues/337 - if color_glyph.ufo_glyph_name != _color_glyph_name( - reused_glyph_name - ) or _attrib_apply_paint_uses(reused_el): - _migrate_to_defs(svg, reused_el, reuse_cache, reuse_result) - else: + # otherwise, create a glyph for the target and use it + reuse_cache.glyph_cache.set_glyph_for_path( + glyph_name, maybe_reuse.shape + ) + reuse_cache.add_glyph(glyph_name, maybe_reuse.shape) el = reuse_cache.glyph_elements[glyph_name] + _apply_paint( svg_defs, el, @@ -537,6 +586,15 @@ def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): upem_to_vbox, reuse_cache, ) + + # If we need a transformed version of the path do it by wrapping a g around + # to ensure anyone else who reuses the shape doesn't pick up our transform + if not maybe_reuse.transform.almost_equals(Affine2D.identity()): + g = etree.Element("g") + _transform(g, maybe_reuse.transform) + g.append(el) + el = g + parent_el.append(el) # pytype: disable=attribute-error # don't update el_by_path because we're declaring this path complete @@ -635,6 +693,15 @@ def _tidy_use_elements(svg: SVG): for duplicate_attr in duplicate_attrs: del use_el.attrib[duplicate_attr] + # if the parent is a transform-only group migrate the transform to the use + if use_el.getparent().tag == "g" and set(use_el.getparent().attrib.keys()) == { + "transform" + }: + g = use_el.getparent() + g.addnext(use_el) + _transform(use_el, parse_svg_transform(g.attrib["transform"])) + g.getparent().remove(g) + # If all have the same paint attr migrate it from use to target for ref, uses in groupby(use_els, key=_use_href): uses = list(uses) @@ -649,12 +716,12 @@ def _tidy_use_elements(svg: SVG): def _picosvg_docs( - config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph] + config: FontConfig, + reusable_parts: ReusableParts, + 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) + reuse_groups = _glyph_groups(config, color_glyphs, reusable_parts) color_glyph_order = [c.ufo_glyph_name for c in color_glyphs] color_glyphs = {c.ufo_glyph_name: c for c in color_glyphs} _ensure_groups_grouped_in_glyph_order( @@ -663,7 +730,9 @@ def _picosvg_docs( doc_list = [] for group in reuse_groups: - reuse_cache.gradient_ids = {} # don't share gradients across groups + # reuse is only possible within a single doc so process each individually + # we created our reuse groups specifically to ensure glyphs that share are together + reuse_cache = ReuseCache(GlyphReuseCache(reusable_parts)) # establish base svg, defs root = etree.Element( @@ -671,24 +740,26 @@ def _picosvg_docs( {"version": "1.1"}, nsmap={None: svg_meta.svgns(), "xlink": svg_meta.xlinkns()}, ) - defs = etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) + etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) svg = SVG(root) + del root # SVG could change root, shouldn't matter for us 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) # sort by @id to increase diff stability + defs = svg.xpath_one("//svg:defs") defs[:] = sorted(defs, key=lambda e: e.attrib["id"]) # strip if empty if len(defs) == 0: - root.remove(defs) + svg.svg_root.remove(defs) - if len(root) == 0: + if len(svg.svg_root) == 0: continue gids = tuple(color_glyphs[g].glyph_id for g in group) @@ -716,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 @@ -735,6 +806,7 @@ def _rawsvg_docs( def make_svg_table( config: FontConfig, + reusable_parts: ReusableParts, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], picosvg: bool, @@ -749,7 +821,7 @@ def make_svg_table( """ if picosvg: - doc_list = _picosvg_docs(config, ttfont, color_glyphs) + doc_list = _picosvg_docs(config, reusable_parts, ttfont, color_glyphs) else: doc_list = _rawsvg_docs(config, ttfont, color_glyphs) diff --git a/src/nanoemoji/svg_path.py b/src/nanoemoji/svg_path.py index 1b190684..53904e15 100644 --- a/src/nanoemoji/svg_path.py +++ b/src/nanoemoji/svg_path.py @@ -53,6 +53,10 @@ def draw_svg_path( pen.endPath() closed = False + for arg in args: + if not -32768 <= arg <= 32767: + assert False, path.d + # pens expect args as 2-tuples; we use the 'grouper' itertools recipe # https://docs.python.org/3.8/library/itertools.html#recipes assert len(args) % 2 == 0 diff --git a/src/nanoemoji/write_combined_part_files.py b/src/nanoemoji/write_combined_part_files.py new file mode 100644 index 00000000..76b83882 --- /dev/null +++ b/src/nanoemoji/write_combined_part_files.py @@ -0,0 +1,52 @@ +# 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..af76b54d 100644 --- a/src/nanoemoji/write_font.py +++ b/src/nanoemoji/write_font.py @@ -29,13 +29,14 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.pens.boundsPen import ControlBoundsPen from fontTools.pens.transformPen import TransformPen +import functools from itertools import chain from lxml import etree # pytype: disable=import-error from nanoemoji.bitmap_tables import make_cbdt_table, make_sbix_table from nanoemoji import codepoints, config, glyphmap from nanoemoji.colors import Color from nanoemoji.config import FontConfig -from nanoemoji.color_glyph import ColorGlyph +from nanoemoji.color_glyph import ColorGlyph, map_viewbox_to_font_space from nanoemoji.fixed import fixed_safe from nanoemoji.glyph import glyph_name from nanoemoji.glyphmap import GlyphMapping @@ -51,6 +52,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 +88,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 @@ -106,9 +109,12 @@ class InputGlyph(NamedTuple): # If the output file is .ufo then apply_ttfont is not called. # Where possible code to the ufo and let apply_ttfont be a nop. class ColorGenerator(NamedTuple): - apply_ufo: Callable[[FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...]], None] + apply_ufo: Callable[ + [FontConfig, ReusableParts, ufoLib2.Font, Tuple[ColorGlyph, ...]], None + ] apply_ttfont: Callable[ - [FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...], ttLib.TTFont], None + [FontConfig, ReusableParts, ufoLib2.Font, Tuple[ColorGlyph, ...], ttLib.TTFont], + None, ] font_ext: str # extension for font binary, .ttf or .otf @@ -261,82 +267,166 @@ def _next_name(ufo: ufoLib2.Font, name_fn) -> str: return name_fn(i) -def _create_glyph( - color_glyph: ColorGlyph, paint: PaintGlyph, path_in_font_space: str -) -> Glyph: +def _create_glyph(color_glyph: ColorGlyph, path_in_font_space: str) -> Glyph: glyph = _init_glyph(color_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 -def _migrate_paths_to_ufo_glyphs( - color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache -) -> ColorGlyph: - svg_units_to_font_units = color_glyph.transform_for_font_space() +def _svg_transform_in_font_space( + svg_units_to_font_units: Affine2D, transform: Affine2D +) -> Affine2D: + # We have a transform in svg space to apply to a thing in font space + # Come back from font space, apply, and then return to font space + return Affine2D.compose_ltr( + ( + svg_units_to_font_units.inverse(), + transform, + svg_units_to_font_units, + ) + ) + + +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, + ) + - # Walk through the color glyph, where we see a PaintGlyph take the path out of it, - # move the path into font coordinates, generate a ufo glyph, and push the name of - # the ufo glyph into the PaintGlyph - def _update_paint_glyph(paint): - if paint.format != PaintGlyph.format: - return paint +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 = _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 + ) + print( + "_create_glyph_for_svg_path, svg ", + path_in_svg_space, + "in", + glyph_cache.view_box(), + ) + print("_create_glyph_for_svg_path, transform ", svg_units_to_font_units) + print("_create_glyph_for_svg_path, font", path_in_font_space) + glyph = _create_glyph(color_glyph, path_in_font_space) + glyph_cache.set_glyph_for_path(glyph.name, path_in_svg_space) + return glyph - 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 +def _fix_nested_gradient(reuse_transform: Affine2D, paint: PaintGlyph) -> Paint: + # If we are applying a transform that can change downstream gradients in unwanted ways. + # If that seems likely, fix it. + child_transform = Affine2D.identity() + child_paint = paint.paint + if is_transform(child_paint): + child_transform = child_paint.gettransform() + child_paint = child_paint.paint # pytype: disable=attribute-error + + # 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 + # maybe_reuse.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 + gradient_fix_transform = Affine2D.compose_ltr( + (child_transform, reuse_transform.inverse()) ) + # skip reuse if combined transform overflows OT int bounds + if fixed_safe(*gradient_fix_transform): + try: + child_paint = child_paint.apply_transform( + gradient_fix_transform + ) # pytype: disable=attribute-error + except OverflowError: + child_paint = transformed(gradient_fix_transform, child_paint) - 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, - ), - ) + return child_paint + + +def _create_glyphs_for_svg_paths( + config: FontConfig, + color_glyph: ColorGlyph, + glyph_cache: GlyphReuseCache, + paint: Paint, +): + """Create glyphs for unique paths and references for repeat encounters.""" + if paint.format != PaintGlyph.format: + return paint - glyph = _create_glyph(color_glyph, paint, path_in_font_space) - glyph_cache.add_glyph(glyph.name, path_in_font_space) + paint = cast(PaintGlyph, paint) - return dataclasses.replace(paint, glyph=glyph.name) + if glyph_cache.is_known_glyph(paint.glyph): + return paint - return color_glyph.mutating_traverse(_update_paint_glyph) + paint = cast(PaintGlyph, paint) + 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 + if glyph_cache.is_known_path(maybe_reuse.shape): + paint = dataclasses.replace( + paint, glyph=glyph_cache.get_glyph_for_path(maybe_reuse.shape) + ) + 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) + glyph = _create_glyph_for_svg_path( + config, color_glyph, glyph_cache, maybe_reuse.shape + ) + paint = dataclasses.replace(paint, glyph=glyph.name) + + 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 = _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 + child_paint = _fix_nested_gradient(reuse_transform, paint) + if paint.paint is not child_paint: + paint = dataclasses.replace(paint, paint=child_paint) + + paint = transformed(reuse_transform, paint) + + return paint + + +def _migrate_paths_to_ufo_glyphs( + config: FontConfig, color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache +) -> ColorGlyph: + # Initially PaintGlyph's have paths not glyph names. + # We need to create all the unique paths as ufo glyphs and assign glyph names. + return color_glyph.mutating_traverse( + functools.partial( + _create_glyphs_for_svg_paths, config, color_glyph, glyph_cache + ) + ) def _draw_glyph_extents( @@ -376,26 +466,28 @@ def _draw_notdef(config: FontConfig, ufo: ufoLib2.Font): def _glyf_ufo( - config: FontConfig, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...] + config: FontConfig, + reusable_parts: ReusableParts, + ufo: ufoLib2.Font, + color_glyphs: Tuple[ColorGlyph, ...], ): # We want to mutate our view of color_glyphs color_glyphs = list(color_glyphs) # glyphs by reuse_key - glyph_cache = GlyphReuseCache(config.reuse_tolerance) + glyph_cache = GlyphReuseCache(reusable_parts) glyph_uses = Counter() for i, color_glyph in enumerate(color_glyphs): logging.debug( "%s %s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, - color_glyph.transform_for_font_space(), ) parent_glyph = color_glyph.ufo_glyph # generate glyphs for PaintGlyph's and assign glyph names color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( - color_glyph, glyph_cache + config, color_glyph, glyph_cache ) for root in color_glyph.painted_layers: @@ -448,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 @@ -568,6 +661,7 @@ def _ufo_colr_layers( def _colr_ufo( colr_version: int, config: FontConfig, + reusable_parts: ReusableParts, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ): @@ -599,7 +693,7 @@ def _colr_ufo( ufo_color_layers = {} # potentially reusable glyphs - glyph_cache = GlyphReuseCache(config.reuse_tolerance) + glyph_cache = GlyphReuseCache(reusable_parts) clipBoxes = {} quantization = config.clipbox_quantization @@ -608,15 +702,14 @@ 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 color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( - color_glyph, glyph_cache + config, color_glyph, glyph_cache ) if color_glyph.painted_layers: @@ -648,44 +741,37 @@ def _colr_ufo( def _sbix_ttfont( config: FontConfig, - _, + reusable_parts: ReusableParts, + ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): + del reusable_parts, ufo make_sbix_table(config, ttfont, color_glyphs) def _cbdt_ttfont( config: FontConfig, - _, + reusable_parts: ReusableParts, + ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): + del reusable_parts, ufo make_cbdt_table(config, ttfont, color_glyphs) def _svg_ttfont( config: FontConfig, - _, + reusable_parts: ReusableParts, + ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, picosvg: bool = True, compressed: bool = False, ): - make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) - - -def _picosvg_and_cbdt( - config: FontConfig, - _, - color_glyphs: Tuple[ColorGlyph, ...], - ttfont: ttLib.TTFont, -): - picosvg = True - compressed = False - # make the svg table first because it changes glyph order and cbdt cares - make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) - make_cbdt_table(config, ttfont, color_glyphs) + del ufo + make_svg_table(config, reusable_parts, ttfont, color_glyphs, picosvg, compressed) def _ensure_codepoints_will_have_glyphs(ufo, glyph_inputs): @@ -716,8 +802,11 @@ def _ensure_codepoints_will_have_glyphs(ufo, glyph_inputs): ufo.glyphOrder = ufo.glyphOrder + sorted(glyph_names) -def _generate_color_font(config: FontConfig, inputs: Iterable[InputGlyph]): +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) @@ -755,7 +844,9 @@ def _generate_color_font(config: FontConfig, inputs: Iterable[InputGlyph]): g.glyph_id == ufo_gid ), f"{g.ufo_glyph_name} is {ufo_gid} in ufo, {g.glyph_id} in ColorGlyph" - _COLOR_FORMAT_GENERATORS[config.color_format].apply_ufo(config, ufo, color_glyphs) + _COLOR_FORMAT_GENERATORS[config.color_format].apply_ufo( + config, reusable_parts, ufo, color_glyphs + ) if config.fea_file: with open(config.fea_file) as f: @@ -772,7 +863,7 @@ def _generate_color_font(config: FontConfig, inputs: Iterable[InputGlyph]): # Permit fixups where we can't express something adequately in UFO _COLOR_FORMAT_GENERATORS[config.color_format].apply_ttfont( - config, ufo, color_glyphs, ttfont + config, reusable_parts, ufo, color_glyphs, ttfont ) # some formats keep glyph order through to here @@ -823,10 +914,14 @@ def main(argv): ) inputs = list(_inputs(font_config, glyphmap.parse_csv(FLAGS.glyphmap_file))) - if not inputs: sys.exit("Please provide at least one svg filename") - ufo, ttfont = _generate_color_font(font_config, inputs) + + reusable_parts = ReusableParts() + if 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) logging.info("Wrote %s" % font_config.output_file) diff --git a/src/nanoemoji/write_part_file.py b/src/nanoemoji/write_part_file.py index c1cd9f86..f92209da 100644 --- a/src/nanoemoji/write_part_file.py +++ b/src/nanoemoji/write_part_file.py @@ -12,37 +12,43 @@ # 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_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.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() with util.file_printer(FLAGS.output_file) as print: print(parts.to_json()) 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_colr_1.ttx b/tests/clocks_colr_1.ttx index 4779ff61..12dc8af5 100644 --- a/tests/clocks_colr_1.ttx +++ b/tests/clocks_colr_1.ttx @@ -290,8 +290,8 @@ - - + + @@ -319,8 +319,8 @@ - - + + @@ -336,8 +336,8 @@ - - + + @@ -353,8 +353,8 @@ - - + + @@ -370,8 +370,8 @@ - - + + diff --git a/tests/clocks_colr_1_noreuse.ttx b/tests/clocks_colr_1_noreuse.ttx index 1a7d68a4..4e0d8d96 100644 --- a/tests/clocks_colr_1_noreuse.ttx +++ b/tests/clocks_colr_1_noreuse.ttx @@ -18,15 +18,6 @@ - - - - - - - - - @@ -44,16 +35,7 @@ - - - - - - - - - - + @@ -329,55 +311,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -399,164 +333,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -575,13 +351,13 @@ - + - + @@ -693,70 +469,25 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - + + + diff --git a/tests/clocks_rects_picosvg.ttx b/tests/clocks_rects_picosvg.ttx index 91b7ef2f..8f4016f0 100644 --- a/tests/clocks_rects_picosvg.ttx +++ b/tests/clocks_rects_picosvg.ttx @@ -117,15 +117,15 @@ - + - - + + - - + + ]]> 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/glyph_reuse_test.py b/tests/glyph_reuse_test.py index a77c089f..bfdfbdff 100644 --- a/tests/glyph_reuse_test.py +++ b/tests/glyph_reuse_test.py @@ -14,29 +14,94 @@ from nanoemoji.config import _DEFAULT_CONFIG -from nanoemoji.glyph_reuse import GlyphReuseCache, ReuseResult +from nanoemoji.glyph_reuse import GlyphReuseCache +from nanoemoji.parts import as_shape, ReuseResult, ReusableParts +from picosvg.geometric_types import Rect +from picosvg.svg import SVG from picosvg.svg_transform import Affine2D +from picosvg.svg_types import SVGPath import pytest +def _svg(view_box, *paths): + raw_svg = f'\n' + for path in paths: + raw_svg += f' \n' + raw_svg += "" + print(raw_svg) + return SVG.fromstring(raw_svg) + + +# https://github.com/googlefonts/nanoemoji/issues/313: fixed by ReusableParts. +# Previously if small was seen first no solution. +def test_small_then_large_circle(): + + small_circle = "M818.7666015625,133.60003662109375 C818.7666015625,130.28631591796875 816.080322265625,127.5999755859375 812.7666015625,127.5999755859375 C809.4529418945312,127.5999755859375 806.7666015625,130.28631591796875 806.7666015625,133.60003662109375 C806.7666015625,136.9136962890625 809.4529418945312,139.60003662109375 812.7666015625,139.60003662109375 C816.080322265625,139.60003662109375 818.7666015625,136.9136962890625 818.7666015625,133.60003662109375 Z" + large_circle = "M1237.5,350 C1237.5,18.629150390625 968.870849609375,-250 637.5,-250 C306.1291198730469,-250 37.5,18.629150390625 37.5,350 C37.5,681.370849609375 306.1291198730469,950 637.5,950 C968.870849609375,950 1237.5,681.370849609375 1237.5,350 Z" + + view_box = Rect(0, 0, 1024, 1024) + svg = _svg( + view_box, + # small circle, encountered first + small_circle, + # large circle, encountered second + large_circle, + ) + + parts = ReusableParts(_DEFAULT_CONFIG.reuse_tolerance, view_box=view_box) + parts.add(svg) + parts.compute_donors() + + assert ( + len(parts.shape_sets) == 1 + ), f"Did not normalize the same :( \n{parts.to_json()}" + + # should both have a solution + solutions = ( + parts.try_reuse(SVGPath(d=small_circle)), + parts.try_reuse(SVGPath(d=large_circle)), + ) + assert all(s is not None for s in solutions), parts.to_json() + + # exactly one should have identity, the other ... not + assert ( + len([s for s in solutions if s.transform.almost_equals(Affine2D.identity())]) + == 1 + ), parts.to_json() + assert ( + len( + [s for s in solutions if not s.transform.almost_equals(Affine2D.identity())] + ) + == 1 + ), parts.to_json() + + # Not try to fully exercise affine_between, just to sanity check things somewhat work @pytest.mark.parametrize( "path_a, path_b, expected_result", [ ( - "M-1,-1 L 0,1 L 1, -1 z", - "M-2,-2 L 0,2 L 2, -2 z", - ReuseResult(glyph_name="A", transform=Affine2D.identity().scale(2)), - ), - # https://github.com/googlefonts/nanoemoji/issues/313 - ( - "M818.7666015625,133.60003662109375 C818.7666015625,130.28631591796875 816.080322265625,127.5999755859375 812.7666015625,127.5999755859375 C809.4529418945312,127.5999755859375 806.7666015625,130.28631591796875 806.7666015625,133.60003662109375 C806.7666015625,136.9136962890625 809.4529418945312,139.60003662109375 812.7666015625,139.60003662109375 C816.080322265625,139.60003662109375 818.7666015625,136.9136962890625 818.7666015625,133.60003662109375 Z", - "M1237.5,350 C1237.5,18.629150390625 968.870849609375,-250 637.5,-250 C306.1291198730469,-250 37.5,18.629150390625 37.5,350 C37.5,681.370849609375 306.1291198730469,950 637.5,950 C968.870849609375,950 1237.5,681.370849609375 1237.5,350 Z", - None, + "M-2,-2 L0,2 L2,-2 z", + "M-1,-1 L0,1 L1,-1 z", + ReuseResult( + transform=Affine2D.identity().scale(0.5), + shape=as_shape(SVGPath(d="M-2,-2 L0,2 L2,-2 z")), + ), ), ], ) def test_glyph_reuse_cache(path_a, path_b, expected_result): - reuse_cache = GlyphReuseCache(_DEFAULT_CONFIG.reuse_tolerance) - reuse_cache.add_glyph("A", path_a) - assert reuse_cache.try_reuse(path_b) == expected_result + view_box = Rect(0, 0, 10, 10) + svg = _svg( + view_box, + path_a, + path_b, + ) + + parts = ReusableParts(_DEFAULT_CONFIG.reuse_tolerance, view_box=view_box) + parts.add(svg) + reuse_cache = GlyphReuseCache(parts) + reuse_cache.set_glyph_for_path("A", path_a) + reuse_cache.set_glyph_for_path("B", path_b) + + assert reuse_cache.try_reuse(path_b, view_box) == expected_result diff --git a/tests/group_opacity_reuse_picosvg.ttx b/tests/group_opacity_reuse_picosvg.ttx index cede523b..e5aaecbd 100644 --- a/tests/group_opacity_reuse_picosvg.ttx +++ b/tests/group_opacity_reuse_picosvg.ttx @@ -60,11 +60,14 @@ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> + <defs> + <path d="M40,30 L100,30 L100,50 L40,50 L40,30 Z" id="e000.0"/> + </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> - <path d="M60,10 L80,10 L80,60 L60,60 L60,10 Z" id="e000.0"/> + <use xlink:href="#e000.0" transform="matrix(0.333 0 0 2.5 46.667 -65)"/> <g opacity="0.6"> - <use xlink:href="#e000.0" x="-280" y="16" transform="matrix(5 0 0 0.4 1120 9.6)" fill="red"/> - <use xlink:href="#e000.0" x="-140" y="26" transform="matrix(3 0 0 0.4 280 15.6)" fill="blue"/> + <path d="M20,20 L120,20 L120,40 L20,40 L20,20 Z" fill="red"/> + <use xlink:href="#e000.0" fill="blue"/> </g> </g> </svg> 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..e919a4c3 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, parts, glyph_inputs = color_font_config( config_overrides, svgs, tmp_dir=config_file.parent ) + del parts, glyph_inputs config.write(config_file, font_config) print(config_file, font_config) diff --git a/tests/outside_viewbox_not_clipped_colr_1.ttx b/tests/outside_viewbox_not_clipped_colr_1.ttx index 7b92da06..b475773b 100644 --- a/tests/outside_viewbox_not_clipped_colr_1.ttx +++ b/tests/outside_viewbox_not_clipped_colr_1.ttx @@ -15,7 +15,7 @@ <mtx name=".space" width="100" lsb="0"/> <mtx name="g_25fd" width="100" lsb="0"/> <mtx name="g_25fd.0" width="100" lsb="20"/> - <mtx name="g_25fd.1" width="100" lsb="70"/> + <mtx name="g_25fd.1" width="100" lsb="20"/> </hmtx> <cmap> @@ -69,24 +69,28 @@ <instructions/> </TTGlyph> - <TTGlyph name="g_25fd.1" xMin="70" yMin="30" xMax="110" yMax="70"> + <TTGlyph name="g_25fd.1" xMin="20" yMin="-60" xMax="80" yMax="0"> <contour> - <pt x="110" y="50" on="1"/> - <pt x="110" y="56" on="0"/> - <pt x="105" y="65" on="0"/> - <pt x="96" y="70" on="0"/> - <pt x="90" y="70" on="1"/> - <pt x="84" y="70" on="0"/> - <pt x="75" y="65" on="0"/> - <pt x="70" y="56" on="0"/> - <pt x="70" y="50" on="1"/> - <pt x="70" y="44" on="0"/> - <pt x="75" y="35" on="0"/> - <pt x="84" y="30" on="0"/> - <pt x="90" y="30" on="1"/> - <pt x="96" y="30" on="0"/> - <pt x="105" y="35" on="0"/> - <pt x="110" y="44" on="0"/> + <pt x="80" y="-30" on="1"/> + <pt x="80" y="-24" on="0"/> + <pt x="75" y="-13" on="0"/> + <pt x="67" y="-5" on="0"/> + <pt x="56" y="0" on="0"/> + <pt x="50" y="0" on="1"/> + <pt x="44" y="0" on="0"/> + <pt x="33" y="-5" on="0"/> + <pt x="25" y="-13" on="0"/> + <pt x="20" y="-24" on="0"/> + <pt x="20" y="-30" on="1"/> + <pt x="20" y="-36" on="0"/> + <pt x="25" y="-47" on="0"/> + <pt x="33" y="-55" on="0"/> + <pt x="44" y="-60" on="0"/> + <pt x="50" y="-60" on="1"/> + <pt x="56" y="-60" on="0"/> + <pt x="67" y="-55" on="0"/> + <pt x="75" y="-47" on="0"/> + <pt x="80" y="-36" on="0"/> </contour> <instructions/> </TTGlyph> @@ -116,24 +120,29 @@ </Paint> <Glyph value="g_25fd.0"/> </Paint> - <Paint index="1" Format="10"><!-- PaintGlyph --> - <Paint Format="2"><!-- PaintSolid --> - <PaletteIndex value="1"/> - <Alpha value="1.0"/> - </Paint> - <Glyph value="g_25fd.1"/> - </Paint> - <Paint index="2" Format="22"><!-- PaintScaleUniformAroundCenter --> + <Paint index="1" Format="12"><!-- PaintTransform --> <Paint Format="10"><!-- PaintGlyph --> <Paint Format="2"><!-- PaintSolid --> - <PaletteIndex value="2"/> + <PaletteIndex value="1"/> <Alpha value="1.0"/> </Paint> <Glyph value="g_25fd.1"/> </Paint> - <scale value="1.5"/> - <centerX value="170"/> - <centerY value="210"/> + <Transform> + <xx value="0.667"/> + <yx value="0.0"/> + <xy value="0.0"/> + <yy value="0.667"/> + <dx value="56.67"/> + <dy value="69.97"/> + </Transform> + </Paint> + <Paint index="2" Format="10"><!-- PaintGlyph --> + <Paint Format="2"><!-- PaintSolid --> + <PaletteIndex value="2"/> + <Alpha value="1.0"/> + </Paint> + <Glyph value="g_25fd.1"/> </Paint> </LayerList> <ClipList Format="1"> diff --git a/tests/parentless_reused_el.ttx b/tests/parentless_reused_el.ttx index b26921c0..f391fdf0 100644 --- a/tests/parentless_reused_el.ttx +++ b/tests/parentless_reused_el.ttx @@ -16,16 +16,16 @@ <svgDoc endGlyphID="6" startGlyphID="4"> <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M0,0 L64,0 L64,64 L0,64 L0,0 Z" id="g_23_20e3.0"/> + <path d="M64,64 L128,64 L128,128 L64,128 L64,64 Z" id="g_1f170.0"/> </defs> <g id="glyph4" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_23_20e3.0" x="32" y="32" fill="red"/> + <use xlink:href="#g_1f170.0" fill="red" x="-32" y="-32"/> </g> <g id="glyph5" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_23_20e3.0" x="64" y="64" fill="blue"/> + <use xlink:href="#g_1f170.0" fill="blue"/> </g> <g id="glyph6" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_23_20e3.0" fill="yellow"/> + <use xlink:href="#g_1f170.0" x="-64" y="-64" fill="yellow"/> </g> </svg> diff --git a/tests/parts_test.py b/tests/parts_test.py index fa379390..c8cef3b2 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): +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 test_collects_normalized_shapes(): - shapes = ( - SVGRect(width=2, height=1), - SVGRect(width=4, height=2), - SVGCircle(r=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_add_svg(): + parts = _from_svg( + """ + + + + + """ ) + check_num_shapes(parts, 1) - parts = ReuseableParts() - parts.add(shapes) - - check_num_shapes(parts, 2) +def test_collects_normalized_shapes(): + parts = _from_svg( + """ + + + + + + """ + ) -def test_from_svg(): - parts = ReuseableParts.load(locate_test_file("rect.svg")) - check_num_shapes(parts, 1) + check_num_shapes(parts, 2) -def test_merge(): - shapes1 = (SVGRect(width=2, height=1),) - shapes2 = ( - SVGRect(width=4, height=2), - SVGCircle(r=2), +def test_simple_merge(): + p1 = _from_svg( + """ + + + + """ ) - - p1 = ReuseableParts() - p1.add(shapes1) check_num_shapes(p1, 1) - p2 = ReuseableParts() - p2.add(shapes2) + p2 = _from_svg( + """ + + + + + """ + ) check_num_shapes(p2, 2) p1.add(p2) @@ -77,12 +116,134 @@ 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( + """ + + + + + """ + ).topicosvg(), + # https://github.com/googlefonts/nanoemoji/issues/415 arc normalization + SVG.fromstring( + """ + + + + + """ + ).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_reuse_with_inconsistent_width_viewbox(): + # square in varied width vbox + # https://codepen.io/rs42/pen/xxPBrRJ?editors=1100 + svgs = ( + "square_vbox_narrow.svg", + "square_vbox_square.svg", + "square_vbox_wide.svg", + ) + svgs = tuple(SVG.parse(locate_test_file(svg)) for svg in svgs) + + parts = ReusableParts(view_box=Rect(0, 0, 10, 10)) + for svg in svgs: + parts.add(svg) + + # each square has the exact same box, did we find reuse? + 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_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 @@ + + + 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 @@ + + + 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 @@ + + + + diff --git a/tests/rect_colr_0.ttx b/tests/rect_colr_0.ttx index def8e5d5..73029e5f 100644 --- a/tests/rect_colr_0.ttx +++ b/tests/rect_colr_0.ttx @@ -14,8 +14,8 @@ - - + + @@ -65,18 +65,18 @@ - + - - - - + + + + - - + + @@ -84,8 +84,8 @@ - - + + diff --git a/tests/rect_colr_1.ttx b/tests/rect_colr_1.ttx index 048aa33e..3841d250 100644 --- a/tests/rect_colr_1.ttx +++ b/tests/rect_colr_1.ttx @@ -13,7 +13,7 @@ - + @@ -57,12 +57,12 @@ - + - - - - + + + + @@ -85,23 +85,23 @@ - - - - - - - - + - + - - + + + + + + + + + diff --git a/tests/rect_from_colr_1.svg b/tests/rect_from_colr_1.svg index 7596a90e..cdc860fe 100644 --- a/tests/rect_from_colr_1.svg +++ b/tests/rect_from_colr_1.svg @@ -1,5 +1,5 @@ - - + + diff --git a/tests/rect_picosvg.ttx b/tests/rect_picosvg.ttx index 67d04a1d..5807227c 100644 --- a/tests/rect_picosvg.ttx +++ b/tests/rect_picosvg.ttx @@ -61,11 +61,11 @@ - + - - - + + + ]]> diff --git a/tests/rects_colr_1.ttx b/tests/rects_colr_1.ttx index 76dc8582..15be2a95 100644 --- a/tests/rects_colr_1.ttx +++ b/tests/rects_colr_1.ttx @@ -14,7 +14,7 @@ - + @@ -61,12 +61,12 @@ - + - - - - + + + + @@ -98,41 +98,41 @@ - - - - - - - - + - + - - + + - + - - + + - + - - + + - - + + + + + + + + + diff --git a/tests/reorder_glyphs_test.py b/tests/reorder_glyphs_test.py index d280826f..e7ca9299 100644 --- a/tests/reorder_glyphs_test.py +++ b/tests/reorder_glyphs_test.py @@ -114,7 +114,7 @@ def _pair_pos(font): svgs = tuple( test_helper.locate_test_file(f"narrow_rects/{c}.svg") for c in "abc" ) - config, glyph_inputs = test_helper.color_font_config( + config, parts, glyph_inputs = test_helper.color_font_config( { "upem": 24, "fea_file": fea_file, @@ -123,7 +123,7 @@ def _pair_pos(font): tmp_dir=Path(temp_dir), codepoint_fn=lambda svg_file, _: (ord(svg_file.stem),), ) - _, font = write_font._generate_color_font(config, glyph_inputs) + _, font = write_font._generate_color_font(config, parts, glyph_inputs) # Initial state assert _pair_pos(font) == (("a", "b", -12), ("b", "c", -16)) diff --git a/tests/reuse_shape_varying_fill.ttx b/tests/reuse_shape_varying_fill.ttx index 0d227b8d..042e801c 100644 --- a/tests/reuse_shape_varying_fill.ttx +++ b/tests/reuse_shape_varying_fill.ttx @@ -61,12 +61,12 @@ - + - - - + + + ]]> diff --git a/tests/reused_shape_2_picosvg.ttx b/tests/reused_shape_2_picosvg.ttx index efaee039..bb048511 100644 --- a/tests/reused_shape_2_picosvg.ttx +++ b/tests/reused_shape_2_picosvg.ttx @@ -61,12 +61,21 @@ +<<<<<<< HEAD +======= + + + + + + +>>>>>>> 7597df1 (Generate a parts file for each input svg and use it to compute reuse.) ]]> diff --git a/tests/reused_shape_glyf.ttx b/tests/reused_shape_glyf.ttx index 8df12d4d..d8e6e9ce 100644 --- a/tests/reused_shape_glyf.ttx +++ b/tests/reused_shape_glyf.ttx @@ -14,7 +14,7 @@ - + @@ -58,51 +58,51 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/reused_shape_with_gradient_colr.ttx b/tests/reused_shape_with_gradient_colr.ttx index e26b0048..ddc07dcf 100644 --- a/tests/reused_shape_with_gradient_colr.ttx +++ b/tests/reused_shape_with_gradient_colr.ttx @@ -165,7 +165,7 @@ - + @@ -210,7 +210,7 @@ - + diff --git a/tests/reused_shape_with_gradient_svg.ttx b/tests/reused_shape_with_gradient_svg.ttx index 54e33af6..10051ae8 100644 --- a/tests/reused_shape_with_gradient_svg.ttx +++ b/tests/reused_shape_with_gradient_svg.ttx @@ -61,13 +61,13 @@ - + - + @@ -82,7 +82,7 @@ - + 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 @@ - + diff --git a/tests/svg_colr_svg_test.py b/tests/svg_colr_svg_test.py index fc293d08..593e4bab 100644 --- a/tests/svg_colr_svg_test.py +++ b/tests/svg_colr_svg_test.py @@ -89,16 +89,21 @@ ], ) def test_svg_to_colr_to_svg(svg_in, expected_svg_out, config_overrides): - config, glyph_inputs = test_helper.color_font_config( + config, parts, glyph_inputs = test_helper.color_font_config( config_overrides, (svg_in,), ) - _, ttfont = write_font._generate_color_font(config, glyph_inputs) + parts.compute_donors() + + _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) svg_before = SVG.parse(str(test_helper.locate_test_file(svg_in))) - font_to_svg_scale = svg_before.view_box().h / config.upem + svgs_from_font = tuple( colr_to_svg( - lambda _: svg_before.view_box(), ttfont, rounding_ndigits=3 + lambda _: parts.view_box, + lambda _: svg_before.view_box(), + ttfont, + rounding_ndigits=3, ).values() ) assert len(svgs_from_font) == 1 diff --git a/tests/test_helper.py b/tests/test_helper.py index c583e326..23023c16 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -26,12 +26,15 @@ from nanoemoji import features from nanoemoji.glyph import glyph_name from nanoemoji import write_font +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 import tempfile +from typing import Iterable, List, Tuple def test_data_dir() -> Path: @@ -72,7 +75,7 @@ def color_font_config( svgs, tmp_dir=None, codepoint_fn=lambda svg_file, idx: (0xE000 + idx,), -): +) -> Tuple[config.FontConfig, ReusableParts, List[write_font.InputGlyph]]: if tmp_dir is None: tmp_dir = Path(tempfile.gettempdir()) svgs = tuple(locate_test_file(s) for s in svgs) @@ -118,23 +121,42 @@ def color_font_config( for svg in svgs ] - return ( - font_config, - [ - write_font.InputGlyph( - svg_file, - bitmap_file, - codepoint_fn(svg_file, idx), - glyph_name(codepoint_fn(svg_file, idx)), - svg, - bitmap, - ) - for idx, ((svg_file, svg), (bitmap_file, bitmap)) in enumerate( - zip(svg_inputs, bitmap_inputs) - ) - ], + glyph_inputs = [ + write_font.InputGlyph( + svg_file, + bitmap_file, + codepoint_fn(svg_file, idx), + glyph_name(codepoint_fn(svg_file, idx)), + svg, + bitmap, + ) + for idx, ((svg_file, svg), (bitmap_file, bitmap)) in enumerate( + zip(svg_inputs, bitmap_inputs) + ) + ] + + parts = reusable_parts(font_config.upem, font_config.reuse_tolerance, glyph_inputs) + + return (font_config, parts, glyph_inputs) + + +def reusable_parts( + upem: int, reuse_tolerance: float, glyph_inputs: Iterable[write_font.InputGlyph] +) -> ReusableParts: + glyph_inputs = [g for g in glyph_inputs if g.svg] + parts = ReusableParts( + reuse_tolerance=reuse_tolerance, + 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 + def reload_font(ttfont): tmp = io.BytesIO() @@ -142,8 +164,8 @@ def reload_font(ttfont): return ttLib.TTFont(tmp) -def _save_actual_ttx(expected_ttx, ttx_content): - tmp_file = os.path.join(tempfile.gettempdir(), expected_ttx) +def _save_ttx_in_tmp(filename, ttx_content): + tmp_file = os.path.join(tempfile.gettempdir(), filename) with open(tmp_file, "w") as f: f.write(ttx_content) return tmp_file @@ -179,18 +201,12 @@ def _strip_inline_bitmaps(ttx_content): ) -def assert_expected_ttx( - svgs, - ttfont, - expected_ttx, - include_tables=None, - skip_tables=("head", "hhea", "maxp", "name", "post", "OS/2"), -): - actual_ttx = io.StringIO() +def _ttx(ttfont: ttLib.TTFont, include_tables=None, skip_tables=()) -> str: + str_io = io.StringIO() # Timestamps inside files #@$@#%@# # force consistent Unix newlines (the expected test files use \n too) ttfont.saveXML( - actual_ttx, + str_io, newlinestr="\n", tables=include_tables, skipTables=skip_tables, @@ -198,21 +214,37 @@ def assert_expected_ttx( ) # Elide ttFont attributes because ttLibVersion may change - actual = re.sub(r'\s+ttLibVersion="[^"]+"', "", actual_ttx.getvalue()) + ttx = re.sub(r'\s+ttLibVersion="[^"]+"', "", str_io.getvalue()) + + ttx = _strip_inline_bitmaps(ttx) + + return ttx - actual = _strip_inline_bitmaps(actual) + +def assert_expected_ttx( + svgs, + ttfont, + expected_ttx, + include_tables=None, + skip_tables=("head", "hhea", "maxp", "name", "post", "OS/2"), +): + actual = _ttx(ttfont, include_tables, skip_tables) expected_location = locate_test_file(expected_ttx) if os.path.isfile(expected_location): with open(expected_location) as f: expected = f.read() else: - tmp_file = _save_actual_ttx(expected_ttx, actual) + tmp_file = _save_ttx_in_tmp(expected_ttx, actual) raise FileNotFoundError( f"Missing expected in {expected_location}. Actual in {tmp_file}" ) if actual != expected: + full_ttx_file = _save_ttx_in_tmp( + expected_ttx.replace(".ttx", "-complete.ttx"), _ttx(ttfont) + ) + for line in difflib.unified_diff( expected.splitlines(keepends=True), actual.splitlines(keepends=True), @@ -221,8 +253,11 @@ def assert_expected_ttx( ): sys.stderr.write(line) print(f"SVGS: {svgs}") - tmp_file = _save_actual_ttx(expected_ttx, actual) - pytest.fail(f"{tmp_file} != {expected_ttx}") + print(f"Unabriged ttx in {full_ttx_file}") + tmp_file = _save_ttx_in_tmp(expected_ttx, actual) + pytest.fail( + f"{tmp_file} != {expected_location.relative_to(test_data_dir().parent)}" + ) # Copied from picosvg @@ -255,7 +290,7 @@ def svg_diff(actual_svg: SVG, expected_svg: SVG): drop_whitespace(expected_svg) print(f"A: {pretty_print(actual_svg.toetree())}") print(f"E: {pretty_print(expected_svg.toetree())}") - assert actual_svg.tostring() == expected_svg.tostring() + assert pretty_print(actual_svg.toetree()) == pretty_print(expected_svg.toetree()) def run(cmd): @@ -323,3 +358,9 @@ def bool_flag(name: str, value: bool) -> str: result += "no" result += name return result + + +def ttx(font: ttLib.TTFont) -> str: + raw_out = io.BytesIO() + font.saveXML(raw_out) + return raw_out.getvalue().decode("utf-8") diff --git a/tests/transformed_components_overlap.ttx b/tests/transformed_components_overlap.ttx index 7082eed6..e47afcac 100644 --- a/tests/transformed_components_overlap.ttx +++ b/tests/transformed_components_overlap.ttx @@ -57,12 +57,12 @@ - + - - - - + + + + @@ -85,14 +85,7 @@ - - - - - - - - + @@ -101,15 +94,15 @@ - - - - - - + + + + + + - + @@ -118,15 +111,15 @@ - - - - - - + + + + + + - + @@ -135,14 +128,21 @@ - - - - - - + + + + + + + + + + + + + diff --git a/tests/transformed_gradient_reuse.ttx b/tests/transformed_gradient_reuse.ttx index 248c1081..c7b13450 100644 --- a/tests/transformed_gradient_reuse.ttx +++ b/tests/transformed_gradient_reuse.ttx @@ -161,7 +161,7 @@ - + diff --git a/tests/write_font_test.py b/tests/write_font_test.py index 61ef3f9e..9f382b4a 100644 --- a/tests/write_font_test.py +++ b/tests/write_font_test.py @@ -21,6 +21,7 @@ from nanoemoji.colr import paints_of_type from nanoemoji.config import _DEFAULT_CONFIG from nanoemoji.glyphmap import GlyphMapping +from nanoemoji.parts import ReusableParts from picosvg.svg_transform import Affine2D from ufo2ft.constants import COLR_CLIP_BOXES_KEY from fontTools.ttLib.tables import otTables as ot @@ -37,10 +38,10 @@ ) @pytest.mark.parametrize("keep_glyph_names", [True, False]) def test_keep_glyph_names(svgs, color_format, keep_glyph_names): - config, glyph_inputs = test_helper.color_font_config( + config, parts, glyph_inputs = test_helper.color_font_config( {"color_format": color_format, "keep_glyph_names": keep_glyph_names}, svgs ) - ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert len(ufo.glyphOrder) == len(ttfont.getGlyphOrder()) @@ -83,10 +84,10 @@ def test_version(color_format, version_major, version_minor, expected): else: version_minor = 0 - config, glyph_inputs = test_helper.color_font_config( + config, parts, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) - ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert ufo.info.versionMajor == version_major @@ -112,10 +113,10 @@ def test_vertical_metrics(ascender, descender, linegap): "descender": descender, "linegap": linegap, } - config, glyph_inputs = test_helper.color_font_config( + config, parts, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) - ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) ttfont = test_helper.reload_font(ttfont) hhea = ttfont["hhea"] @@ -327,8 +328,8 @@ def test_vertical_metrics(ascender, descender, linegap): ], ) def test_write_font_binary(svgs, expected_ttx, config_overrides): - config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) - _, ttfont = write_font._generate_color_font(config, glyph_inputs) + config, parts, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) + _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) ttfont = test_helper.reload_font(ttfont) # sanity check the font # glyf should not have identical-except-name entries except .notdef and .space @@ -386,8 +387,8 @@ def test_write_font_binary(svgs, expected_ttx, config_overrides): ) def test_ufo_color_base_glyph_bounds(svgs, config_overrides, expected_clip_boxes): config_overrides = {"output_file": "font.ufo", **config_overrides} - config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) - ufo, _ = write_font._generate_color_font(config, glyph_inputs) + config, parts, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) + ufo, _ = write_font._generate_color_font(config, parts, glyph_inputs) base_glyph_names = [f"e{str(i).zfill(3)}" for i in range(len(svgs))] for base_glyph_name in base_glyph_names: @@ -411,8 +412,10 @@ class TestCurrentColor: # https://github.com/googlefonts/nanoemoji/issues/380 @staticmethod def generate_color_font(svgs, config_overrides): - config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) - _, ttfont = write_font._generate_color_font(config, glyph_inputs) + config, parts, glyph_inputs = test_helper.color_font_config( + config_overrides, svgs + ) + _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) return test_helper.reload_font(ttfont) @pytest.mark.parametrize( @@ -569,23 +572,86 @@ def test_square_varied_hmetrics(): "square_vbox_square.svg", "square_vbox_wide.svg", ) - config, glyph_inputs = test_helper.color_font_config({"width": 0}, svgs) - _, font = write_font._generate_color_font(config, glyph_inputs) + config, parts, glyph_inputs = test_helper.color_font_config({"width": 0}, svgs) + parts.compute_donors() + + _, font = write_font._generate_color_font(config, parts, glyph_inputs) colr = font["COLR"] glyph_names = {r.BaseGlyph for r in colr.table.BaseGlyphList.BaseGlyphPaintRecord} assert ( len(glyph_names) == 3 - ), f"Should have 3 color glyphs, got {names_of_colr_glyphs}" + ), f"Should have 3 color glyphs, got {names_of_colr_glyphs}\n{test_helper.ttx(font)}" glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} assert ( len(glyphs) == 1 - ), f"Should only be one glyph referenced from COLR, got {glyphs}" + ), f"Should only be one glyph referenced from COLR, got {glyphs}\n{test_helper.ttx(font)}" glyph_widths = sorted(font["hmtx"][gn][0] for gn in glyph_names) for i in range(len(glyph_widths) - 1): assert ( glyph_widths[i] * 2 == glyph_widths[i + 1] - ), f"n+1 should double, fails at {i}; {glyph_widths}" + ), 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", + "rect_10x.svg", + ) + config, parts, glyph_inputs = test_helper.color_font_config({}, svgs) + parts.compute_donors() + + _, font = write_font._generate_color_font(config, parts, glyph_inputs) + + # There should be only one glyph referenced by COLR + glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} + assert len(glyphs) == 1, str(glyphs) + + +# Reduced version of real problem observed with the demo fonts repo +# where an affine with massive coordinates was produced (dx = 54033) +def test_reuse_with_real_inconsistent_viewbox(): + svgs = ( + "rect_10.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() + + _, font = write_font._generate_color_font(config, parts, glyph_inputs) + + # There should be only one glyph referenced by COLR + glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} + assert len(glyphs) == 1, str(glyphs) From 6d34eda2b167b1abc357ffa708a1224e9b4f344c Mon Sep 17 00:00:00 2001 From: Rod S Date: Thu, 9 Jun 2022 20:56:41 -0700 Subject: [PATCH 2/4] Narrow the scope --- src/nanoemoji/color_glyph.py | 22 +- src/nanoemoji/colr_to_svg.py | 75 ++--- src/nanoemoji/config.py | 2 +- src/nanoemoji/extract_svgs_from_otsvg.py | 1 + src/nanoemoji/generate_svgs_from_colr.py | 4 +- src/nanoemoji/glyph_reuse.py | 127 ++++---- src/nanoemoji/maximum_color.py | 10 +- src/nanoemoji/nanoemoji.py | 5 +- src/nanoemoji/paint.py | 29 +- src/nanoemoji/parts.py | 5 +- src/nanoemoji/svg.py | 266 +++++++---------- src/nanoemoji/svg_path.py | 4 - src/nanoemoji/write_combined_part_files.py | 4 +- src/nanoemoji/write_font.py | 279 ++++++------------ tests/clocks_colr_1.ttx | 20 +- tests/clocks_colr_1_noreuse.ttx | 289 ++++++++++++++++++- tests/clocks_rects_picosvg.ttx | 10 +- tests/glyph_reuse_test.py | 91 +----- tests/group_opacity_reuse_picosvg.ttx | 9 +- tests/nanoemoji_test.py | 4 +- tests/outside_viewbox_not_clipped_colr_1.ttx | 69 ++--- tests/parentless_reused_el.ttx | 8 +- tests/parts_test.py | 20 -- tests/rect_colr_0.ttx | 22 +- tests/rect_colr_1.ttx | 34 +-- tests/rect_from_colr_1.svg | 6 +- tests/rect_picosvg.ttx | 8 +- tests/rects_colr_1.ttx | 50 ++-- tests/reorder_glyphs_test.py | 4 +- tests/reuse_shape_varying_fill.ttx | 8 +- tests/reused_shape_2_picosvg.ttx | 9 - tests/reused_shape_glyf.ttx | 80 ++--- tests/reused_shape_with_gradient_colr.ttx | 4 +- tests/reused_shape_with_gradient_svg.ttx | 6 +- tests/smiley_cheeks_gradient_svg.ttx | 2 +- tests/svg_colr_svg_test.py | 13 +- tests/test_helper.py | 107 +++---- tests/transformed_components_overlap.ttx | 66 ++--- tests/transformed_gradient_reuse.ttx | 2 +- tests/write_font_test.py | 100 ++----- 40 files changed, 844 insertions(+), 1030 deletions(-) diff --git a/src/nanoemoji/color_glyph.py b/src/nanoemoji/color_glyph.py index 6dec3c54..96a72f72 100644 --- a/src/nanoemoji/color_glyph.py +++ b/src/nanoemoji/color_glyph.py @@ -69,8 +69,7 @@ def _scale_viewbox_to_font_metrics( def map_viewbox_to_font_space( view_box: Rect, ascender: int, descender: int, width: int, user_transform: Affine2D ) -> Affine2D: - # print("map_viewbox_to_font_space", view_box, ascender, descender, width, user_transform) - transform = Affine2D.compose_ltr( + 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 @@ -78,8 +77,6 @@ def map_viewbox_to_font_space( user_transform, ] ) - # print("map_viewbox_to_font_space", transform) - return transform # https://docs.microsoft.com/en-us/typography/opentype/spec/svg#coordinate-systems-and-glyph-metrics @@ -475,6 +472,23 @@ 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): + 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/colr_to_svg.py b/src/nanoemoji/colr_to_svg.py index 727a5796..9c514e9d 100644 --- a/src/nanoemoji/colr_to_svg.py +++ b/src/nanoemoji/colr_to_svg.py @@ -31,7 +31,6 @@ is_transform, _decompose_uniform_transform, ) -from nanoemoji.parts import ReusableParts from nanoemoji.svg import ( _svg_matrix, _apply_solid_paint, @@ -183,12 +182,11 @@ def _apply_gradient_ot_paint( def _colr_v0_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttFont._TTGlyphSet, - shape_view_box_callback: ViewboxCallback, - dest_view_box_callback: ViewboxCallback, + view_box_callback: ViewboxCallback, glyph_name: str, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( - ttfont, shape_view_box_callback, dest_view_box_callback, glyph_name + ttfont, view_box_callback, glyph_name ) svg_root = _svg_root(view_box) for glyph_layer in ttfont["COLR"].ColorLayers[glyph_name]: @@ -325,41 +323,28 @@ def glyph_region(ttfont: ttLib.TTFont, glyph_name: str) -> Rect: def _view_box_and_transform( - ttfont: ttLib.TTFont, - shape_view_box_callback: ViewboxCallback, - dest_view_box_callback: ViewboxCallback, - glyph_name: str, + ttfont: ttLib.TTFont, view_box_callback: ViewboxCallback, glyph_name: str ) -> Tuple[Rect, Affine2D]: - dest_view_box = dest_view_box_callback(glyph_name) - assert dest_view_box.w > 0, f"0-width viewBox for {glyph_name}?!" + view_box = view_box_callback(glyph_name) + assert view_box.w > 0, f"0-width viewBox for {glyph_name}?!" - parts_view_box = shape_view_box_callback(glyph_name) + region = glyph_region(ttfont, glyph_name) + assert region.w > 0, f"0-width region for {glyph_name}?!" - width = ttfont["hmtx"][glyph_name][0] - if width == 0: - width = ttfont["glyf"][glyph_name].xMax - # TODO we lost user transform? - svg_units_to_font_units = color_glyph.map_viewbox_to_font_space( - parts_view_box, - ttfont["OS/2"].sTypoAscender, - ttfont["OS/2"].sTypoDescender, - width, - Affine2D.identity(), - ) + font_to_vbox = map_font_space_to_viewbox(view_box, region) - return (dest_view_box, svg_units_to_font_units.inverse()) + return (view_box, font_to_vbox) def _colr_v1_glyph_to_svg( ttfont: ttLib.TTFont, glyph_set: ttLib.ttFont._TTGlyphSet, - shape_view_box_callback: ViewboxCallback, view_box_callback: ViewboxCallback, glyph: otTables.BaseGlyphRecord, ) -> etree.Element: view_box, font_to_vbox = _view_box_and_transform( - ttfont, shape_view_box_callback, view_box_callback, glyph.BaseGlyph + ttfont, view_box_callback, glyph.BaseGlyph ) svg_root = _svg_root(view_box) svg_defs = svg_root[0] @@ -373,7 +358,7 @@ def _colr_v1_glyph_to_svg( def _new_reuse_cache() -> ReuseCache: - return ReuseCache(GlyphReuseCache(ReusableParts(reuse_tolerance=0.1))) + return ReuseCache(0.1, GlyphReuseCache(0.1)) def colr_glyphs(font: ttLib.TTFont) -> Iterable[int]: @@ -389,21 +374,13 @@ def colr_glyphs(font: ttLib.TTFont) -> Iterable[int]: def _colr_v0_to_svgs( - shape_view_box_callback: ViewboxCallback, - dest_view_box_callback: ViewboxCallback, - ttfont: ttLib.TTFont, + view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont ) -> Dict[str, SVG]: glyph_set = ttfont.getGlyphSet() return { g: SVG.fromstring( etree.tostring( - _colr_v0_glyph_to_svg( - ttfont, - glyph_set, - shape_view_box_callback, - dest_view_box_callback, - g, - ) + _colr_v0_glyph_to_svg(ttfont, glyph_set, view_box_callback, g) ) ) for g in ttfont["COLR"].ColorLayers @@ -411,21 +388,13 @@ def _colr_v0_to_svgs( def _colr_v1_to_svgs( - shape_view_box_callback: ViewboxCallback, - dest_view_box_callback: ViewboxCallback, - ttfont: ttLib.TTFont, + view_box_callback: ViewboxCallback, 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, - shape_view_box_callback, - dest_view_box_callback, - g, - ) + _colr_v1_glyph_to_svg(ttfont, glyph_set, view_box_callback, g) ) ) for g in ttfont["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord @@ -433,24 +402,18 @@ def _colr_v1_to_svgs( def colr_to_svg( - shape_view_box_callback: ViewboxCallback, - dest_view_box_callback: ViewboxCallback, + view_box_callback: ViewboxCallback, ttfont: ttLib.TTFont, rounding_ndigits: Optional[int] = None, ) -> Dict[str, SVG]: - """ - Creates a glyph name => SVG dict from a COLR table. - - shape_view_box_callback: function to get the space in which shapes for a glyph were defined, such as the parts view box. - dest_view_box_callback: function to get the view box of the destination - """ + """For testing only, don't use for real!""" assert len(ttfont["CPAL"].palettes) == 1, "We assume one palette" colr_version = ttfont["COLR"].version if colr_version == 0: - svgs = _colr_v0_to_svgs(shape_view_box_callback, dest_view_box_callback, ttfont) + svgs = _colr_v0_to_svgs(view_box_callback, ttfont) elif colr_version == 1: - svgs = _colr_v1_to_svgs(shape_view_box_callback, dest_view_box_callback, ttfont) + svgs = _colr_v1_to_svgs(view_box_callback, ttfont) else: raise NotImplementedError(colr_version) diff --git a/src/nanoemoji/config.py b/src/nanoemoji/config.py index 66687215..0d18c0d5 100644 --- a/src/nanoemoji/config.py +++ b/src/nanoemoji/config.py @@ -158,7 +158,7 @@ class FontConfig(NamedTuple): transform: Affine2D = Affine2D.identity() version_major: int = 1 version_minor: int = 0 - reuse_tolerance: float = 0.05 + reuse_tolerance: float = 0.1 ignore_reuse_error: bool = True keep_glyph_names: bool = False clip_to_viewbox: bool = True diff --git a/src/nanoemoji/extract_svgs_from_otsvg.py b/src/nanoemoji/extract_svgs_from_otsvg.py index 526c7df9..7c186ea8 100644 --- a/src/nanoemoji/extract_svgs_from_otsvg.py +++ b/src/nanoemoji/extract_svgs_from_otsvg.py @@ -19,6 +19,7 @@ from fontTools import ttLib from lxml import etree from nanoemoji import codepoints +from nanoemoji.color_glyph import map_viewbox_to_otsvg_space from nanoemoji.extract_svgs import svg_glyphs from nanoemoji import util import os diff --git a/src/nanoemoji/generate_svgs_from_colr.py b/src/nanoemoji/generate_svgs_from_colr.py index 6175042e..ce6178f2 100644 --- a/src/nanoemoji/generate_svgs_from_colr.py +++ b/src/nanoemoji/generate_svgs_from_colr.py @@ -53,9 +53,7 @@ def main(argv): assert "COLR" in font, f"No COLR table in {font_file}" logging.debug("Writing svgs from %s to %s", font_file, out_dir) - region_callback = lambda gn: _view_box(font, gn) - - for glyph_name, svg in colr_to_svg(region_callback, region_callback, font).items(): + for glyph_name, svg in colr_to_svg(lambda gn: _view_box(font, gn), font).items(): gid = font.getGlyphID(glyph_name) dest_file = out_dir / f"{gid:05d}.svg" with open(dest_file, "w") as f: diff --git a/src/nanoemoji/glyph_reuse.py b/src/nanoemoji/glyph_reuse.py index df748c93..fd50019f 100644 --- a/src/nanoemoji/glyph_reuse.py +++ b/src/nanoemoji/glyph_reuse.py @@ -16,97 +16,76 @@ from absl import logging -import dataclasses -from nanoemoji import parts -from nanoemoji.parts import ReuseResult, ReusableParts -from picosvg.geometric_types import Rect +from picosvg.svg_reuse import normalize, affine_between from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from typing import ( - MutableMapping, NamedTuple, Optional, - Set, ) from .fixed import fixed_safe -@dataclasses.dataclass +class ReuseResult(NamedTuple): + glyph_name: str + transform: Affine2D + + class GlyphReuseCache: - _reusable_parts: ReusableParts - _shape_to_glyph: MutableMapping[parts.Shape, str] = dataclasses.field( - default_factory=dict - ) - _glyph_to_shape: MutableMapping[str, parts.Shape] = dataclasses.field( - default_factory=dict - ) - - def try_reuse(self, path: str, path_view_box: Rect) -> ReuseResult: - assert path[0].upper() == "M", path - - path = SVGPath(d=path) - if path_view_box != self._reusable_parts.view_box: - print(path, path_view_box, self._reusable_parts.view_box) - path = path.apply_transform( - Affine2D.rect_to_rect(path_view_box, self._reusable_parts.view_box) - ) + def __init__(self, reuse_tolerance: float): + self._reuse_tolerance = reuse_tolerance + self._known_glyphs = set() + self._reusable_paths = {} - maybe_reuse = self._reusable_parts.try_reuse(path) + # normalize tries to remap first two significant vectors to [1 0], [0 1] + # reuse tolerence is relative to viewbox, which is typically much larger + # than the space normalize operates in. TODO: better default. + self._normalize_tolerance = self._reuse_tolerance / 10 - # https://github.com/googlefonts/nanoemoji/issues/313 avoid out of bounds affines - if maybe_reuse is not None and not fixed_safe(*maybe_reuse.transform): - logging.warning( - "affine_between overflows Fixed: %s %s, %s", - path, - maybe_reuse.shape, - maybe_reuse.transform, - ) - maybe_reuse = None - if maybe_reuse is None: - maybe_reuse = ReuseResult(Affine2D.identity(), parts.as_shape(path)) - return maybe_reuse - - def set_glyph_for_path(self, glyph_name: str, path: str): - norm = self._reusable_parts.normalize(path) - assert norm in self._reusable_parts.shape_sets, f"No shape set for {path}" - shape = parts.as_shape(SVGPath(d=path)) - assert ( - shape in self._reusable_parts.shape_sets[norm] - ), f"Not present in shape set: {path}" + def try_reuse(self, path: str) -> Optional[ReuseResult]: + """Try to reproduce path as the transformation of another glyph. + + Path is expected to be in font units. - if self._shape_to_glyph.get(shape, glyph_name) != glyph_name: - raise ValueError(f"{shape} cannot be associated with glyphs") - if self._glyph_to_shape.get(glyph_name, shape) != shape: - raise ValueError(f"{glyph_name} cannot be associated with multiple shapes") + Returns (glyph name, transform) if possible, None if not. + """ + assert ( + not path in self._known_glyphs + ), f"{path} isn't a path, it's a glyph name we've seen before" + assert path.startswith("M"), f"{path} doesn't look like a path" - self._shape_to_glyph[shape] = glyph_name - self._glyph_to_shape[glyph_name] = shape + if self._reuse_tolerance == -1: + return None - def get_glyph_for_path(self, path: str) -> str: - return self._shape_to_glyph[parts.as_shape(SVGPath(d=path))] + norm_path = normalize(SVGPath(d=path), self._normalize_tolerance).d + if norm_path not in self._reusable_paths: + return None - def forget_glyph_path_associations(self): - self._shape_to_glyph.clear() - self._glyph_to_shape.clear() + glyph_name, glyph_path = self._reusable_paths[norm_path] + affine = affine_between( + SVGPath(d=glyph_path), SVGPath(d=path), self._reuse_tolerance + ) + if affine is None: + logging.warning("affine_between failed: %s %s ", glyph_path, path) + return None - def consuming_glyphs(self, path: str) -> Set[str]: - norm = self._reusable_parts.normalize(path) - assert ( - norm in self._reusable_parts.shape_sets - ), f"{path} not associated with any parts!" - return { - self._shape_to_glyph[shape] - for shape in self._reusable_parts.shape_sets[norm] - } + # https://github.com/googlefonts/nanoemoji/issues/313 avoid out of bounds affines + if not fixed_safe(*affine): + logging.warning( + "affine_between overflows Fixed: %s %s, %s", glyph_path, path, affine + ) + return None - def is_known_glyph(self, glyph_name: str): - return glyph_name in self._glyph_to_shape + return ReuseResult(glyph_name, affine) - def is_known_path(self, path: str): - return parts.as_shape(SVGPath(d=path)) in self._shape_to_glyph + def add_glyph(self, glyph_name, glyph_path): + assert glyph_path.startswith("M"), f"{glyph_path} doesn't look like a path" + if self._reuse_tolerance != -1: + norm_path = normalize(SVGPath(d=glyph_path), self._normalize_tolerance).d + else: + norm_path = glyph_path + self._reusable_paths[norm_path] = (glyph_name, glyph_path) + self._known_glyphs.add(glyph_name) - def view_box(self) -> Rect: - """ - The box within which the shapes in this cache exist. - """ - return self._reusable_parts.view_box + def is_known_glyph(self, glyph_name): + return glyph_name in self._known_glyphs diff --git a/src/nanoemoji/maximum_color.py b/src/nanoemoji/maximum_color.py index 352bf2d4..0a5c1a19 100644 --- a/src/nanoemoji/maximum_color.py +++ b/src/nanoemoji/maximum_color.py @@ -189,7 +189,7 @@ def _write_preamble(nw: NinjaWriter): 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() @@ -267,7 +267,7 @@ def _picosvgs(nw: NinjaWriter, svg_files: List[Path]) -> List[Path]: def _part_file( - nw: NinjaWriter, reuse_tolerance: float, picosvg_files: List[Path] + nw: NinjaWriter, reuse_tolerance: float, upem: int, 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): @@ -275,7 +275,7 @@ def _part_file( part_file, "write_part_file", picosvg_file, - variables={"reuse_tolerance": reuse_tolerance}, + variables={"reuse_tolerance": reuse_tolerance, "upem": upem}, ) nw.build( @@ -347,7 +347,7 @@ def _generate_svg_from_colr( # create and merge an SVG table picosvgs = _picosvgs(nw, svg_files) - part_file = _part_file(nw, font_config.reuse_tolerance, picosvgs) + part_file = _part_file(nw, font_config.reuse_tolerance, font_config.upem, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "SVG ", input_font @@ -370,7 +370,7 @@ def _generate_colr_from_svg( # create and merge a COLR table picosvgs = _picosvgs(nw, svg_files) - part_file = _part_file(nw, font_config.reuse_tolerance, picosvgs) + part_file = _part_file(nw, font_config.reuse_tolerance, font_config.upem, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "COLR", input_font diff --git a/src/nanoemoji/nanoemoji.py b/src/nanoemoji/nanoemoji.py index 12cd88e3..5ebcba5b 100644 --- a/src/nanoemoji/nanoemoji.py +++ b/src/nanoemoji/nanoemoji.py @@ -382,7 +382,10 @@ def write_picosvg_builds( part_dest, "write_part_file", dest, - variables={"reuse_tolerance": font_config.reuse_tolerance, "upem": font_config.upem}, + variables={ + "reuse_tolerance": font_config.reuse_tolerance, + "upem": font_config.upem, + }, ) picosvgs.add(dest) diff --git a/src/nanoemoji/paint.py b/src/nanoemoji/paint.py index 1d684e02..e9660310 100644 --- a/src/nanoemoji/paint.py +++ b/src/nanoemoji/paint.py @@ -26,7 +26,6 @@ from nanoemoji.fixed import ( int16_safe, f2dot14_safe, - fixed_safe, MIN_INT16, MAX_INT16, MIN_UINT16, @@ -762,38 +761,14 @@ def is_gradient(paint_or_format) -> bool: ) -def _int16_part(v) -> int: - if v < 0: - return max(v, MIN_INT16) - else: - return min(v, MAX_INT16) - - def transformed(transform: Affine2D, target: Paint) -> Paint: - if transform.almost_equals(Affine2D.identity()): + if transform == Affine2D.identity(): return target sx, b, c, sy, dx, dy = transform - # Large translations (dx=50k) can occur due to use of big shapes - # to produce small shapes. Make a modest effort to work around. - if fixed_safe(sx, b, c, sy) and not fixed_safe(dx, dy): - dxt = _int16_part(dx) - dyt = _int16_part(dy) - dx -= dxt - dy -= dyt - if fixed_safe(dx, dy): - return PaintTranslate( - paint=transformed(Affine2D(sx, b, c, sy, dx, dy), target), - dx=dxt, - dy=dyt, - ) - raise ValueError(f"Transform values are outlandishly large :( {transform}") - # Int16 translation? - if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy).almost_equals( - transform - ): + if (dx, dy) != (0, 0) and Affine2D.identity().translate(dx, dy) == transform: if int16_safe(dx, dy): return PaintTranslate(paint=target, dx=dx, dy=dy) diff --git a/src/nanoemoji/parts.py b/src/nanoemoji/parts.py index f78b5612..f65a88d7 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -266,9 +266,7 @@ def from_json(cls, string: str) -> "ReusableParts": 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(" ")) - ) + parts.view_box = Rect(*(int(v) for v in json_dict.pop("view_box").split(" "))) assert parts.view_box[:2] == ( 0, 0, @@ -295,4 +293,3 @@ def loadjson(cls, input_file: Path) -> "ReusableParts": if ext != ".json": raise ValueError(f"Unknown format {input_file}") return cls.from_json(input_file.read_text(encoding="utf-8")) - diff --git a/src/nanoemoji/svg.py b/src/nanoemoji/svg.py index 52a36551..30f85c4f 100644 --- a/src/nanoemoji/svg.py +++ b/src/nanoemoji/svg.py @@ -21,10 +21,10 @@ from functools import reduce from lxml import etree # pytype: disable=import-error from nanoemoji.colors import Color -from nanoemoji.color_glyph import map_viewbox_to_otsvg_space, ColorGlyph +from nanoemoji.color_glyph import ColorGlyph from nanoemoji.config import FontConfig from nanoemoji.disjoint_set import DisjointSet -from nanoemoji.glyph_reuse import GlyphReuseCache +from nanoemoji.glyph_reuse import GlyphReuseCache, ReuseResult from nanoemoji.paint import ( _BasePaintTransform, CompositeMode, @@ -39,17 +39,15 @@ PaintColrLayers, is_transform, ) -from nanoemoji.parts import ReusableParts -from nanoemoji.reorder_glyphs import reorder_glyphs from picosvg.geometric_types import Rect -from picosvg import svg_meta +from nanoemoji.reorder_glyphs import reorder_glyphs from picosvg.svg import to_element, SVG, SVGTraverseContext +from picosvg import svg_meta from picosvg.svg_reuse import normalize, affine_between -from picosvg.svg_transform import parse_svg_transform, Affine2D +from picosvg.svg_transform import Affine2D from picosvg.svg_types import SVGPath from typing import ( cast, - Iterable, Mapping, MutableMapping, NamedTuple, @@ -75,10 +73,14 @@ class GradientReuseKey(NamedTuple): @dataclasses.dataclass class ReuseCache: + reuse_tolerance: float glyph_cache: GlyphReuseCache glyph_elements: MutableMapping[str, etree.Element] = dataclasses.field( default_factory=dict ) + reuse_results: MutableMapping[str, ReuseResult] = dataclasses.field( + default_factory=dict + ) gradient_ids: MutableMapping[GradientReuseKey, str] = dataclasses.field( default_factory=dict ) @@ -86,21 +88,17 @@ class ReuseCache: def add_glyph( self, glyph_name: str, - glyph_path: str, + reuse_result: Optional[ReuseResult], + context: SVGTraverseContext, ): assert glyph_name not in self.glyph_elements, f"Second addition of {glyph_name}" - self.glyph_elements[glyph_name] = to_element(SVGPath(d=glyph_path)) - - def reuse_spans_glyphs(self, path: str) -> bool: - return ( - len( - { - _color_glyph_name(gn) - for gn in self.glyph_cache.consuming_glyphs(path) - } - ) - > 1 - ) + if not isinstance(context.paint, PaintGlyph): + raise ValueError(f"Not a PaintGlyph {context}") + if not reuse_result: + self.glyph_cache.add_glyph(glyph_name, context.paint.glyph) + else: + self.reuse_results[glyph_name] = reuse_result + self.glyph_elements[glyph_name] = to_element(SVGPath(d=context.paint.glyph)) def _ensure_has_id(el: etree.Element): @@ -122,26 +120,11 @@ def _color_glyph_name(glyph_name: str) -> str: return glyph_name[: glyph_name.rindex(".")] -def _paint_glyphs(color_glyph: ColorGlyph) -> Iterable[PaintGlyph]: - for root in color_glyph.painted_layers: - for context in root.breadth_first(): - # Group glyphs based on common shapes - if not isinstance(context.paint, PaintGlyph): - continue - yield cast(PaintGlyph, context.paint) - - def _glyph_groups( - config: FontConfig, - color_glyphs: Sequence[ColorGlyph], - reusable_parts: ReusableParts, + config: FontConfig, color_glyphs: Sequence[ColorGlyph], reuse_cache: ReuseCache ) -> Tuple[Tuple[str, ...]]: """Find glyphs that need to be kept together by union find.""" - # This cache is solely to help us group - glyph_cache = GlyphReuseCache(reusable_parts) - - # Make sure we keep together color glyphs that share shapes reuse_groups = DisjointSet() # ensure glyphs sharing shapes are in the same doc for color_glyph in color_glyphs: reuse_groups.make_set(color_glyph.ufo_glyph_name) @@ -152,24 +135,17 @@ def _glyph_groups( if not isinstance(context.paint, PaintGlyph): continue - paint_glyph = cast(PaintGlyph, context.paint) - maybe_reuse = glyph_cache.try_reuse( - paint_glyph.glyph, color_glyph.svg.view_box() + glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) + reuse_result = reuse_cache.glyph_cache.try_reuse( + context.paint.glyph # pytype: disable=attribute-error ) - - if glyph_cache.is_known_path(maybe_reuse.shape): - # we've seen this exact path before, join the union with other consumers + reuse_cache.add_glyph(glyph_name, reuse_result, context) + if reuse_result: + # This entire color glyph and the one we share a shape with go in one svg doc reuse_groups.union( color_glyph.ufo_glyph_name, - _color_glyph_name( - glyph_cache.get_glyph_for_path(maybe_reuse.shape) - ), + _color_glyph_name(reuse_result.glyph_name), ) - else: - # I claim this path in the name of myself! - # Use a path-specific name so each color glyph can register multiple paths - paint_glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) - glyph_cache.set_glyph_for_path(paint_glyph_name, maybe_reuse.shape) nth_paint_glyph += 1 @@ -408,7 +384,7 @@ def _migrate_to_defs( svg: SVG, reused_el: etree.Element, reuse_cache: ReuseCache, - glyph_name: str, + reuse_result: ReuseResult, ): svg_defs = svg.xpath_one("//svg:defs") @@ -419,8 +395,13 @@ def _migrate_to_defs( 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"#{glyph_name}" - reused_el.addnext(svg_use) + 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 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 @@ -431,53 +412,24 @@ def _migrate_to_defs( return svg_use -def _transform(el: etree.Element, transform: Affine2D): - if transform.almost_equals(Affine2D.identity()): - return - - # offset-only use is nice and tidy - tx, ty = transform.gettranslate() - if el.tag == "use" and transform.translate(-tx, -ty).almost_equals( - Affine2D.identity() - ): - if tx: - el.attrib["x"] = _ntos(tx) - if ty: - el.attrib["y"] = _ntos(ty) - else: - el.attrib["transform"] = _svg_matrix(transform) - - def _create_use_element( - svg: SVG, parent_el: etree.Element, glyph_name: str, transform: Affine2D + svg: SVG, parent_el: etree.Element, reuse_result: ReuseResult ) -> etree.Element: svg_use = etree.SubElement(parent_el, "use", nsmap=svg.svg_root.nsmap) - svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{glyph_name}" - _transform(svg_use, transform) + svg_use.attrib[_XLINK_HREF_ATTR_NAME] = f"#{reuse_result.glyph_name}" + transform = reuse_result.transform + tx, ty = transform.gettranslate() + if tx: + svg_use.attrib["x"] = _ntos(tx) + if ty: + svg_use.attrib["y"] = _ntos(ty) + transform = transform.translate(-tx, -ty) + if transform != Affine2D.identity(): + svg_use.attrib["transform"] = _svg_matrix(transform) return svg_use -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): +def _add_glyph(svg: SVG, color_glyph: ColorGlyph, reuse_cache: ReuseCache): svg_defs = svg.xpath_one("//svg:defs") # each glyph gets a group of its very own @@ -489,11 +441,11 @@ def _add_glyph(config: FontConfig, svg: SVG, color_glyph: ColorGlyph, reuse_cach raise ValueError(f"{color_glyph.svg_filename} must declare view box") # https://github.com/googlefonts/nanoemoji/issues/58: group needs transform - transform = _font_units_to_svg_units(reuse_cache.glyph_cache.view_box(), config, color_glyph.ufo_glyph.width) + transform = color_glyph.transform_for_otsvg_space() if not transform.almost_equals(Affine2D.identity()): svg_g.attrib["transform"] = _svg_matrix(transform) - vbox_to_upem = _svg_units_to_font_units(reuse_cache.glyph_cache.view_box(), config, color_glyph.ufo_glyph.width) + vbox_to_upem = color_glyph.transform_for_font_space() upem_to_vbox = vbox_to_upem.inverse() # copy the shapes into our svg @@ -515,50 +467,42 @@ def _add_glyph(config: FontConfig, svg: SVG, color_glyph: ColorGlyph, reuse_cach path = path[:-1] if isinstance(context.paint, PaintGlyph): - paint = cast(PaintGlyph, context.paint) glyph_name = _paint_glyph_name(color_glyph, nth_paint_glyph) + assert ( + glyph_name in reuse_cache.glyph_elements + ), f"Missing entry for {glyph_name}" - assert paint.glyph.startswith( - "M" - ), f"{paint.glyph} doesn't look like a path" - - maybe_reuse = reuse_cache.glyph_cache.try_reuse( - paint.glyph, color_glyph.svg.view_box() - ) + reuse_result = reuse_cache.reuse_results.get(glyph_name, None) - # if we have a glyph for the shape already, use that - if reuse_cache.glyph_cache.is_known_path(maybe_reuse.shape): - reused_glyph_name = reuse_cache.glyph_cache.get_glyph_for_path( - maybe_reuse.shape - ) - svg_use = _create_use_element( - svg, parent_el, reused_glyph_name, maybe_reuse.transform - ) - - # the reused glyph will already exist, but it may need adjustment on grounds of reuse + if reuse_result: + reused_glyph_name = reuse_result.glyph_name reused_el = reuse_cache.glyph_elements[reused_glyph_name] - reused_el.attrib["id"] = reused_glyph_name # hard to target w/o id + reused_el_tag = etree.QName(reused_el.tag).localname + if reused_el_tag == "use": + # if reused_el is a it means _migrate_to_defs has already + # replaced a parent-less with a pointing to it, and + # has appended the reused path to . Assert that's the case + assert _use_href(reused_el) == reused_glyph_name + reused_el = svg.xpath_one( + f'//svg:defs/svg:path[@id="{reused_glyph_name}"]', + ) + elif reused_el_tag == "path": + # we need to refer to you, it's important you have identity + reused_el.attrib["id"] = reused_glyph_name + else: + raise AssertionError(reused_el_tag) - # In two cases, we need to push the reused element to the outer - # and replace its first occurence with a : - # 1) If reuse spans multiple glyphs, as Adobe Illustrator - # doesn't support direct references between glyphs: - # https://github.com/googlefonts/nanoemoji/issues/264 - # 2) If the reused_el has attributes cannot override - # https://github.com/googlefonts/nanoemoji/issues/337 - # We don't know if #1 holds so to make life simpler just always - # promote reused glyphs to defs - _migrate_to_defs(svg, reused_el, reuse_cache, reused_glyph_name) + svg_use = _create_use_element(svg, parent_el, reuse_result) # We must apply the inverse of the reuse transform to the children # paints to discount its effect on them, since these refer to the # original pre-reuse paths. _apply_paint expects 'transform' to be - # in UPEM space, whereas maybe_reuse.transform is in SVG space, so + # in UPEM space, whereas reuse_result.transform is in SVG space, so # we remap the (inverse of the) latter from SVG to UPEM. inverse_reuse_transform = Affine2D.compose_ltr( ( upem_to_vbox, - maybe_reuse.transform.inverse(), + reuse_result.transform.inverse(), upem_to_vbox.inverse(), ) ) @@ -566,19 +510,26 @@ def _add_glyph(config: FontConfig, svg: SVG, color_glyph: ColorGlyph, reuse_cach _apply_paint( svg_defs, svg_use, - paint.paint, + context.paint.paint, # pytype: disable=attribute-error upem_to_vbox, reuse_cache, inverse_reuse_transform, ) + + # In two cases, we need to push the reused element to the outer + # and replace its first occurence with a : + # 1) If reuse spans multiple glyphs, as Adobe Illustrator + # doesn't support direct references between glyphs: + # https://github.com/googlefonts/nanoemoji/issues/264 + # 2) If the reused_el has attributes cannot override + # https://github.com/googlefonts/nanoemoji/issues/337 + if color_glyph.ufo_glyph_name != _color_glyph_name( + reused_glyph_name + ) or _attrib_apply_paint_uses(reused_el): + _migrate_to_defs(svg, reused_el, reuse_cache, reuse_result) + else: - # otherwise, create a glyph for the target and use it - reuse_cache.glyph_cache.set_glyph_for_path( - glyph_name, maybe_reuse.shape - ) - reuse_cache.add_glyph(glyph_name, maybe_reuse.shape) el = reuse_cache.glyph_elements[glyph_name] - _apply_paint( svg_defs, el, @@ -586,15 +537,6 @@ def _add_glyph(config: FontConfig, svg: SVG, color_glyph: ColorGlyph, reuse_cach upem_to_vbox, reuse_cache, ) - - # If we need a transformed version of the path do it by wrapping a g around - # to ensure anyone else who reuses the shape doesn't pick up our transform - if not maybe_reuse.transform.almost_equals(Affine2D.identity()): - g = etree.Element("g") - _transform(g, maybe_reuse.transform) - g.append(el) - el = g - parent_el.append(el) # pytype: disable=attribute-error # don't update el_by_path because we're declaring this path complete @@ -693,15 +635,6 @@ def _tidy_use_elements(svg: SVG): for duplicate_attr in duplicate_attrs: del use_el.attrib[duplicate_attr] - # if the parent is a transform-only group migrate the transform to the use - if use_el.getparent().tag == "g" and set(use_el.getparent().attrib.keys()) == { - "transform" - }: - g = use_el.getparent() - g.addnext(use_el) - _transform(use_el, parse_svg_transform(g.attrib["transform"])) - g.getparent().remove(g) - # If all have the same paint attr migrate it from use to target for ref, uses in groupby(use_els, key=_use_href): uses = list(uses) @@ -716,12 +649,12 @@ def _tidy_use_elements(svg: SVG): def _picosvg_docs( - config: FontConfig, - reusable_parts: ReusableParts, - ttfont: ttLib.TTFont, - color_glyphs: Sequence[ColorGlyph], + config: FontConfig, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph] ) -> Sequence[Tuple[str, int, int]]: - reuse_groups = _glyph_groups(config, color_glyphs, reusable_parts) + reuse_cache = ReuseCache( + config.reuse_tolerance, GlyphReuseCache(config.reuse_tolerance) + ) + reuse_groups = _glyph_groups(config, color_glyphs, reuse_cache) color_glyph_order = [c.ufo_glyph_name for c in color_glyphs] color_glyphs = {c.ufo_glyph_name: c for c in color_glyphs} _ensure_groups_grouped_in_glyph_order( @@ -730,9 +663,7 @@ def _picosvg_docs( doc_list = [] for group in reuse_groups: - # reuse is only possible within a single doc so process each individually - # we created our reuse groups specifically to ensure glyphs that share are together - reuse_cache = ReuseCache(GlyphReuseCache(reusable_parts)) + reuse_cache.gradient_ids = {} # don't share gradients across groups # establish base svg, defs root = etree.Element( @@ -740,26 +671,24 @@ def _picosvg_docs( {"version": "1.1"}, nsmap={None: svg_meta.svgns(), "xlink": svg_meta.xlinkns()}, ) - etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) + defs = etree.SubElement(root, f"{{{svg_meta.svgns()}}}defs", nsmap=root.nsmap) svg = SVG(root) - del root # SVG could change root, shouldn't matter for us for color_glyph in (color_glyphs[g] for g in group): if color_glyph.painted_layers: - _add_glyph(config, svg, color_glyph, reuse_cache) + _add_glyph(svg, color_glyph, reuse_cache) # tidy use elements, they may emerge from _add_glyph with unnecessary attributes _tidy_use_elements(svg) # sort by @id to increase diff stability - defs = svg.xpath_one("//svg:defs") defs[:] = sorted(defs, key=lambda e: e.attrib["id"]) # strip if empty if len(defs) == 0: - svg.svg_root.remove(defs) + root.remove(defs) - if len(svg.svg_root) == 0: + if len(root) == 0: continue gids = tuple(color_glyphs[g].glyph_id for g in group) @@ -787,7 +716,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(_font_units_to_svg_units(color_glyph.svg.view_box(), config, color_glyph.ufo_glyph.width)), + "transform": _svg_matrix(color_glyph.transform_for_otsvg_space()), }, ) # move all the elements under the new group @@ -806,7 +735,6 @@ def _rawsvg_docs( def make_svg_table( config: FontConfig, - reusable_parts: ReusableParts, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], picosvg: bool, @@ -821,7 +749,7 @@ def make_svg_table( """ if picosvg: - doc_list = _picosvg_docs(config, reusable_parts, ttfont, color_glyphs) + doc_list = _picosvg_docs(config, ttfont, color_glyphs) else: doc_list = _rawsvg_docs(config, ttfont, color_glyphs) diff --git a/src/nanoemoji/svg_path.py b/src/nanoemoji/svg_path.py index 53904e15..1b190684 100644 --- a/src/nanoemoji/svg_path.py +++ b/src/nanoemoji/svg_path.py @@ -53,10 +53,6 @@ def draw_svg_path( pen.endPath() closed = False - for arg in args: - if not -32768 <= arg <= 32767: - assert False, path.d - # pens expect args as 2-tuples; we use the 'grouper' itertools recipe # https://docs.python.org/3.8/library/itertools.html#recipes assert len(args) % 2 == 0 diff --git a/src/nanoemoji/write_combined_part_files.py b/src/nanoemoji/write_combined_part_files.py index 76b83882..8645f3f6 100644 --- a/src/nanoemoji/write_combined_part_files.py +++ b/src/nanoemoji/write_combined_part_files.py @@ -35,9 +35,7 @@ def main(argv): 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} - ) + combined_parts.view_box = util.only({p.view_box for p in individual_parts}) for parts in individual_parts: combined_parts.add(parts) diff --git a/src/nanoemoji/write_font.py b/src/nanoemoji/write_font.py index af76b54d..919735d5 100644 --- a/src/nanoemoji/write_font.py +++ b/src/nanoemoji/write_font.py @@ -29,14 +29,13 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.pens.boundsPen import ControlBoundsPen from fontTools.pens.transformPen import TransformPen -import functools from itertools import chain from lxml import etree # pytype: disable=import-error from nanoemoji.bitmap_tables import make_cbdt_table, make_sbix_table from nanoemoji import codepoints, config, glyphmap from nanoemoji.colors import Color from nanoemoji.config import FontConfig -from nanoemoji.color_glyph import ColorGlyph, map_viewbox_to_font_space +from nanoemoji.color_glyph import ColorGlyph from nanoemoji.fixed import fixed_safe from nanoemoji.glyph import glyph_name from nanoemoji.glyphmap import GlyphMapping @@ -109,12 +108,9 @@ class InputGlyph(NamedTuple): # If the output file is .ufo then apply_ttfont is not called. # Where possible code to the ufo and let apply_ttfont be a nop. class ColorGenerator(NamedTuple): - apply_ufo: Callable[ - [FontConfig, ReusableParts, ufoLib2.Font, Tuple[ColorGlyph, ...]], None - ] + apply_ufo: Callable[[FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...]], None] apply_ttfont: Callable[ - [FontConfig, ReusableParts, ufoLib2.Font, Tuple[ColorGlyph, ...], ttLib.TTFont], - None, + [FontConfig, ufoLib2.Font, Tuple[ColorGlyph, ...], ttLib.TTFont], None ] font_ext: str # extension for font binary, .ttf or .otf @@ -267,166 +263,82 @@ def _next_name(ufo: ufoLib2.Font, name_fn) -> str: return name_fn(i) -def _create_glyph(color_glyph: ColorGlyph, path_in_font_space: str) -> Glyph: +def _create_glyph( + color_glyph: ColorGlyph, paint: PaintGlyph, path_in_font_space: str +) -> Glyph: glyph = _init_glyph(color_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 - - -def _svg_transform_in_font_space( - svg_units_to_font_units: Affine2D, transform: Affine2D -) -> Affine2D: - # We have a transform in svg space to apply to a thing in font space - # Come back from font space, apply, and then return to font space - return Affine2D.compose_ltr( - ( - svg_units_to_font_units.inverse(), - transform, - svg_units_to_font_units, - ) - ) - - -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 = _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 - ) - print( - "_create_glyph_for_svg_path, svg ", - path_in_svg_space, - "in", - glyph_cache.view_box(), - ) - print("_create_glyph_for_svg_path, transform ", svg_units_to_font_units) - print("_create_glyph_for_svg_path, font", path_in_font_space) - glyph = _create_glyph(color_glyph, path_in_font_space) - glyph_cache.set_glyph_for_path(glyph.name, path_in_svg_space) return glyph -def _fix_nested_gradient(reuse_transform: Affine2D, paint: PaintGlyph) -> Paint: - # If we are applying a transform that can change downstream gradients in unwanted ways. - # If that seems likely, fix it. - child_transform = Affine2D.identity() - child_paint = paint.paint - if is_transform(child_paint): - child_transform = child_paint.gettransform() - child_paint = child_paint.paint # pytype: disable=attribute-error - - # 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 - # maybe_reuse.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 - gradient_fix_transform = Affine2D.compose_ltr( - (child_transform, reuse_transform.inverse()) - ) - # skip reuse if combined transform overflows OT int bounds - if fixed_safe(*gradient_fix_transform): - try: - child_paint = child_paint.apply_transform( - gradient_fix_transform - ) # pytype: disable=attribute-error - except OverflowError: - child_paint = transformed(gradient_fix_transform, child_paint) - - return child_paint - - -def _create_glyphs_for_svg_paths( - config: FontConfig, - color_glyph: ColorGlyph, - glyph_cache: GlyphReuseCache, - paint: Paint, -): - """Create glyphs for unique paths and references for repeat encounters.""" - if paint.format != PaintGlyph.format: - return paint - - paint = cast(PaintGlyph, paint) - - if glyph_cache.is_known_glyph(paint.glyph): - return paint - - paint = cast(PaintGlyph, paint) - 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()) +def _migrate_paths_to_ufo_glyphs( + color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache +) -> ColorGlyph: + svg_units_to_font_units = color_glyph.transform_for_font_space() - # if we have a glyph for the target already, use that - if glyph_cache.is_known_path(maybe_reuse.shape): - paint = dataclasses.replace( - paint, glyph=glyph_cache.get_glyph_for_path(maybe_reuse.shape) - ) - 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) - glyph = _create_glyph_for_svg_path( - config, color_glyph, glyph_cache, maybe_reuse.shape - ) - paint = dataclasses.replace(paint, glyph=glyph.name) + # Walk through the color glyph, where we see a PaintGlyph take the path out of it, + # move the path into font coordinates, generate a ufo glyph, and push the name of + # the ufo glyph into the PaintGlyph + def _update_paint_glyph(paint): + if paint.format != PaintGlyph.format: + return paint - if not maybe_reuse.transform.almost_equals(Affine2D.identity()): - # TODO: when is it more compact to use a new transforming glyph? + if glyph_cache.is_known_glyph(paint.glyph): + return paint - 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 + 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 ) - 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 - child_paint = _fix_nested_gradient(reuse_transform, paint) - if paint.paint is not child_paint: - paint = dataclasses.replace(paint, paint=child_paint) - - paint = transformed(reuse_transform, paint) + 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, + ), + ) - return 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 _migrate_paths_to_ufo_glyphs( - config: FontConfig, color_glyph: ColorGlyph, glyph_cache: GlyphReuseCache -) -> ColorGlyph: - # Initially PaintGlyph's have paths not glyph names. - # We need to create all the unique paths as ufo glyphs and assign glyph names. - return color_glyph.mutating_traverse( - functools.partial( - _create_glyphs_for_svg_paths, config, color_glyph, glyph_cache - ) - ) + return color_glyph.mutating_traverse(_update_paint_glyph) def _draw_glyph_extents( @@ -466,28 +378,26 @@ def _draw_notdef(config: FontConfig, ufo: ufoLib2.Font): def _glyf_ufo( - config: FontConfig, - reusable_parts: ReusableParts, - ufo: ufoLib2.Font, - color_glyphs: Tuple[ColorGlyph, ...], + config: FontConfig, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...] ): # We want to mutate our view of color_glyphs color_glyphs = list(color_glyphs) # glyphs by reuse_key - glyph_cache = GlyphReuseCache(reusable_parts) + glyph_cache = GlyphReuseCache(config.reuse_tolerance) glyph_uses = Counter() for i, color_glyph in enumerate(color_glyphs): logging.debug( "%s %s %s", ufo.info.familyName, color_glyph.ufo_glyph_name, + color_glyph.transform_for_font_space(), ) parent_glyph = color_glyph.ufo_glyph # generate glyphs for PaintGlyph's and assign glyph names color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( - config, color_glyph, glyph_cache + color_glyph, glyph_cache ) for root in color_glyph.painted_layers: @@ -540,7 +450,6 @@ 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 @@ -661,7 +570,6 @@ def _ufo_colr_layers( def _colr_ufo( colr_version: int, config: FontConfig, - reusable_parts: ReusableParts, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ): @@ -693,7 +601,7 @@ def _colr_ufo( ufo_color_layers = {} # potentially reusable glyphs - glyph_cache = GlyphReuseCache(reusable_parts) + glyph_cache = GlyphReuseCache(config.reuse_tolerance) clipBoxes = {} quantization = config.clipbox_quantization @@ -702,14 +610,15 @@ 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 color_glyphs[i] = color_glyph = _migrate_paths_to_ufo_glyphs( - config, color_glyph, glyph_cache + color_glyph, glyph_cache ) if color_glyph.painted_layers: @@ -741,37 +650,44 @@ def _colr_ufo( def _sbix_ttfont( config: FontConfig, - reusable_parts: ReusableParts, - ufo: ufoLib2.Font, + _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): - del reusable_parts, ufo make_sbix_table(config, ttfont, color_glyphs) def _cbdt_ttfont( config: FontConfig, - reusable_parts: ReusableParts, - ufo: ufoLib2.Font, + _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, ): - del reusable_parts, ufo make_cbdt_table(config, ttfont, color_glyphs) def _svg_ttfont( config: FontConfig, - reusable_parts: ReusableParts, - ufo: ufoLib2.Font, + _, color_glyphs: Tuple[ColorGlyph, ...], ttfont: ttLib.TTFont, picosvg: bool = True, compressed: bool = False, ): - del ufo - make_svg_table(config, reusable_parts, ttfont, color_glyphs, picosvg, compressed) + make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) + + +def _picosvg_and_cbdt( + config: FontConfig, + _, + color_glyphs: Tuple[ColorGlyph, ...], + ttfont: ttLib.TTFont, +): + picosvg = True + compressed = False + # make the svg table first because it changes glyph order and cbdt cares + make_svg_table(config, ttfont, color_glyphs, picosvg, compressed) + make_cbdt_table(config, ttfont, color_glyphs) def _ensure_codepoints_will_have_glyphs(ufo, glyph_inputs): @@ -802,11 +718,8 @@ def _ensure_codepoints_will_have_glyphs(ufo, glyph_inputs): ufo.glyphOrder = ufo.glyphOrder + sorted(glyph_names) -def _generate_color_font( - config: FontConfig, reusable_parts: ReusableParts, inputs: Iterable[InputGlyph] -): +def _generate_color_font(config: FontConfig, 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) @@ -844,9 +757,7 @@ def _generate_color_font( g.glyph_id == ufo_gid ), f"{g.ufo_glyph_name} is {ufo_gid} in ufo, {g.glyph_id} in ColorGlyph" - _COLOR_FORMAT_GENERATORS[config.color_format].apply_ufo( - config, reusable_parts, ufo, color_glyphs - ) + _COLOR_FORMAT_GENERATORS[config.color_format].apply_ufo(config, ufo, color_glyphs) if config.fea_file: with open(config.fea_file) as f: @@ -863,7 +774,7 @@ def _generate_color_font( # Permit fixups where we can't express something adequately in UFO _COLOR_FORMAT_GENERATORS[config.color_format].apply_ttfont( - config, reusable_parts, ufo, color_glyphs, ttfont + config, ufo, color_glyphs, ttfont ) # some formats keep glyph order through to here @@ -914,14 +825,14 @@ def main(argv): ) inputs = list(_inputs(font_config, glyphmap.parse_csv(FLAGS.glyphmap_file))) - if not inputs: - sys.exit("Please provide at least one svg filename") reusable_parts = ReusableParts() if FLAGS.part_file: reusable_parts = ReusableParts.loadjson(Path(FLAGS.part_file)) - ufo, ttfont = _generate_color_font(font_config, reusable_parts, inputs) + if not inputs: + sys.exit("Please provide at least one svg filename") + ufo, ttfont = _generate_color_font(font_config, inputs) _write(ufo, ttfont, font_config.output_file) logging.info("Wrote %s" % font_config.output_file) diff --git a/tests/clocks_colr_1.ttx b/tests/clocks_colr_1.ttx index 12dc8af5..4779ff61 100644 --- a/tests/clocks_colr_1.ttx +++ b/tests/clocks_colr_1.ttx @@ -290,8 +290,8 @@ - - + + @@ -319,8 +319,8 @@ - - + + @@ -336,8 +336,8 @@ - - + + @@ -353,8 +353,8 @@ - - + + @@ -370,8 +370,8 @@ - - + + diff --git a/tests/clocks_colr_1_noreuse.ttx b/tests/clocks_colr_1_noreuse.ttx index 4e0d8d96..1a7d68a4 100644 --- a/tests/clocks_colr_1_noreuse.ttx +++ b/tests/clocks_colr_1_noreuse.ttx @@ -18,6 +18,15 @@ + + + + + + + + + @@ -35,7 +44,16 @@ - + + + + + + + + + + @@ -311,7 +329,55 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -333,6 +399,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -351,13 +575,13 @@ - + - + @@ -469,25 +693,70 @@ - + - + - + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/clocks_rects_picosvg.ttx b/tests/clocks_rects_picosvg.ttx index 8f4016f0..91b7ef2f 100644 --- a/tests/clocks_rects_picosvg.ttx +++ b/tests/clocks_rects_picosvg.ttx @@ -117,15 +117,15 @@ - + - - + + - - + + ]]> diff --git a/tests/glyph_reuse_test.py b/tests/glyph_reuse_test.py index bfdfbdff..a77c089f 100644 --- a/tests/glyph_reuse_test.py +++ b/tests/glyph_reuse_test.py @@ -14,94 +14,29 @@ from nanoemoji.config import _DEFAULT_CONFIG -from nanoemoji.glyph_reuse import GlyphReuseCache -from nanoemoji.parts import as_shape, ReuseResult, ReusableParts -from picosvg.geometric_types import Rect -from picosvg.svg import SVG +from nanoemoji.glyph_reuse import GlyphReuseCache, ReuseResult from picosvg.svg_transform import Affine2D -from picosvg.svg_types import SVGPath import pytest -def _svg(view_box, *paths): - raw_svg = f'\n' - for path in paths: - raw_svg += f' \n' - raw_svg += "" - print(raw_svg) - return SVG.fromstring(raw_svg) - - -# https://github.com/googlefonts/nanoemoji/issues/313: fixed by ReusableParts. -# Previously if small was seen first no solution. -def test_small_then_large_circle(): - - small_circle = "M818.7666015625,133.60003662109375 C818.7666015625,130.28631591796875 816.080322265625,127.5999755859375 812.7666015625,127.5999755859375 C809.4529418945312,127.5999755859375 806.7666015625,130.28631591796875 806.7666015625,133.60003662109375 C806.7666015625,136.9136962890625 809.4529418945312,139.60003662109375 812.7666015625,139.60003662109375 C816.080322265625,139.60003662109375 818.7666015625,136.9136962890625 818.7666015625,133.60003662109375 Z" - large_circle = "M1237.5,350 C1237.5,18.629150390625 968.870849609375,-250 637.5,-250 C306.1291198730469,-250 37.5,18.629150390625 37.5,350 C37.5,681.370849609375 306.1291198730469,950 637.5,950 C968.870849609375,950 1237.5,681.370849609375 1237.5,350 Z" - - view_box = Rect(0, 0, 1024, 1024) - svg = _svg( - view_box, - # small circle, encountered first - small_circle, - # large circle, encountered second - large_circle, - ) - - parts = ReusableParts(_DEFAULT_CONFIG.reuse_tolerance, view_box=view_box) - parts.add(svg) - parts.compute_donors() - - assert ( - len(parts.shape_sets) == 1 - ), f"Did not normalize the same :( \n{parts.to_json()}" - - # should both have a solution - solutions = ( - parts.try_reuse(SVGPath(d=small_circle)), - parts.try_reuse(SVGPath(d=large_circle)), - ) - assert all(s is not None for s in solutions), parts.to_json() - - # exactly one should have identity, the other ... not - assert ( - len([s for s in solutions if s.transform.almost_equals(Affine2D.identity())]) - == 1 - ), parts.to_json() - assert ( - len( - [s for s in solutions if not s.transform.almost_equals(Affine2D.identity())] - ) - == 1 - ), parts.to_json() - - # Not try to fully exercise affine_between, just to sanity check things somewhat work @pytest.mark.parametrize( "path_a, path_b, expected_result", [ ( - "M-2,-2 L0,2 L2,-2 z", - "M-1,-1 L0,1 L1,-1 z", - ReuseResult( - transform=Affine2D.identity().scale(0.5), - shape=as_shape(SVGPath(d="M-2,-2 L0,2 L2,-2 z")), - ), + "M-1,-1 L 0,1 L 1, -1 z", + "M-2,-2 L 0,2 L 2, -2 z", + ReuseResult(glyph_name="A", transform=Affine2D.identity().scale(2)), + ), + # https://github.com/googlefonts/nanoemoji/issues/313 + ( + "M818.7666015625,133.60003662109375 C818.7666015625,130.28631591796875 816.080322265625,127.5999755859375 812.7666015625,127.5999755859375 C809.4529418945312,127.5999755859375 806.7666015625,130.28631591796875 806.7666015625,133.60003662109375 C806.7666015625,136.9136962890625 809.4529418945312,139.60003662109375 812.7666015625,139.60003662109375 C816.080322265625,139.60003662109375 818.7666015625,136.9136962890625 818.7666015625,133.60003662109375 Z", + "M1237.5,350 C1237.5,18.629150390625 968.870849609375,-250 637.5,-250 C306.1291198730469,-250 37.5,18.629150390625 37.5,350 C37.5,681.370849609375 306.1291198730469,950 637.5,950 C968.870849609375,950 1237.5,681.370849609375 1237.5,350 Z", + None, ), ], ) def test_glyph_reuse_cache(path_a, path_b, expected_result): - view_box = Rect(0, 0, 10, 10) - svg = _svg( - view_box, - path_a, - path_b, - ) - - parts = ReusableParts(_DEFAULT_CONFIG.reuse_tolerance, view_box=view_box) - parts.add(svg) - reuse_cache = GlyphReuseCache(parts) - reuse_cache.set_glyph_for_path("A", path_a) - reuse_cache.set_glyph_for_path("B", path_b) - - assert reuse_cache.try_reuse(path_b, view_box) == expected_result + reuse_cache = GlyphReuseCache(_DEFAULT_CONFIG.reuse_tolerance) + reuse_cache.add_glyph("A", path_a) + assert reuse_cache.try_reuse(path_b) == expected_result diff --git a/tests/group_opacity_reuse_picosvg.ttx b/tests/group_opacity_reuse_picosvg.ttx index e5aaecbd..cede523b 100644 --- a/tests/group_opacity_reuse_picosvg.ttx +++ b/tests/group_opacity_reuse_picosvg.ttx @@ -60,14 +60,11 @@ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> - <defs> - <path d="M40,30 L100,30 L100,50 L40,50 L40,30 Z" id="e000.0"/> - </defs> <g id="glyph2" transform="matrix(0.781 0 0 0.781 0 -100)"> - <use xlink:href="#e000.0" transform="matrix(0.333 0 0 2.5 46.667 -65)"/> + <path d="M60,10 L80,10 L80,60 L60,60 L60,10 Z" id="e000.0"/> <g opacity="0.6"> - <path d="M20,20 L120,20 L120,40 L20,40 L20,20 Z" fill="red"/> - <use xlink:href="#e000.0" fill="blue"/> + <use xlink:href="#e000.0" x="-280" y="16" transform="matrix(5 0 0 0.4 1120 9.6)" fill="red"/> + <use xlink:href="#e000.0" x="-140" y="26" transform="matrix(3 0 0 0.4 280 15.6)" fill="blue"/> </g> </g> </svg> diff --git a/tests/nanoemoji_test.py b/tests/nanoemoji_test.py index e919a4c3..8ae1e81d 100644 --- a/tests/nanoemoji_test.py +++ b/tests/nanoemoji_test.py @@ -68,10 +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, parts, glyph_inputs = color_font_config( + font_config, glyph_inputs = color_font_config( config_overrides, svgs, tmp_dir=config_file.parent ) - del parts, glyph_inputs + del glyph_inputs config.write(config_file, font_config) print(config_file, font_config) diff --git a/tests/outside_viewbox_not_clipped_colr_1.ttx b/tests/outside_viewbox_not_clipped_colr_1.ttx index b475773b..7b92da06 100644 --- a/tests/outside_viewbox_not_clipped_colr_1.ttx +++ b/tests/outside_viewbox_not_clipped_colr_1.ttx @@ -15,7 +15,7 @@ <mtx name=".space" width="100" lsb="0"/> <mtx name="g_25fd" width="100" lsb="0"/> <mtx name="g_25fd.0" width="100" lsb="20"/> - <mtx name="g_25fd.1" width="100" lsb="20"/> + <mtx name="g_25fd.1" width="100" lsb="70"/> </hmtx> <cmap> @@ -69,28 +69,24 @@ <instructions/> </TTGlyph> - <TTGlyph name="g_25fd.1" xMin="20" yMin="-60" xMax="80" yMax="0"> + <TTGlyph name="g_25fd.1" xMin="70" yMin="30" xMax="110" yMax="70"> <contour> - <pt x="80" y="-30" on="1"/> - <pt x="80" y="-24" on="0"/> - <pt x="75" y="-13" on="0"/> - <pt x="67" y="-5" on="0"/> - <pt x="56" y="0" on="0"/> - <pt x="50" y="0" on="1"/> - <pt x="44" y="0" on="0"/> - <pt x="33" y="-5" on="0"/> - <pt x="25" y="-13" on="0"/> - <pt x="20" y="-24" on="0"/> - <pt x="20" y="-30" on="1"/> - <pt x="20" y="-36" on="0"/> - <pt x="25" y="-47" on="0"/> - <pt x="33" y="-55" on="0"/> - <pt x="44" y="-60" on="0"/> - <pt x="50" y="-60" on="1"/> - <pt x="56" y="-60" on="0"/> - <pt x="67" y="-55" on="0"/> - <pt x="75" y="-47" on="0"/> - <pt x="80" y="-36" on="0"/> + <pt x="110" y="50" on="1"/> + <pt x="110" y="56" on="0"/> + <pt x="105" y="65" on="0"/> + <pt x="96" y="70" on="0"/> + <pt x="90" y="70" on="1"/> + <pt x="84" y="70" on="0"/> + <pt x="75" y="65" on="0"/> + <pt x="70" y="56" on="0"/> + <pt x="70" y="50" on="1"/> + <pt x="70" y="44" on="0"/> + <pt x="75" y="35" on="0"/> + <pt x="84" y="30" on="0"/> + <pt x="90" y="30" on="1"/> + <pt x="96" y="30" on="0"/> + <pt x="105" y="35" on="0"/> + <pt x="110" y="44" on="0"/> </contour> <instructions/> </TTGlyph> @@ -120,29 +116,24 @@ </Paint> <Glyph value="g_25fd.0"/> </Paint> - <Paint index="1" Format="12"><!-- PaintTransform --> + <Paint index="1" Format="10"><!-- PaintGlyph --> + <Paint Format="2"><!-- PaintSolid --> + <PaletteIndex value="1"/> + <Alpha value="1.0"/> + </Paint> + <Glyph value="g_25fd.1"/> + </Paint> + <Paint index="2" Format="22"><!-- PaintScaleUniformAroundCenter --> <Paint Format="10"><!-- PaintGlyph --> <Paint Format="2"><!-- PaintSolid --> - <PaletteIndex value="1"/> + <PaletteIndex value="2"/> <Alpha value="1.0"/> </Paint> <Glyph value="g_25fd.1"/> </Paint> - <Transform> - <xx value="0.667"/> - <yx value="0.0"/> - <xy value="0.0"/> - <yy value="0.667"/> - <dx value="56.67"/> - <dy value="69.97"/> - </Transform> - </Paint> - <Paint index="2" Format="10"><!-- PaintGlyph --> - <Paint Format="2"><!-- PaintSolid --> - <PaletteIndex value="2"/> - <Alpha value="1.0"/> - </Paint> - <Glyph value="g_25fd.1"/> + <scale value="1.5"/> + <centerX value="170"/> + <centerY value="210"/> </Paint> </LayerList> <ClipList Format="1"> diff --git a/tests/parentless_reused_el.ttx b/tests/parentless_reused_el.ttx index f391fdf0..b26921c0 100644 --- a/tests/parentless_reused_el.ttx +++ b/tests/parentless_reused_el.ttx @@ -16,16 +16,16 @@ <svgDoc endGlyphID="6" startGlyphID="4"> <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> <defs> - <path d="M64,64 L128,64 L128,128 L64,128 L64,64 Z" id="g_1f170.0"/> + <path d="M0,0 L64,0 L64,64 L0,64 L0,0 Z" id="g_23_20e3.0"/> </defs> <g id="glyph4" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_1f170.0" fill="red" x="-32" y="-32"/> + <use xlink:href="#g_23_20e3.0" x="32" y="32" fill="red"/> </g> <g id="glyph5" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_1f170.0" fill="blue"/> + <use xlink:href="#g_23_20e3.0" x="64" y="64" fill="blue"/> </g> <g id="glyph6" transform="matrix(9.375 0 0 9.375 37.5 -950)"> - <use xlink:href="#g_1f170.0" x="-64" y="-64" fill="yellow"/> + <use xlink:href="#g_23_20e3.0" fill="yellow"/> </g> </svg> diff --git a/tests/parts_test.py b/tests/parts_test.py index c8cef3b2..c6ff204b 100644 --- a/tests/parts_test.py +++ b/tests/parts_test.py @@ -192,26 +192,6 @@ def test_reuse_with_inconsistent_square_viewbox(): }, "There should be 2 (not 4) shapes after scaled merge. r2 should use the big viewbox." -def test_reuse_with_inconsistent_width_viewbox(): - # square in varied width vbox - # https://codepen.io/rs42/pen/xxPBrRJ?editors=1100 - svgs = ( - "square_vbox_narrow.svg", - "square_vbox_square.svg", - "square_vbox_wide.svg", - ) - svgs = tuple(SVG.parse(locate_test_file(svg)) for svg in svgs) - - parts = ReusableParts(view_box=Rect(0, 0, 10, 10)) - for svg in svgs: - parts.add(svg) - - # each square has the exact same box, did we find reuse? - 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( """ diff --git a/tests/rect_colr_0.ttx b/tests/rect_colr_0.ttx index 73029e5f..def8e5d5 100644 --- a/tests/rect_colr_0.ttx +++ b/tests/rect_colr_0.ttx @@ -14,8 +14,8 @@ - - + + @@ -65,18 +65,18 @@ - + - - - - + + + + - - + + @@ -84,8 +84,8 @@ - - + + diff --git a/tests/rect_colr_1.ttx b/tests/rect_colr_1.ttx index 3841d250..048aa33e 100644 --- a/tests/rect_colr_1.ttx +++ b/tests/rect_colr_1.ttx @@ -13,7 +13,7 @@ - + @@ -57,12 +57,12 @@ - + - - - - + + + + @@ -85,23 +85,23 @@ - + + + + + + + + - + - - - - - - - - - + + diff --git a/tests/rect_from_colr_1.svg b/tests/rect_from_colr_1.svg index cdc860fe..51acd717 100644 --- a/tests/rect_from_colr_1.svg +++ b/tests/rect_from_colr_1.svg @@ -1,5 +1,5 @@ - - - + + + \ No newline at end of file diff --git a/tests/rect_picosvg.ttx b/tests/rect_picosvg.ttx index 5807227c..67d04a1d 100644 --- a/tests/rect_picosvg.ttx +++ b/tests/rect_picosvg.ttx @@ -61,11 +61,11 @@ - + - - - + + + ]]> diff --git a/tests/rects_colr_1.ttx b/tests/rects_colr_1.ttx index 15be2a95..76dc8582 100644 --- a/tests/rects_colr_1.ttx +++ b/tests/rects_colr_1.ttx @@ -14,7 +14,7 @@ - + @@ -61,12 +61,12 @@ - + - - - - + + + + @@ -98,41 +98,41 @@ - + + + + + + + + - + - - + + - + - - + + - + - - + + - - - - - - - - - + + diff --git a/tests/reorder_glyphs_test.py b/tests/reorder_glyphs_test.py index e7ca9299..d280826f 100644 --- a/tests/reorder_glyphs_test.py +++ b/tests/reorder_glyphs_test.py @@ -114,7 +114,7 @@ def _pair_pos(font): svgs = tuple( test_helper.locate_test_file(f"narrow_rects/{c}.svg") for c in "abc" ) - config, parts, glyph_inputs = test_helper.color_font_config( + config, glyph_inputs = test_helper.color_font_config( { "upem": 24, "fea_file": fea_file, @@ -123,7 +123,7 @@ def _pair_pos(font): tmp_dir=Path(temp_dir), codepoint_fn=lambda svg_file, _: (ord(svg_file.stem),), ) - _, font = write_font._generate_color_font(config, parts, glyph_inputs) + _, font = write_font._generate_color_font(config, glyph_inputs) # Initial state assert _pair_pos(font) == (("a", "b", -12), ("b", "c", -16)) diff --git a/tests/reuse_shape_varying_fill.ttx b/tests/reuse_shape_varying_fill.ttx index 042e801c..0d227b8d 100644 --- a/tests/reuse_shape_varying_fill.ttx +++ b/tests/reuse_shape_varying_fill.ttx @@ -61,12 +61,12 @@ - + - - - + + + ]]> diff --git a/tests/reused_shape_2_picosvg.ttx b/tests/reused_shape_2_picosvg.ttx index bb048511..efaee039 100644 --- a/tests/reused_shape_2_picosvg.ttx +++ b/tests/reused_shape_2_picosvg.ttx @@ -61,21 +61,12 @@ -<<<<<<< HEAD -======= - - - - - - ->>>>>>> 7597df1 (Generate a parts file for each input svg and use it to compute reuse.) ]]> diff --git a/tests/reused_shape_glyf.ttx b/tests/reused_shape_glyf.ttx index d8e6e9ce..8df12d4d 100644 --- a/tests/reused_shape_glyf.ttx +++ b/tests/reused_shape_glyf.ttx @@ -14,7 +14,7 @@ - + @@ -58,51 +58,51 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/reused_shape_with_gradient_colr.ttx b/tests/reused_shape_with_gradient_colr.ttx index ddc07dcf..e26b0048 100644 --- a/tests/reused_shape_with_gradient_colr.ttx +++ b/tests/reused_shape_with_gradient_colr.ttx @@ -165,7 +165,7 @@ - + @@ -210,7 +210,7 @@ - + diff --git a/tests/reused_shape_with_gradient_svg.ttx b/tests/reused_shape_with_gradient_svg.ttx index 10051ae8..54e33af6 100644 --- a/tests/reused_shape_with_gradient_svg.ttx +++ b/tests/reused_shape_with_gradient_svg.ttx @@ -61,13 +61,13 @@ - + - + @@ -82,7 +82,7 @@ - + diff --git a/tests/smiley_cheeks_gradient_svg.ttx b/tests/smiley_cheeks_gradient_svg.ttx index 9db082aa..c9c0c745 100644 --- a/tests/smiley_cheeks_gradient_svg.ttx +++ b/tests/smiley_cheeks_gradient_svg.ttx @@ -61,7 +61,7 @@ - + diff --git a/tests/svg_colr_svg_test.py b/tests/svg_colr_svg_test.py index 593e4bab..fc293d08 100644 --- a/tests/svg_colr_svg_test.py +++ b/tests/svg_colr_svg_test.py @@ -89,21 +89,16 @@ ], ) def test_svg_to_colr_to_svg(svg_in, expected_svg_out, config_overrides): - config, parts, glyph_inputs = test_helper.color_font_config( + config, glyph_inputs = test_helper.color_font_config( config_overrides, (svg_in,), ) - parts.compute_donors() - - _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + _, ttfont = write_font._generate_color_font(config, glyph_inputs) svg_before = SVG.parse(str(test_helper.locate_test_file(svg_in))) - + font_to_svg_scale = svg_before.view_box().h / config.upem svgs_from_font = tuple( colr_to_svg( - lambda _: parts.view_box, - lambda _: svg_before.view_box(), - ttfont, - rounding_ndigits=3, + lambda _: svg_before.view_box(), ttfont, rounding_ndigits=3 ).values() ) assert len(svgs_from_font) == 1 diff --git a/tests/test_helper.py b/tests/test_helper.py index 23023c16..c583e326 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -26,15 +26,12 @@ from nanoemoji import features from nanoemoji.glyph import glyph_name from nanoemoji import write_font -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 import tempfile -from typing import Iterable, List, Tuple def test_data_dir() -> Path: @@ -75,7 +72,7 @@ def color_font_config( svgs, tmp_dir=None, codepoint_fn=lambda svg_file, idx: (0xE000 + idx,), -) -> Tuple[config.FontConfig, ReusableParts, List[write_font.InputGlyph]]: +): if tmp_dir is None: tmp_dir = Path(tempfile.gettempdir()) svgs = tuple(locate_test_file(s) for s in svgs) @@ -121,42 +118,23 @@ def color_font_config( for svg in svgs ] - glyph_inputs = [ - write_font.InputGlyph( - svg_file, - bitmap_file, - codepoint_fn(svg_file, idx), - glyph_name(codepoint_fn(svg_file, idx)), - svg, - bitmap, - ) - for idx, ((svg_file, svg), (bitmap_file, bitmap)) in enumerate( - zip(svg_inputs, bitmap_inputs) - ) - ] - - parts = reusable_parts(font_config.upem, font_config.reuse_tolerance, glyph_inputs) - - return (font_config, parts, glyph_inputs) - - -def reusable_parts( - upem: int, reuse_tolerance: float, glyph_inputs: Iterable[write_font.InputGlyph] -) -> ReusableParts: - glyph_inputs = [g for g in glyph_inputs if g.svg] - parts = ReusableParts( - reuse_tolerance=reuse_tolerance, - view_box=Rect(0, 0, upem, upem), + return ( + font_config, + [ + write_font.InputGlyph( + svg_file, + bitmap_file, + codepoint_fn(svg_file, idx), + glyph_name(codepoint_fn(svg_file, idx)), + svg, + bitmap, + ) + for idx, ((svg_file, svg), (bitmap_file, bitmap)) in enumerate( + zip(svg_inputs, bitmap_inputs) + ) + ], ) - for glyph_input in glyph_inputs: - parts.add(glyph_input.svg) - - # TEMPORARY - print(parts.to_json()) - - return parts - def reload_font(ttfont): tmp = io.BytesIO() @@ -164,8 +142,8 @@ def reload_font(ttfont): return ttLib.TTFont(tmp) -def _save_ttx_in_tmp(filename, ttx_content): - tmp_file = os.path.join(tempfile.gettempdir(), filename) +def _save_actual_ttx(expected_ttx, ttx_content): + tmp_file = os.path.join(tempfile.gettempdir(), expected_ttx) with open(tmp_file, "w") as f: f.write(ttx_content) return tmp_file @@ -201,12 +179,18 @@ def _strip_inline_bitmaps(ttx_content): ) -def _ttx(ttfont: ttLib.TTFont, include_tables=None, skip_tables=()) -> str: - str_io = io.StringIO() +def assert_expected_ttx( + svgs, + ttfont, + expected_ttx, + include_tables=None, + skip_tables=("head", "hhea", "maxp", "name", "post", "OS/2"), +): + actual_ttx = io.StringIO() # Timestamps inside files #@$@#%@# # force consistent Unix newlines (the expected test files use \n too) ttfont.saveXML( - str_io, + actual_ttx, newlinestr="\n", tables=include_tables, skipTables=skip_tables, @@ -214,37 +198,21 @@ def _ttx(ttfont: ttLib.TTFont, include_tables=None, skip_tables=()) -> str: ) # Elide ttFont attributes because ttLibVersion may change - ttx = re.sub(r'\s+ttLibVersion="[^"]+"', "", str_io.getvalue()) - - ttx = _strip_inline_bitmaps(ttx) - - return ttx + actual = re.sub(r'\s+ttLibVersion="[^"]+"', "", actual_ttx.getvalue()) - -def assert_expected_ttx( - svgs, - ttfont, - expected_ttx, - include_tables=None, - skip_tables=("head", "hhea", "maxp", "name", "post", "OS/2"), -): - actual = _ttx(ttfont, include_tables, skip_tables) + actual = _strip_inline_bitmaps(actual) expected_location = locate_test_file(expected_ttx) if os.path.isfile(expected_location): with open(expected_location) as f: expected = f.read() else: - tmp_file = _save_ttx_in_tmp(expected_ttx, actual) + tmp_file = _save_actual_ttx(expected_ttx, actual) raise FileNotFoundError( f"Missing expected in {expected_location}. Actual in {tmp_file}" ) if actual != expected: - full_ttx_file = _save_ttx_in_tmp( - expected_ttx.replace(".ttx", "-complete.ttx"), _ttx(ttfont) - ) - for line in difflib.unified_diff( expected.splitlines(keepends=True), actual.splitlines(keepends=True), @@ -253,11 +221,8 @@ def assert_expected_ttx( ): sys.stderr.write(line) print(f"SVGS: {svgs}") - print(f"Unabriged ttx in {full_ttx_file}") - tmp_file = _save_ttx_in_tmp(expected_ttx, actual) - pytest.fail( - f"{tmp_file} != {expected_location.relative_to(test_data_dir().parent)}" - ) + tmp_file = _save_actual_ttx(expected_ttx, actual) + pytest.fail(f"{tmp_file} != {expected_ttx}") # Copied from picosvg @@ -290,7 +255,7 @@ def svg_diff(actual_svg: SVG, expected_svg: SVG): drop_whitespace(expected_svg) print(f"A: {pretty_print(actual_svg.toetree())}") print(f"E: {pretty_print(expected_svg.toetree())}") - assert pretty_print(actual_svg.toetree()) == pretty_print(expected_svg.toetree()) + assert actual_svg.tostring() == expected_svg.tostring() def run(cmd): @@ -358,9 +323,3 @@ def bool_flag(name: str, value: bool) -> str: result += "no" result += name return result - - -def ttx(font: ttLib.TTFont) -> str: - raw_out = io.BytesIO() - font.saveXML(raw_out) - return raw_out.getvalue().decode("utf-8") diff --git a/tests/transformed_components_overlap.ttx b/tests/transformed_components_overlap.ttx index e47afcac..7082eed6 100644 --- a/tests/transformed_components_overlap.ttx +++ b/tests/transformed_components_overlap.ttx @@ -57,12 +57,12 @@ - + - - - - + + + + @@ -85,7 +85,14 @@ - + + + + + + + + @@ -94,15 +101,15 @@ - - - - - - + + + + + + - + @@ -111,15 +118,15 @@ - - - - - - + + + + + + - + @@ -128,21 +135,14 @@ - - - - - - + + + + + + - - - - - - - diff --git a/tests/transformed_gradient_reuse.ttx b/tests/transformed_gradient_reuse.ttx index c7b13450..248c1081 100644 --- a/tests/transformed_gradient_reuse.ttx +++ b/tests/transformed_gradient_reuse.ttx @@ -161,7 +161,7 @@ - + diff --git a/tests/write_font_test.py b/tests/write_font_test.py index 9f382b4a..61ef3f9e 100644 --- a/tests/write_font_test.py +++ b/tests/write_font_test.py @@ -21,7 +21,6 @@ from nanoemoji.colr import paints_of_type from nanoemoji.config import _DEFAULT_CONFIG from nanoemoji.glyphmap import GlyphMapping -from nanoemoji.parts import ReusableParts from picosvg.svg_transform import Affine2D from ufo2ft.constants import COLR_CLIP_BOXES_KEY from fontTools.ttLib.tables import otTables as ot @@ -38,10 +37,10 @@ ) @pytest.mark.parametrize("keep_glyph_names", [True, False]) def test_keep_glyph_names(svgs, color_format, keep_glyph_names): - config, parts, glyph_inputs = test_helper.color_font_config( + config, glyph_inputs = test_helper.color_font_config( {"color_format": color_format, "keep_glyph_names": keep_glyph_names}, svgs ) - ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert len(ufo.glyphOrder) == len(ttfont.getGlyphOrder()) @@ -84,10 +83,10 @@ def test_version(color_format, version_major, version_minor, expected): else: version_minor = 0 - config, parts, glyph_inputs = test_helper.color_font_config( + config, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) - ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) assert ufo.info.versionMajor == version_major @@ -113,10 +112,10 @@ def test_vertical_metrics(ascender, descender, linegap): "descender": descender, "linegap": linegap, } - config, parts, glyph_inputs = test_helper.color_font_config( + config, glyph_inputs = test_helper.color_font_config( config_overrides, ("rect.svg", "one-o-clock.svg") ) - ufo, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + ufo, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) hhea = ttfont["hhea"] @@ -328,8 +327,8 @@ def test_vertical_metrics(ascender, descender, linegap): ], ) def test_write_font_binary(svgs, expected_ttx, config_overrides): - config, parts, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) - _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) + _, ttfont = write_font._generate_color_font(config, glyph_inputs) ttfont = test_helper.reload_font(ttfont) # sanity check the font # glyf should not have identical-except-name entries except .notdef and .space @@ -387,8 +386,8 @@ def test_write_font_binary(svgs, expected_ttx, config_overrides): ) def test_ufo_color_base_glyph_bounds(svgs, config_overrides, expected_clip_boxes): config_overrides = {"output_file": "font.ufo", **config_overrides} - config, parts, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) - ufo, _ = write_font._generate_color_font(config, parts, glyph_inputs) + config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) + ufo, _ = write_font._generate_color_font(config, glyph_inputs) base_glyph_names = [f"e{str(i).zfill(3)}" for i in range(len(svgs))] for base_glyph_name in base_glyph_names: @@ -412,10 +411,8 @@ class TestCurrentColor: # https://github.com/googlefonts/nanoemoji/issues/380 @staticmethod def generate_color_font(svgs, config_overrides): - config, parts, glyph_inputs = test_helper.color_font_config( - config_overrides, svgs - ) - _, ttfont = write_font._generate_color_font(config, parts, glyph_inputs) + config, glyph_inputs = test_helper.color_font_config(config_overrides, svgs) + _, ttfont = write_font._generate_color_font(config, glyph_inputs) return test_helper.reload_font(ttfont) @pytest.mark.parametrize( @@ -572,86 +569,23 @@ def test_square_varied_hmetrics(): "square_vbox_square.svg", "square_vbox_wide.svg", ) - config, parts, glyph_inputs = test_helper.color_font_config({"width": 0}, svgs) - parts.compute_donors() - - _, font = write_font._generate_color_font(config, parts, glyph_inputs) + config, glyph_inputs = test_helper.color_font_config({"width": 0}, svgs) + _, font = write_font._generate_color_font(config, glyph_inputs) colr = font["COLR"] glyph_names = {r.BaseGlyph for r in colr.table.BaseGlyphList.BaseGlyphPaintRecord} assert ( len(glyph_names) == 3 - ), f"Should have 3 color glyphs, got {names_of_colr_glyphs}\n{test_helper.ttx(font)}" + ), f"Should have 3 color glyphs, got {names_of_colr_glyphs}" glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} assert ( len(glyphs) == 1 - ), f"Should only be one glyph referenced from COLR, got {glyphs}\n{test_helper.ttx(font)}" + ), f"Should only be one glyph referenced from COLR, got {glyphs}" glyph_widths = sorted(font["hmtx"][gn][0] for gn in glyph_names) for i in range(len(glyph_widths) - 1): assert ( glyph_widths[i] * 2 == glyph_widths[i + 1] - ), 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", - "rect_10x.svg", - ) - config, parts, glyph_inputs = test_helper.color_font_config({}, svgs) - parts.compute_donors() - - _, font = write_font._generate_color_font(config, parts, glyph_inputs) - - # There should be only one glyph referenced by COLR - glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} - assert len(glyphs) == 1, str(glyphs) - - -# Reduced version of real problem observed with the demo fonts repo -# where an affine with massive coordinates was produced (dx = 54033) -def test_reuse_with_real_inconsistent_viewbox(): - svgs = ( - "rect_10.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() - - _, font = write_font._generate_color_font(config, parts, glyph_inputs) - - # There should be only one glyph referenced by COLR - glyphs = {p.Glyph for p in paints_of_type(font, ot.PaintFormat.PaintGlyph)} - assert len(glyphs) == 1, str(glyphs) + ), f"n+1 should double, fails at {i}; {glyph_widths}" From b2a1690474cb25769c7da7ccfd428d45cc54b7d9 Mon Sep 17 00:00:00 2001 From: Rod S Date: Sat, 11 Jun 2022 14:20:08 -0700 Subject: [PATCH 3/4] Per code review, use asc-desc not upem. --- src/nanoemoji/maximum_color.py | 13 ++++++++----- src/nanoemoji/nanoemoji.py | 4 ++-- src/nanoemoji/write_part_file.py | 5 +++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/nanoemoji/maximum_color.py b/src/nanoemoji/maximum_color.py index 0a5c1a19..355ab1a7 100644 --- a/src/nanoemoji/maximum_color.py +++ b/src/nanoemoji/maximum_color.py @@ -189,7 +189,7 @@ def _write_preamble(nw: NinjaWriter): module_rule( nw, "write_part_file", - f"--reuse_tolerance $reuse_tolerance --upem $upem --output_file $out $in", + f"--reuse_tolerance $reuse_tolerance --wh $wh --output_file $out $in", ) nw.newline() @@ -267,7 +267,7 @@ def _picosvgs(nw: NinjaWriter, svg_files: List[Path]) -> List[Path]: def _part_file( - nw: NinjaWriter, reuse_tolerance: float, upem: int, picosvg_files: List[Path] + 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): @@ -275,7 +275,10 @@ def _part_file( part_file, "write_part_file", picosvg_file, - variables={"reuse_tolerance": reuse_tolerance, "upem": upem}, + variables={ + "reuse_tolerance": font_config.reuse_tolerance, + "wh": font_config.ascender - font_config.descender, + }, ) nw.build( @@ -347,7 +350,7 @@ def _generate_svg_from_colr( # create and merge an SVG table picosvgs = _picosvgs(nw, svg_files) - part_file = _part_file(nw, font_config.reuse_tolerance, font_config.upem, picosvgs) + part_file = _part_file(nw, font_config, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "SVG ", input_font @@ -370,7 +373,7 @@ def _generate_colr_from_svg( # create and merge a COLR table picosvgs = _picosvgs(nw, svg_files) - part_file = _part_file(nw, font_config.reuse_tolerance, font_config.upem, picosvgs) + part_file = _part_file(nw, font_config, picosvgs) output_file = _generate_additional_color_table( nw, input_font, picosvgs + [input_font], "COLR", input_font diff --git a/src/nanoemoji/nanoemoji.py b/src/nanoemoji/nanoemoji.py index 5ebcba5b..55a7b9d9 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 --upem $upem --output_file $out $in", + f"--reuse_tolerance $reuse_tolerance --wh $wh --output_file $out $in", ) nw.newline() @@ -384,7 +384,7 @@ def write_picosvg_builds( dest, variables={ "reuse_tolerance": font_config.reuse_tolerance, - "upem": font_config.upem, + "wh": font_config.ascender - font_config.descender, }, ) diff --git a/src/nanoemoji/write_part_file.py b/src/nanoemoji/write_part_file.py index f92209da..ef1f9227 100644 --- a/src/nanoemoji/write_part_file.py +++ b/src/nanoemoji/write_part_file.py @@ -28,6 +28,7 @@ FLAGS = flags.FLAGS +flags.DEFINE_integer("wh", None, "The width and height to use.") flags.DEFINE_bool("compute_donors", False, "Whether to compute donors.") @@ -35,7 +36,7 @@ def main(argv): if len(argv) != 2: raise ValueError("Specify exactly one input") - view_box = Rect(0, 0, FLAGS.upem, FLAGS.upem) + 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])) @@ -49,6 +50,6 @@ def main(argv): if __name__ == "__main__": - flags.mark_flag_as_required("upem") + flags.mark_flag_as_required("wh") flags.mark_flag_as_required("reuse_tolerance") app.run(main) From f662ad5edf6ab60e3867f297ac41b1d48b43783e Mon Sep 17 00:00:00 2001 From: Rod S Date: Sat, 11 Jun 2022 14:46:03 -0700 Subject: [PATCH 4/4] Add test that exposes a bug, fix bug. Caught in review. --- src/nanoemoji/color_glyph.py | 6 +++--- src/nanoemoji/parts.py | 6 +++++- tests/parts_test.py | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) 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/parts.py b/src/nanoemoji/parts.py index f65a88d7..fd27c025 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -24,6 +24,7 @@ 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 @@ -149,7 +150,10 @@ def add(self, source: PathSource): ) elif isinstance(source, SVG): source.checkpicosvg() - transform = Affine2D.rect_to_rect(source.view_box(), self.view_box) + 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: raise ValueError(f"Unknown part source: {type(source)}") diff --git a/tests/parts_test.py b/tests/parts_test.py index c6ff204b..74833b0c 100644 --- a/tests/parts_test.py +++ b/tests/parts_test.py @@ -227,3 +227,30 @@ def test_scaled_merge_arcs_to_cubics(): "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"