diff --git a/src/nanoemoji/color_glyph.py b/src/nanoemoji/color_glyph.py index 4a042c48..e7ccc2db 100644 --- a/src/nanoemoji/color_glyph.py +++ b/src/nanoemoji/color_glyph.py @@ -472,23 +472,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/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 fd27c025..095961c9 100644 --- a/src/nanoemoji/parts.py +++ b/src/nanoemoji/parts.py @@ -152,7 +152,7 @@ def add(self, source: PathSource): source.checkpicosvg() source_box = source.view_box() transform = scale_viewbox_to_font_metrics( - self.view_box, source_box.h, 0, source_box.w + source_box, self.view_box.h, 0, self.view_box.w ) shapes = tuple(s.as_path() for s in source.shapes()) else: diff --git a/src/nanoemoji/svg.py b/src/nanoemoji/svg.py index 30f85c4f..69c6669e 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,59 @@ 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 +495,15 @@ 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 +525,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" - if reuse_result: - reused_glyph_name = reuse_result.glyph_name + maybe_reuse = reuse_cache.glyph_cache.try_reuse( + paint.glyph, color_glyph.svg.view_box() + ) + + # 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 +576,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 +596,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 +703,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 +726,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 +740,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 +750,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 +797,11 @@ 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 +820,7 @@ def _rawsvg_docs( def make_svg_table( config: FontConfig, + reusable_parts: ReusableParts, ttfont: ttLib.TTFont, color_glyphs: Sequence[ColorGlyph], picosvg: bool, @@ -749,7 +835,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/write_font.py b/src/nanoemoji/write_font.py index 919735d5..6f6a6d2a 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 @@ -108,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 @@ -263,82 +267,172 @@ 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, + ) + ) - # 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 glyph_cache.is_known_glyph(paint.glyph): - return paint +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 - 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 + + 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()) + + # 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 + ) - glyph = _create_glyph(color_glyph, paint, path_in_font_space) - glyph_cache.add_glyph(glyph.name, path_in_font_space) + 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}" - return dataclasses.replace(paint, glyph=glyph.name) + # 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) - return color_glyph.mutating_traverse(_update_paint_glyph) + 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( @@ -378,26 +472,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: @@ -450,6 +546,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 @@ -570,6 +667,7 @@ def _ufo_colr_layers( def _colr_ufo( colr_version: int, config: FontConfig, + reusable_parts: ReusableParts, ufo: ufoLib2.Font, color_glyphs: Tuple[ColorGlyph, ...], ): @@ -601,7 +699,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 @@ -610,15 +708,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: @@ -650,44 +747,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): @@ -718,8 +808,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) @@ -757,7 +850,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: @@ -774,7 +869,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 @@ -825,14 +920,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)) - if not inputs: - sys.exit("Please provide at least one svg filename") - ufo, ttfont = _generate_color_font(font_config, inputs) + 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/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/color_glyph_test.py b/tests/color_glyph_test.py index a906d1b1..16315251 100644 --- a/tests/color_glyph_test.py +++ b/tests/color_glyph_test.py @@ -13,9 +13,10 @@ # limitations under the License. from nanoemoji.colors import Color -from nanoemoji.color_glyph import ColorGlyph +from nanoemoji.color_glyph import map_viewbox_to_font_space, ColorGlyph from nanoemoji.config import FontConfig from nanoemoji.paint import * +from picosvg.geometric_types import Rect from picosvg.svg import SVG from picosvg.svg_transform import Affine2D import dataclasses @@ -126,7 +127,16 @@ def test_transform_and_width( config, ufo, "duck", 1, "glyph_name", [0x0042], SVG.fromstring(svg_str) ) - assert color_glyph.transform_for_font_space() == pytest.approx(expected_transform) + assert ( + map_viewbox_to_font_space( + Rect(*(int(s) for s in view_box.split(" "))), + ufo.info.ascender, + ufo.info.descender, + color_glyph.ufo_glyph.width, + config.transform, + ) + == pytest.approx(expected_transform) + ) assert ufo[color_glyph.ufo_glyph_name].width == expected_width 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/nanoemoji_test.py b/tests/nanoemoji_test.py index 8ae1e81d..e919a4c3 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, glyph_inputs = color_font_config( + font_config, parts, glyph_inputs = color_font_config( config_overrides, svgs, tmp_dir=config_file.parent ) - del glyph_inputs + del parts, glyph_inputs config.write(config_file, font_config) print(config_file, font_config) diff --git a/tests/omit_empty_color_glyphs_svg.ttx b/tests/omit_empty_color_glyphs_svg.ttx index 82933da5..18b7ef2e 100644 --- a/tests/omit_empty_color_glyphs_svg.ttx +++ b/tests/omit_empty_color_glyphs_svg.ttx @@ -55,8 +55,8 @@ <SVG> <svgDoc endGlyphID="2" startGlyphID="2"> <![CDATA[<svg xmlns="http://www.w3.org/2000/svg" version="1.1"> - <g id="glyph2" transform="matrix(120 0 0 120 37.5 -950)"> - <path d="M4,4 L8,4 L8,8 L4,8 L4,4 Z" fill="orange"/> + <g id="glyph2" transform="translate(37.5, -950)"> + <path d="M480,480 L960,480 L960,960 L480,960 L480,480 Z" fill="orange"/> </g> </svg> diff --git a/tests/outside_viewbox_not_clipped_colr_1.ttx b/tests/outside_viewbox_not_clipped_colr_1.ttx index 7b92da06..5f368e83 100644 --- a/tests/outside_viewbox_not_clipped_colr_1.ttx +++ b/tests/outside_viewbox_not_clipped_colr_1.ttx @@ -15,7 +15,7 @@ - + @@ -69,24 +69,28 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -116,24 +120,29 @@ - - - - - - - - + - + - - - + + + + + + + + + + + + + + + diff --git a/tests/parentless_reused_el.ttx b/tests/parentless_reused_el.ttx index b26921c0..2a2fa952 100644 --- a/tests/parentless_reused_el.ttx +++ b/tests/parentless_reused_el.ttx @@ -16,16 +16,16 @@ - + - - + + - - + + - - + + ]]> diff --git a/tests/parts_test.py b/tests/parts_test.py index 74833b0c..4c1bbe1f 100644 --- a/tests/parts_test.py +++ b/tests/parts_test.py @@ -254,3 +254,18 @@ def test_squares_stay_squares(): 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" + + +def test_scaled_squares_stay_squares(): + parts = ReusableParts(view_box=Rect(0, 0, 100, 100)) + + 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"))) + + 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 l30,0 l0,30 l-30,0 l0,-30 z" + }, "The square should be 30x30" \ No newline at end of file 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_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_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..3c42b2c8 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,89 @@ 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)