Skip to content

Commit

Permalink
Use part files to compute reuse
Browse files Browse the repository at this point in the history
  • Loading branch information
rsheeter committed Jun 12, 2022
1 parent d65a627 commit e5cb9f1
Show file tree
Hide file tree
Showing 34 changed files with 1,016 additions and 831 deletions.
17 changes: 0 additions & 17 deletions src/nanoemoji/color_glyph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
75 changes: 56 additions & 19 deletions src/nanoemoji/colr_to_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
is_transform,
_decompose_uniform_transform,
)
from nanoemoji.parts import ReusableParts
from nanoemoji.svg import (
_svg_matrix,
_apply_solid_paint,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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]
Expand All @@ -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]:
Expand All @@ -374,46 +389,68 @@ 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
}


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
}


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)

Expand Down
2 changes: 1 addition & 1 deletion src/nanoemoji/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/nanoemoji/extract_svgs_from_otsvg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/nanoemoji/generate_svgs_from_colr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
127 changes: 74 additions & 53 deletions src/nanoemoji/glyph_reuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit e5cb9f1

Please sign in to comment.