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