Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use precomputed part files for shape reuse #414

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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