-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #407 from googlefonts/parts
Precompute reusable parts for each picosvg
- Loading branch information
Showing
4 changed files
with
293 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# Copyright 2022 Google LLC | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
import dataclasses | ||
from functools import lru_cache | ||
import json | ||
from nanoemoji.config import FontConfig | ||
from pathlib import Path | ||
from picosvg.svg import SVG | ||
from picosvg.svg_reuse import normalize | ||
from picosvg.svg_types import SVGPath, SVGShape | ||
from typing import Iterable, List, MutableMapping, NewType, Set, Tuple, Union | ||
|
||
|
||
PathSource = Union[SVGShape, Iterable[SVGShape], "ReuseableParts"] | ||
|
||
|
||
@lru_cache(maxsize=1) | ||
def _default_tolerence() -> float: | ||
return FontConfig().reuse_tolerance | ||
|
||
|
||
def _is_iterable_of(thing, desired_type) -> bool: | ||
try: | ||
it = iter(thing) | ||
except TypeError: | ||
return False | ||
|
||
try: | ||
val = next(it) | ||
return isinstance(val, desired_type) | ||
except StopIteration: | ||
return True | ||
|
||
|
||
# an SVG style path, e.g. the d attribute of <svg:path/> | ||
Shape = NewType("Shape", str) | ||
|
||
|
||
# A normalized SVG style path | ||
NormalizedShape = NewType("NormalizedShape", str) | ||
|
||
|
||
# A set of shapes that normalize to the same path | ||
ShapeSet = NewType("ShapeSet", Set[Shape]) | ||
|
||
|
||
@dataclasses.dataclass | ||
class ReuseableParts: | ||
version: Tuple[int, int, int] = (1, 0, 0) | ||
reuse_tolerance: float = dataclasses.field(default_factory=_default_tolerence) | ||
shape_sets: MutableMapping[NormalizedShape, ShapeSet] = dataclasses.field( | ||
default_factory=dict | ||
) | ||
|
||
def _add_norm_path(self, norm: NormalizedShape, shape: Shape): | ||
if norm not in self.shape_sets: | ||
self.shape_sets[norm] = ShapeSet(set()) | ||
self.shape_sets[norm].add(shape) | ||
|
||
def _add(self, shape: Shape): | ||
norm = NormalizedShape(shape) | ||
if self.reuse_tolerance != -1: | ||
norm = NormalizedShape(normalize(SVGPath(d=shape), self.reuse_tolerance).d) | ||
self._add_norm_path(norm, shape) | ||
|
||
def add(self, source: PathSource): | ||
if isinstance(source, ReuseableParts): | ||
for normalized, shape_set in source.shape_sets.items(): | ||
for shape in shape_set: | ||
self._add_norm_path(normalized, shape) | ||
else: | ||
if not _is_iterable_of(source, SVGShape): | ||
source = (source,) | ||
for a_source in source: | ||
if not isinstance(a_source, SVGShape): | ||
raise ValueError(f"Illegal source {type(a_source)}") | ||
svg_shape: SVGShape = a_source # pytype: disable=attribute-error | ||
self._add(Shape(svg_shape.as_path().d)) | ||
|
||
def to_json(self): | ||
json_dict = { | ||
"version": ".".join(str(v) for v in self.version), | ||
"reuse_tolerance": self.reuse_tolerance, | ||
"shape_sets": [ | ||
{"normalized": n, "shapes": list(s)} for n, s in self.shape_sets.items() | ||
], | ||
} | ||
return json.dumps(json_dict, indent=2) | ||
|
||
@classmethod | ||
def fromstring(cls, string) -> "ReuseableParts": | ||
first = string.strip()[0] | ||
parts = cls() | ||
if first == "<": | ||
svg = SVG.fromstring(string).topicosvg() | ||
for shape in svg.shapes(): | ||
parts.add(SVGPath(d=shape.as_path().d)) | ||
elif first == "{": | ||
json_dict = json.loads(string) | ||
parts.version = tuple(int(v) for v in json_dict.pop("version").split(".")) | ||
assert parts.version == (1, 0, 0), f"Bad version {parts.version}" | ||
parts.reuse_tolerance = float(json_dict.pop("reuse_tolerance")) | ||
for shape_set_json in json_dict.pop("shape_sets"): | ||
norm = NormalizedShape(shape_set_json.pop("normalized")) | ||
shapes = ShapeSet({Shape(s) for s in shape_set_json.pop("shapes")}) | ||
if shape_set_json: | ||
raise ValueError(f"Unconsumed input {shape_set_json}") | ||
parts.shape_sets[norm] = shapes | ||
if json_dict: | ||
raise ValueError(f"Unconsumed input {json_dict}") | ||
|
||
else: | ||
raise ValueError(f"Unrecognized start sequence {string[:16]}") | ||
return parts | ||
|
||
@classmethod | ||
def load(cls, input_file: Path) -> "ReuseableParts": | ||
ext = input_file.suffix.lower() | ||
if ext not in {".svg", ".json"}: | ||
raise ValueError(f"Unknown format {input_file}") | ||
return cls.fromstring(input_file.read_text(encoding="utf-8")) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# Copyright 2022 Google LLC | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
"""Generates a part file from 1..N input part sources. | ||
Part sources can be: | ||
1. Other part files | ||
2. Svg files | ||
Or any mix thereof. | ||
""" | ||
|
||
|
||
from absl import app | ||
from absl import flags | ||
from functools import reduce | ||
from nanoemoji.parts import ReuseableParts | ||
from nanoemoji import util | ||
from pathlib import Path | ||
|
||
|
||
FLAGS = flags.FLAGS | ||
|
||
|
||
def main(argv): | ||
parts = [ReuseableParts.load(Path(part_file)) for part_file in argv[1:]] | ||
if not parts: | ||
raise ValueError("Specify at least one input") | ||
parts = reduce(lambda a, c: a.add(c), parts[1:], parts[0]) | ||
|
||
with util.file_printer(FLAGS.output_file) as print: | ||
print(parts.to_json()) | ||
|
||
|
||
if __name__ == "__main__": | ||
app.run(main) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
# Copyright 2022 Google LLC | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
from nanoemoji.parts import ReuseableParts | ||
from picosvg.svg_types import SVGCircle, SVGRect | ||
import pprint | ||
import pytest | ||
from test_helper import cleanup_temp_dirs, locate_test_file, mkdtemp | ||
|
||
|
||
@pytest.fixture(scope="module", autouse=True) | ||
def _cleanup_temporary_dirs(): | ||
# The mkdtemp() docs say the user is responsible for deleting the directory | ||
# and its contents when done with it. So we use an autouse fixture that | ||
# automatically removes all the temp dirs at the end of the test module | ||
yield | ||
# teardown happens after the 'yield' | ||
cleanup_temp_dirs() | ||
|
||
|
||
# BUG? rect(2,1) and rect(1,2) do NOT normalize the same. | ||
# TODO we get pointless precision, e.g. 1.2000000000000002 | ||
|
||
|
||
def check_num_shapes(parts: ReuseableParts, expected_shape_sets: int): | ||
assert len(parts.shape_sets) == expected_shape_sets, ",".join( | ||
sorted(str(p) for p in parts.shape_sets.keys()) | ||
) | ||
|
||
|
||
def test_collects_normalized_shapes(): | ||
shapes = ( | ||
SVGRect(width=2, height=1), | ||
SVGRect(width=4, height=2), | ||
SVGCircle(r=2), | ||
) | ||
|
||
parts = ReuseableParts() | ||
parts.add(shapes) | ||
|
||
check_num_shapes(parts, 2) | ||
|
||
|
||
def test_from_svg(): | ||
parts = ReuseableParts.load(locate_test_file("rect.svg")) | ||
check_num_shapes(parts, 1) | ||
|
||
|
||
def test_merge(): | ||
shapes1 = (SVGRect(width=2, height=1),) | ||
shapes2 = ( | ||
SVGRect(width=4, height=2), | ||
SVGCircle(r=2), | ||
) | ||
|
||
p1 = ReuseableParts() | ||
p1.add(shapes1) | ||
check_num_shapes(p1, 1) | ||
|
||
p2 = ReuseableParts() | ||
p2.add(shapes2) | ||
check_num_shapes(p2, 2) | ||
|
||
p1.add(p2) | ||
check_num_shapes(p1, 2) | ||
|
||
|
||
def test_file_io(): | ||
parts = ReuseableParts() | ||
parts.add(ReuseableParts.load(locate_test_file("rect.svg"))) | ||
check_num_shapes(parts, 1) | ||
|
||
tmp_dir = mkdtemp() | ||
tmp_file = tmp_dir / "rect.json" | ||
tmp_file.write_text(parts.to_json()) | ||
|
||
assert parts == ReuseableParts.load(tmp_file) |