diff --git a/src/picosvg/geometric_types.py b/src/picosvg/geometric_types.py index bea6c49..3d99e5e 100644 --- a/src/picosvg/geometric_types.py +++ b/src/picosvg/geometric_types.py @@ -15,10 +15,15 @@ import math from typing import NamedTuple, Optional, Union -_DEFAULT_ALMOST_EQUAL_TOLERENCE = 1e-9 + +_DEFAULT_ALMOST_EQUAL_TOLERANCE = 1e-9 _PointOrVec = Union["Point", "Vector"] +def almost_equal(c1, c2, tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE) -> bool: + return abs(c1 - c2) <= tolerance + + class Point(NamedTuple): x: float = 0 y: float = 0 @@ -51,9 +56,11 @@ def round(self, digits: int) -> "Point": return Point(round(self.x, digits), round(self.y, digits)) def almost_equals( - self, other: "Point", tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE + self, other: "Point", tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE ) -> bool: - return abs(self.x - other.x) <= tolerence and abs(self.y - other.y) <= tolerence + return almost_equal(self.x, other.x, tolerance) and almost_equal( + self.y, other.y, tolerance + ) class Vector(NamedTuple): diff --git a/src/picosvg/svg.py b/src/picosvg/svg.py index 52a5bb1..b82838a 100644 --- a/src/picosvg/svg.py +++ b/src/picosvg/svg.py @@ -765,9 +765,10 @@ def _apply_gradient_translation(self, inplace=False): x_prime = (r1 - c * y_prime) / a # sanity check: a`(x`, y`) should be a(x, y) - p = affine.map_point((x, y)) + # all our float brutality damages points; low tolerance sanity checks! + p = Point(r1, r2) p_prime = affine_prime.map_point((x_prime, y_prime)) - assert p.almost_equals(p_prime) + assert p.almost_equals(p_prime, tolerance=1e-1), f"{p} != {p_prime}" el.attrib[x_attr] = ntos(round(x_prime, _GRADIENT_TRANSFORM_NDIGITS)) el.attrib[y_attr] = ntos(round(y_prime, _GRADIENT_TRANSFORM_NDIGITS)) diff --git a/src/picosvg/svg_meta.py b/src/picosvg/svg_meta.py index fea4c73..0614686 100644 --- a/src/picosvg/svg_meta.py +++ b/src/picosvg/svg_meta.py @@ -135,7 +135,7 @@ def parse_css_declarations( output: MutableMapping[str, Any], property_names: Optional[Container[str]] = None, ) -> str: - """ Parse CSS declaration list into {property: value} dict. + """Parse CSS declaration list into {property: value} dict. Args: style: CSS declaration list without the enclosing braces, diff --git a/src/picosvg/svg_path_iter.py b/src/picosvg/svg_path_iter.py index 892a680..375a97b 100644 --- a/src/picosvg/svg_path_iter.py +++ b/src/picosvg/svg_path_iter.py @@ -35,11 +35,11 @@ def _explode_cmd(args_per_cmd, cmd, args): def parse_svg_path(svg_path: str, exploded=False): """Parses an svg path. - Exploded means when params repeat each the command is reported as - if multiplied. For example "M1,1 2,2 3,3" would report as three - separate steps when exploded. + Exploded means when params repeat each the command is reported as + if multiplied. For example "M1,1 2,2 3,3" would report as three + separate steps when exploded. - Yields tuples of (cmd, (args)).""" + Yields tuples of (cmd, (args)).""" command_tuples = [] parts = _CMD_RE.split(svg_path)[1:] for i in range(0, len(parts), 2): diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 4a60c12..34798f3 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -14,41 +14,164 @@ # Functions meant to help discern if shapes are the same or not. +import copy import dataclasses +from itertools import islice +from math import atan2 +from picosvg.geometric_types import Vector, almost_equal from picosvg.svg_types import SVGShape, SVGPath -from typing import Optional, Tuple +from typing import Generator, Iterable, Optional, Tuple +from picosvg import svg_meta from picosvg.svg_transform import Affine2D -# Number of decimal digits to round floats when normalizing or comparing -DEFAULT_TOLERANCE = 9 +_SIGNIFICANCE_FACTOR = 5 # Must be at least N x tolerance to be significant +_ROUND_RANGE = range(3, 13) # range of rounds to try -def _first_move(shape: SVGShape) -> Tuple[float, float]: - cmd, args = next(iter(shape.as_path())) +def _first_move(path: SVGPath) -> Tuple[float, float]: + cmd, args = next(iter(path)) if cmd.upper() != "M": - raise ValueError(f"Path for {shape} should start with a move") + raise ValueError(f"Path for {path} should start with a move") return args -def normalize(shape: SVGShape, tolerance: int = DEFAULT_TOLERANCE) -> SVGShape: - """Build a version of shape that will compare == to other shapes even if offset. +def _vectors(path: SVGPath) -> Generator[Vector, None, None]: + for cmd, args in path: + x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + if cmd.lower() == "z": + yield Vector(0.0, 0.0) + else: + yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]]) - Intended use is to normalize multiple shapes to identify opportunity for reuse.""" - shape = dataclasses.replace(shape, id="") - x, y = _first_move(shape) - shape = ( - shape.as_path().move(-x, -y, inplace=True).round_floats(tolerance, inplace=True) + +def _nth_vector(path: SVGPath, n: int) -> Vector: + return next(islice(_vectors(path), n, n + 1)) + + +def _angle(v: Vector) -> float: + # gives the directional angle of vector (unlike acos) + return atan2(v.y, v.x) + + +def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D: + affine = Affine2D.identity() + + # rotate initial to have the same angle as target (may have different magnitude) + angle = _angle(target) - _angle(initial) + affine = Affine2D.identity().rotate(angle) + vec = affine.map_vector(initial) + + # scale to target magnitude + s = 0 + if vec.norm() != 0: + s = target.norm() / vec.norm() + + affine = Affine2D.compose_ltr((affine, Affine2D.identity().scale(s, s))) + + return affine + + +def _first_y(vectors: Iterable[Vector], tolerance: float) -> Optional[Vector]: + tolerance = _SIGNIFICANCE_FACTOR * tolerance + for idx, vec in enumerate(vectors): + if idx > 0 and abs(vec.y) > tolerance: + return vec + return None + + +# Makes a shape safe for a walk with _affine_callback +def _affine_friendly(shape: SVGShape) -> SVGPath: + path = shape.as_path() + if shape is path: + path = copy.deepcopy(path) + return ( + path.relative(inplace=True) + .explicit_lines(inplace=True) + .expand_shorthand(inplace=True) ) - return shape -def affine_between( - s1: SVGShape, s2: SVGShape, tolerance: int = DEFAULT_TOLERANCE -) -> Optional[Affine2D]: +# Transform all coords in an affine-friendly path +def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): + x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + # hard to do things like rotate if we have 1d coords + assert len(x_coord_idxs) == len(y_coord_idxs), f"{cmd}, {args}" + + args = list(args) # we'd like to mutate 'em + for x_coord_idx, y_coord_idx in zip(x_coord_idxs, y_coord_idxs): + if cmd == cmd.upper(): + # for an absolute cmd allow translation: map_point + new_x, new_y = affine.map_point((args[x_coord_idx], args[y_coord_idx])) + else: + # for a relative coord no translate: map_vector + new_x, new_y = affine.map_vector((args[x_coord_idx], args[y_coord_idx])) + + if almost_equal(new_x, 0): + new_x = 0 + if almost_equal(new_y, 0): + new_y = 0 + args[x_coord_idx] = new_x + args[y_coord_idx] = new_y + return ((cmd, args),) + + +def normalize(shape: SVGShape, tolerance: float) -> SVGShape: + """Build a version of shape that will compare == to other shapes even if offset, + scaled, rotated, etc. + + Intended use is to normalize multiple shapes to identify opportunity for reuse.""" + + path = _affine_friendly(dataclasses.replace(shape, id="")) + + # Make path relative, with first coord at 0,0 + x, y = _first_move(path) + path.move(-x, -y, inplace=True) + + # Normlize vector 1 to [1 0]; eliminates rotation and uniform scaling + vec1 = _nth_vector(path, 1) # ignore M 0,0 + affine1 = _affine_vec2vec(vec1, Vector(1, 0)) + path.walk(lambda *args: _affine_callback(affine1, *args)) + + # Scale first y movement to 1.0 + vecy = _first_y(_vectors(path), tolerance) + if vecy and not almost_equal(vecy.y, 1.0): + affine2 = Affine2D.identity().scale(1, 1 / vecy.y) + path.walk(lambda *args: _affine_callback(affine2, *args)) + + # TODO: what if shapes are the same but different start point + # TODO: what if shapes are the same but different drawing cmds + # This DOES happen in Noto; extent unclear + + path.round_multiple(tolerance, inplace=True) + return path + + +def _apply_affine(affine: Affine2D, s: SVGPath) -> SVGPath: + s_prime = copy.deepcopy(s) + s_prime.walk(lambda *args: _affine_callback(affine, *args)) + return s_prime + + +def _try_affine(affine: Affine2D, s1: SVGPath, s2: SVGPath, tolerance: float): + return _apply_affine(affine, s1).almost_equals(s2, tolerance) + + +def _round(affine, s1, s2, tolerance): + # TODO bsearch? + for i in _ROUND_RANGE: + rounded = affine.round(i) + if _try_affine(rounded, s1, s2, tolerance): + return rounded + return affine # give up + + +def affine_between(s1: SVGShape, s2: SVGShape, tolerance: float) -> Optional[Affine2D]: """Returns the Affine2D to change s1 into s2 or None if no solution was found. - Implementation starting *very* basic, can improve over time. + Intended use is to call this only when the normalized versions of the shapes + are the same, in which case finding a solution is typical + """ s1 = dataclasses.replace(s1, id="") s2 = dataclasses.replace(s2, id="") @@ -56,17 +179,68 @@ def affine_between( if s1.almost_equals(s2, tolerance): return Affine2D.identity() - s1 = s1.as_path() - s2 = s2.as_path() + s1 = _affine_friendly(s1) + s2 = _affine_friendly(s2) s1x, s1y = _first_move(s1) s2x, s2y = _first_move(s2) - dx = s2x - s1x - dy = s2y - s1y - s1.move(dx, dy, inplace=True) + affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y) + if _try_affine(affine, s1, s2, tolerance): + return affine - if s1.almost_equals(s2, tolerance): - return Affine2D.identity().translate(dx, dy) + # Normalize first edge. + # Fixes rotation, x-scale, and uniform scaling. + s1_vec1 = _nth_vector(s1, 1) + s2_vec1 = _nth_vector(s2, 1) + + s1_to_origin = Affine2D.identity().translate(-s1x, -s1y) + s2_to_origin = Affine2D.identity().translate(-s2x, -s2y) + s1_vec1_to_s2_vec1 = _affine_vec2vec(s1_vec1, s2_vec1) + # Move to s2 start + origin_to_s2 = Affine2D.identity().translate(s2x, s2y) + + affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) + if _try_affine(affine, s1, s2, tolerance): + return _round(affine, s1, s2, tolerance) + + # Could be non-uniform scaling and/or mirroring + # Scale first y movement (after matching up vec1) to match + + # Rotate first edge to lie on x axis + s2_vec1_angle = _angle(s2_vec1) + rotate_s2vec1_onto_x = Affine2D.identity().rotate(-s2_vec1_angle) + rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle) + + affine = Affine2D.compose_ltr( + (s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x) + ) + s1_prime = _apply_affine(affine, s1) + + affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x)) + s2_prime = _apply_affine(affine, s2) + + s1_vecy = _first_y(_vectors(s1_prime), tolerance) + s2_vecy = _first_y(_vectors(s2_prime), tolerance) + + if s1_vecy and s2_vecy: + affine = Affine2D.compose_ltr( + ( + s1_to_origin, + s1_vec1_to_s2_vec1, + # lie vec1 along x axis + rotate_s2vec1_onto_x, + # scale first y-vectors to match; x-parts should already match + Affine2D.identity().scale(1.0, s2_vecy.y / s1_vecy.y), + # restore the rotation we removed + rotate_s2vec1_off_x, + # drop into final position + origin_to_s2, + ) + ) + if _try_affine(affine, s1, s2, tolerance): + return _round(affine, s1, s2, tolerance) + + # If we still aren't the same give up return None diff --git a/src/picosvg/svg_transform.py b/src/picosvg/svg_transform.py index e590798..18a1b89 100644 --- a/src/picosvg/svg_transform.py +++ b/src/picosvg/svg_transform.py @@ -17,9 +17,10 @@ Focuses on converting to a sequence of affine matrices. """ import collections +from functools import reduce from math import cos, sin, radians, tan import re -from typing import NamedTuple, Tuple +from typing import NamedTuple, Sequence, Tuple from sys import float_info from picosvg.geometric_types import Point, Rect, Vector @@ -48,6 +49,10 @@ class Affine2D(NamedTuple): e: float f: float + @staticmethod + def flip_y(): + return Affine2D._flip_y + @staticmethod def identity(): return Affine2D._identity @@ -145,9 +150,22 @@ def map_vector(self, vec: Tuple[float, float]) -> Vector: x, y = vec return Vector(self.a * x + self.c * y, self.b * x + self.d * y) + @classmethod + def compose_ltr(cls, affines: Sequence["Affine2D"]) -> "Affine2D": + """Creates merged transform equivalent to applying transforms left-to-right order. + + Affines apply like functions - f(g(x)) - so we merge them in reverse order. + """ + return reduce( + lambda acc, a: cls.product(a, acc), reversed(affines), cls.identity() + ) + + def round(self, digits: int) -> "Affine2D": + return Affine2D(*(round(v, digits) for v in self)) + @classmethod def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D": - """ Return Affine2D set to scale and translate src Rect to dst Rect. + """Return Affine2D set to scale and translate src Rect to dst Rect. The mapping completely fills dst, it does not preserve aspect ratio. """ if src.empty(): @@ -163,6 +181,7 @@ def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D": Affine2D._identity = Affine2D(1, 0, 0, 1, 0, 0) Affine2D._degnerate = Affine2D(0, 0, 0, 0, 0, 0) +Affine2D._flip_y = Affine2D(1, 0, 0, -1, 0, 0) def _fix_rotate(args): diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 3565354..f9158a0 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -14,6 +14,7 @@ import copy import dataclasses +from itertools import zip_longest from picosvg.geometric_types import Point, Rect from picosvg.svg_meta import ( check_cmd, @@ -32,6 +33,10 @@ from typing import Generator, Iterable +def _round_multiple(f: float, of: float) -> float: + return round(f / of) * of + + def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): del subpath_start if cmd == "v": @@ -53,20 +58,34 @@ def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): return ((cmd, args),) -def _relative_to_absolute(curr_pos, cmd, args): +def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args): x_coord_idxs, y_coord_idxs = cmd_coords(cmd) - if cmd.islower(): - cmd = cmd.upper() + desired_cmd = cmd_converter(cmd) + if cmd != desired_cmd: + cmd = desired_cmd + # if x_coord_idxs or y_coord_idxs: args = list(args) # we'd like to mutate 'em for x_coord_idx in x_coord_idxs: - args[x_coord_idx] += curr_pos.x + args[x_coord_idx] += coord_converter(curr_pos.x) for y_coord_idx in y_coord_idxs: - args[y_coord_idx] += curr_pos.y + args[y_coord_idx] += coord_converter(curr_pos.y) return (cmd, tuple(args)) -def _next_pos(curr_pos, cmd, cmd_args): +def _relative_to_absolute(curr_pos, cmd, args): + return _rewrite_coords( + lambda cmd: cmd.upper(), lambda curr_scaler: curr_scaler, curr_pos, cmd, args + ) + + +def _absolute_to_relative(curr_pos, cmd, args): + return _rewrite_coords( + lambda cmd: cmd.lower(), lambda curr_scaler: -curr_scaler, curr_pos, cmd, args + ) + + +def _next_pos(curr_pos, cmd, cmd_args) -> Point: # update current position x_coord_idxs, y_coord_idxs = cmd_coords(cmd) new_x, new_y = curr_pos @@ -89,16 +108,17 @@ def _move_endpoint(curr_pos, cmd, cmd_args, new_endpoint): ((cmd, cmd_args),) = _explicit_lines_callback(None, curr_pos, cmd, cmd_args) x_coord_idxs, y_coord_idxs = cmd_coords(cmd) - cmd_args = list(cmd_args) # we'd like to mutate - new_x, new_y = new_endpoint - if cmd.islower(): - new_x = new_x - curr_pos.x - new_y = new_y - curr_pos.y + if x_coord_idxs or y_coord_idxs: + cmd_args = list(cmd_args) # we'd like to mutate + new_x, new_y = new_endpoint + if cmd.islower(): + new_x = new_x - curr_pos.x + new_y = new_y - curr_pos.y - cmd_args[x_coord_idxs[-1]] = new_x - cmd_args[y_coord_idxs[-1]] = new_y + cmd_args[x_coord_idxs[-1]] = new_x + cmd_args[y_coord_idxs[-1]] = new_y - return cmd, cmd_args + return cmd, tuple(cmd_args) # Subset of https://www.w3.org/TR/SVG11/painting.html @@ -247,9 +267,26 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGShape": setattr(target, field.name, round(field_value, ndigits)) return target - def almost_equals(self, other: "SVGShape", tolerance: int) -> bool: - assert isinstance(other, SVGShape) - return self.round_floats(tolerance) == other.round_floats(tolerance) + def round_multiple(self, multiple_of: float, inplace=False) -> "SVGShape": + """Round all floats in SVGShape to nearest multiple of multiple_of.""" + target = self + if not inplace: + target = copy.deepcopy(self) + for field in dataclasses.fields(target): + field_value = getattr(self, field.name) + if isinstance(field_value, float): + setattr(target, field.name, _round_multiple(field_value, multiple_of)) + return target + + def almost_equals(self, other: "SVGShape", tolerance: float) -> bool: + for (l_cmd, l_args), (r_cmd, r_args) in zip_longest( + self.as_path(), other.as_path(), fillvalue=(None, ()) + ): + if l_cmd != r_cmd or len(l_args) != len(r_args): + return False + if any(abs(lv - rv) > tolerance for lv, rv in zip(l_args, r_args)): + return False + return True # https://www.w3.org/TR/SVG11/paths.html#PathElement @@ -326,7 +363,7 @@ def remove_overlaps(self, inplace=False) -> "SVGPath": def __iter__(self): return parse_svg_path(self.d, exploded=True) - def walk(self, callback): + def walk(self, callback) -> "SVGPath": """Walk path and call callback to build potentially new commands. https://www.w3.org/TR/SVG11/paths.html @@ -364,6 +401,7 @@ def callback(subpath_start, curr_xy, cmd, args, prev_xy, prev_cmd, prev_args) self.d = "" for _, cmd, args in new_cmds: self._add_cmd(cmd, *args) + return self def move(self, dx, dy, inplace=False): """Returns a new path that is this one shifted.""" @@ -389,11 +427,9 @@ def move_callback(subpath_start, curr_pos, cmd, args, *_unused): target.walk(move_callback) return target - def absolute(self, inplace=False) -> "SVGPath": - """Returns equivalent path with only absolute commands.""" - - def absolute_callback(subpath_start, curr_pos, cmd, args, *_): - new_cmd, new_cmd_args = _relative_to_absolute(curr_pos, cmd, args) + def _rewrite_path(self, rewrite_fn, inplace) -> "SVGPath": + def rewrite_callback(subpath_start, curr_pos, cmd, args, *_): + new_cmd, new_cmd_args = rewrite_fn(curr_pos, cmd, args) # if we modified cmd to pass *very* close to subpath start snap to it # eliminates issues with not-quite-closed shapes due float imprecision @@ -407,9 +443,17 @@ def absolute_callback(subpath_start, curr_pos, cmd, args, *_): target = self if not inplace: target = copy.deepcopy(self) - target.walk(absolute_callback) + target.walk(rewrite_callback) return target + def absolute(self, inplace=False) -> "SVGPath": + """Returns equivalent path with only absolute commands.""" + return self._rewrite_path(_relative_to_absolute, inplace) + + def relative(self, inplace=False) -> "SVGPath": + """Returns equivalent path with only relative commands.""" + return self._rewrite_path(_absolute_to_relative, inplace) + def explicit_lines(self, inplace=False): """Replace all vertical/horizontal lines with line to (x,y).""" target = self @@ -522,6 +566,19 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGPath": return target + def round_multiple(self, multiple_of: float, inplace=False) -> "SVGPath": + """Round all floats in SVGPath to given decimal digits. + + Also reformat the SVGPath.d string floats with the same rounding. + """ + target: SVGPath = super().round_multiple(multiple_of, inplace=inplace).as_path() + + d, target.d = target.d, "" + for cmd, args in parse_svg_path(d): + target._add_cmd(cmd, *(_round_multiple(n, multiple_of) for n in args)) + + return target + # https://www.w3.org/TR/SVG11/shapes.html#CircleElement @dataclasses.dataclass diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index 564dd36..c0bd821 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -54,13 +54,48 @@ ), Affine2D.identity().translate(16, 0), ), + # Triangles facing one another, same size + ( + SVGPath(d="m60,64 -50,-32 0,30 z"), + SVGPath(d="m68,64 50,-32 0,30 z"), + Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + ), + # Triangles, different rotation, different size + ( + SVGPath(d="m50,100 -48,-75 81,0 z"), + SVGPath(d="m70,64 50,-32 0,54 z"), + Affine2D(a=-0.0, b=0.6667, c=-0.6667, d=-0.0, e=136.6667, f=30.6667), + ), + # TODO triangles, one point stretched not aligned with X or Y + # A square and a rect; different scale for each axis + ( + SVGRect(x=10, y=10, width=50, height=50), + SVGRect(x=70, y=20, width=20, height=100), + Affine2D(a=0.4, b=0.0, c=0.0, d=2.0, e=66.0, f=0.0), + ), + # Squares with same first edge but flipped on Y + ( + SVGPath(d="M10,10 10,60 60,60 60,10 z"), + SVGPath(d="M70,120 90,120 90,20 70,20 z"), + Affine2D(a=0.0, b=-2.0, c=0.4, d=0.0, e=66.0, f=140.0), + ), ], ) def test_svg_reuse(s1, s2, expected_affine): + tolerance = 0.01 # if we can get an affine we should normalize to same shape if expected_affine: - assert normalize(s1) == normalize(s2) + assert normalize(s1, tolerance) == normalize(s2, tolerance) else: - assert normalize(s1) != normalize(s2) + assert normalize(s1, tolerance) != normalize(s2, tolerance) - assert affine_between(s1, s2) == expected_affine + affine = affine_between(s1, s2, tolerance) + if expected_affine: + assert ( + affine + ), f"No affine found between {s1.d} and {s2.d}. Expected {expected_affine}" + # Round because we've seen issues with different test environments when overly fine + affine = affine.round(4) + assert ( + affine == expected_affine + ), f"Unexpected affine found between {s1.d} and {s2.d}." diff --git a/tests/svg_test.py b/tests/svg_test.py index 2b781b5..81294a0 100644 --- a/tests/svg_test.py +++ b/tests/svg_test.py @@ -431,6 +431,11 @@ def test_apply_style_attributes(actual, expected_result): '', '', ), + # Manually constructed objectBBox + ( + '', + '', + ), ], ) def test_apply_gradient_translation(gradient_string, expected_result): diff --git a/tests/svg_types_test.py b/tests/svg_types_test.py index d25f2e7..b7a711f 100644 --- a/tests/svg_types_test.py +++ b/tests/svg_types_test.py @@ -241,3 +241,18 @@ def test_apply_style_attribute(shape, expected): actual = shape.apply_style_attribute() assert actual == expected assert shape.apply_style_attribute(inplace=True) == expected + + +@pytest.mark.parametrize( + "path, multiple_of, expected_result", + [ + ("m1,1 2,0 1,3", 0.1, "m1,1 2,0 1,3"), + # why a multiple that divides evenly into 1 is a good idea + ("m1,1 2,0 1,3", 0.128, "m1.024,1.024 2.048,0 1.024,2.944"), + ], +) +def test_round_multiple(path: str, multiple_of: float, expected_result: str): + actual = SVGPath(d=path).round_multiple(multiple_of, inplace=True).d + print(f"A: {actual}") + print(f"E: {expected_result}") + assert actual == expected_result