picosvgx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- picosvgx/__init__.py +15 -0
- picosvgx/_version.py +34 -0
- picosvgx/arc_to_cubic.py +210 -0
- picosvgx/geometric_types.py +170 -0
- picosvgx/picosvgx.py +85 -0
- picosvgx/py.typed +0 -0
- picosvgx/svg.py +1697 -0
- picosvgx/svg_meta.py +307 -0
- picosvgx/svg_path_iter.py +110 -0
- picosvgx/svg_pathops.py +194 -0
- picosvgx/svg_reuse.py +384 -0
- picosvgx/svg_transform.py +373 -0
- picosvgx/svg_types.py +1031 -0
- picosvgx-0.1.0.dist-info/METADATA +114 -0
- picosvgx-0.1.0.dist-info/RECORD +19 -0
- picosvgx-0.1.0.dist-info/WHEEL +5 -0
- picosvgx-0.1.0.dist-info/entry_points.txt +2 -0
- picosvgx-0.1.0.dist-info/licenses/LICENSE +202 -0
- picosvgx-0.1.0.dist-info/top_level.txt +1 -0
picosvgx/svg_reuse.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# Copyright 2020 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# Functions meant to help discern if shapes are the same or not.
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import dataclasses
|
|
19
|
+
from itertools import islice
|
|
20
|
+
from math import atan2, sqrt
|
|
21
|
+
from picosvgx.geometric_types import Vector, almost_equal
|
|
22
|
+
from picosvgx.svg_types import SVGShape, SVGPath
|
|
23
|
+
from typing import Callable, Generator, Iterable, Optional, Tuple
|
|
24
|
+
from picosvgx import svg_meta
|
|
25
|
+
from picosvgx.svg_transform import Affine2D
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_SIGNIFICANCE_FACTOR = 5 # Must be at least N x tolerance to be significant
|
|
29
|
+
_ROUND_RANGE = range(3, 13) # range of rounds to try
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _first_move(path: SVGPath) -> Tuple[float, float]:
|
|
33
|
+
cmd, args = next(iter(path))
|
|
34
|
+
if cmd.upper() != "M":
|
|
35
|
+
raise ValueError(f"Path for {path} should start with a move")
|
|
36
|
+
return args
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _farthest(rx, ry, large_arc, line_length) -> float:
|
|
40
|
+
"""
|
|
41
|
+
We have an ellipse centered on 0,0 with specified rx/ry.
|
|
42
|
+
There is a horizontal line that intersects it twice and the
|
|
43
|
+
line segment between intersections has a known length.
|
|
44
|
+
|
|
45
|
+
Consider the ellipse as two arcs, one above and one below our
|
|
46
|
+
line segment. Return y-offset of the farthest point on one of these
|
|
47
|
+
arcs. If large_arc is 0, choose the smaller, otherwise the larger.
|
|
48
|
+
|
|
49
|
+
If the ellipse is too small to accomodate a line segment of the
|
|
50
|
+
specified length then scale it until it can.
|
|
51
|
+
|
|
52
|
+
Note: we skip ellipse rotation for now and solve by placing
|
|
53
|
+
the ellipse on the origin and solving the ellipse equation for y when
|
|
54
|
+
x is at +/- 0.5 * the length of our line segment.
|
|
55
|
+
"""
|
|
56
|
+
assert line_length >= 0, f"line_length {line_length} must be >= 0"
|
|
57
|
+
x = line_length / 2
|
|
58
|
+
|
|
59
|
+
y = 0
|
|
60
|
+
|
|
61
|
+
if almost_equal(2 * rx, line_length):
|
|
62
|
+
# simple case common in real input: the ellipse is exactly wide enough
|
|
63
|
+
# no scaling, farthest point for both arcs is simply ry
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
elif 2 * rx >= line_length:
|
|
67
|
+
# The ellipse is big enough that a line through could have
|
|
68
|
+
# the desired length
|
|
69
|
+
y = pow(ry, 2) - pow(ry, 2) * pow(x, 2) / pow(rx, 2)
|
|
70
|
+
y = sqrt(y)
|
|
71
|
+
|
|
72
|
+
else:
|
|
73
|
+
# The ellipse is too small and will be scaled
|
|
74
|
+
# scale: big enough when 2rx == line length at y=0
|
|
75
|
+
# max distance from line is at scaled ry
|
|
76
|
+
# same for large vs small arc
|
|
77
|
+
scale = line_length / (2 * rx)
|
|
78
|
+
rx *= scale
|
|
79
|
+
ry *= scale
|
|
80
|
+
|
|
81
|
+
large_arc_farthest = y + ry
|
|
82
|
+
small_arc_farthest = ry - y
|
|
83
|
+
|
|
84
|
+
if large_arc == 0:
|
|
85
|
+
return small_arc_farthest
|
|
86
|
+
else:
|
|
87
|
+
return large_arc_farthest
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _vectors(path: SVGPath) -> Iterable[Vector]:
|
|
91
|
+
for cmd, args in path:
|
|
92
|
+
x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd)
|
|
93
|
+
|
|
94
|
+
assert cmd == "M" or cmd == cmd.lower(), "path should be relative"
|
|
95
|
+
if cmd == "M":
|
|
96
|
+
x, y = args[x_coord_idxs[-1]], args[y_coord_idxs[-1]]
|
|
97
|
+
|
|
98
|
+
if cmd == "z":
|
|
99
|
+
yield Vector(0.0, 0.0)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if cmd == "a":
|
|
103
|
+
# arcs can confuse search for "significant" x/y movement
|
|
104
|
+
# because, if x or y is unchanged it won't trigger
|
|
105
|
+
# the significant movement test for that axis even if it
|
|
106
|
+
# curves way off it. When svg circles and ellipses convert
|
|
107
|
+
# we typically get two arcs with no y movement.
|
|
108
|
+
#
|
|
109
|
+
# Address this by treating the arc as two vectors:
|
|
110
|
+
# 1) to the endpoint
|
|
111
|
+
# 2) to the farthest point on the arc - to the end point
|
|
112
|
+
# Sum of these takes you to the endpoint, but you keep interesting
|
|
113
|
+
# movement along the way in play.
|
|
114
|
+
|
|
115
|
+
rx, ry, x_rotation, large_arc, sweep, end_x, end_y = args
|
|
116
|
+
|
|
117
|
+
# Dumbed down implementation aimed at what we see in real inputs:
|
|
118
|
+
# handle only non-rotated ellipses where endpoint didn't move on one axis.
|
|
119
|
+
# TODO: convert *all* arcs to two-vector form
|
|
120
|
+
if x_rotation == 0 and 0 in (end_x, end_y):
|
|
121
|
+
if almost_equal(end_y, 0):
|
|
122
|
+
y_max = _farthest(rx, ry, large_arc, abs(end_x))
|
|
123
|
+
yield Vector(end_x, 0.0)
|
|
124
|
+
yield Vector(0.0, y_max)
|
|
125
|
+
continue
|
|
126
|
+
elif almost_equal(end_x, 0):
|
|
127
|
+
# since we have no rotation we can do farthest with coords flipped
|
|
128
|
+
x_max = _farthest(ry, rx, large_arc, abs(end_y))
|
|
129
|
+
yield Vector(x_max, 0.0)
|
|
130
|
+
yield Vector(0.0, end_y)
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# default: vector to endpoint
|
|
134
|
+
yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _nth_vector(path: SVGPath, n: int) -> Vector:
|
|
138
|
+
return next(islice(_vectors(path), n, n + 1))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _angle(v: Vector) -> float:
|
|
142
|
+
# gives the directional angle of vector (unlike acos)
|
|
143
|
+
return atan2(v.y, v.x)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D:
|
|
147
|
+
affine = Affine2D.identity()
|
|
148
|
+
|
|
149
|
+
# rotate initial to have the same angle as target (may have different magnitude)
|
|
150
|
+
angle = _angle(target) - _angle(initial)
|
|
151
|
+
affine = Affine2D.identity().rotate(angle)
|
|
152
|
+
vec = affine.map_vector(initial)
|
|
153
|
+
|
|
154
|
+
# scale to target magnitude
|
|
155
|
+
s = 0
|
|
156
|
+
if vec.norm() != 0:
|
|
157
|
+
s = target.norm() / vec.norm()
|
|
158
|
+
|
|
159
|
+
affine = Affine2D.compose_ltr((affine, Affine2D.identity().scale(s, s)))
|
|
160
|
+
|
|
161
|
+
return affine
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _first_significant(
|
|
165
|
+
vectors: Iterable[Vector], val_fn: Callable[[Vector], float], tolerance: float
|
|
166
|
+
) -> Tuple[int, Optional[Vector]]:
|
|
167
|
+
tolerance = _SIGNIFICANCE_FACTOR * tolerance
|
|
168
|
+
for idx, vec in enumerate(vectors):
|
|
169
|
+
if idx == 0: # skip initial move
|
|
170
|
+
continue
|
|
171
|
+
if abs(val_fn(vec)) > tolerance:
|
|
172
|
+
return (idx, vec)
|
|
173
|
+
return (-1, None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _first_significant_for_both(
|
|
177
|
+
s1: SVGPath, s2: SVGPath, val_fn: Callable[[Vector], float], tolerance: float
|
|
178
|
+
) -> Tuple[int, Optional[Vector], Optional[Vector]]:
|
|
179
|
+
tolerance = _SIGNIFICANCE_FACTOR * tolerance
|
|
180
|
+
for idx, (vec1, vec2) in enumerate(zip(_vectors(s1), _vectors(s2))):
|
|
181
|
+
if idx == 0: # skip initial move
|
|
182
|
+
continue
|
|
183
|
+
if abs(val_fn(vec1)) > tolerance and abs(val_fn(vec2)) > tolerance:
|
|
184
|
+
return (idx, vec1, vec2)
|
|
185
|
+
return (-1, None, None)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Makes a shape safe for a walk with _affine_callback
|
|
189
|
+
def _affine_friendly(shape: SVGShape) -> SVGPath:
|
|
190
|
+
path = shape.as_path()
|
|
191
|
+
if shape is path:
|
|
192
|
+
path = copy.deepcopy(path)
|
|
193
|
+
return (
|
|
194
|
+
path.explicit_lines(inplace=True)
|
|
195
|
+
.expand_shorthand(inplace=True)
|
|
196
|
+
.relative(inplace=True)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
ARC_RADIUS_COORD_OFFSET = (
|
|
201
|
+
-5
|
|
202
|
+
) # offset from corresponding coord, e.g. x coord is 5, matching radius is 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Transform all coords in an affine-friendly path
|
|
206
|
+
def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused):
|
|
207
|
+
x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd)
|
|
208
|
+
# hard to do things like rotate if we have 1d coords
|
|
209
|
+
assert len(x_coord_idxs) == len(y_coord_idxs), f"{cmd}, {args}"
|
|
210
|
+
|
|
211
|
+
args = list(args) # we'd like to mutate 'em
|
|
212
|
+
|
|
213
|
+
for x_coord_idx, y_coord_idx in zip(x_coord_idxs, y_coord_idxs):
|
|
214
|
+
if cmd == cmd.upper():
|
|
215
|
+
# for an absolute cmd allow translation: map_point
|
|
216
|
+
new_x, new_y = affine.map_point((args[x_coord_idx], args[y_coord_idx]))
|
|
217
|
+
else:
|
|
218
|
+
# for a relative coord no translate: map_vector
|
|
219
|
+
new_x, new_y = affine.map_vector((args[x_coord_idx], args[y_coord_idx]))
|
|
220
|
+
|
|
221
|
+
if almost_equal(new_x, 0):
|
|
222
|
+
new_x = 0
|
|
223
|
+
if almost_equal(new_y, 0):
|
|
224
|
+
new_y = 0
|
|
225
|
+
args[x_coord_idx] = new_x
|
|
226
|
+
args[y_coord_idx] = new_y
|
|
227
|
+
|
|
228
|
+
# Arc radii are, excitingly, NOT coords. However, the curvature is entirely different
|
|
229
|
+
# and nothing normalizes if they are not adjusted so try scaling rx/y proportionally to
|
|
230
|
+
# the change in magnitude of the respective basis vectors.
|
|
231
|
+
if cmd.upper() == "A":
|
|
232
|
+
x_basis = Vector(affine.a, affine.b)
|
|
233
|
+
y_basis = Vector(affine.c, affine.d)
|
|
234
|
+
rx = args[x_coord_idx + ARC_RADIUS_COORD_OFFSET]
|
|
235
|
+
ry = args[y_coord_idx + ARC_RADIUS_COORD_OFFSET]
|
|
236
|
+
args[x_coord_idx + ARC_RADIUS_COORD_OFFSET] = rx * x_basis.norm()
|
|
237
|
+
args[y_coord_idx + ARC_RADIUS_COORD_OFFSET] = ry * y_basis.norm()
|
|
238
|
+
return ((cmd, args),)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def normalize(shape: SVGShape, tolerance: float) -> SVGPath:
|
|
242
|
+
"""Build a version of shape that will compare == to other shapes even if offset,
|
|
243
|
+
scaled, rotated, etc.
|
|
244
|
+
|
|
245
|
+
Intended use is to normalize multiple shapes to identify opportunity for reuse."""
|
|
246
|
+
path = _affine_friendly(dataclasses.replace(shape, id=""))
|
|
247
|
+
|
|
248
|
+
# Make path relative, with first coord at 0,0
|
|
249
|
+
x, y = _first_move(path)
|
|
250
|
+
path.move(-x, -y, inplace=True)
|
|
251
|
+
|
|
252
|
+
# Normalize first activity to [1 0]; eliminates rotation and uniform scaling
|
|
253
|
+
_, vec_first = _first_significant(_vectors(path), lambda v: v.norm(), tolerance)
|
|
254
|
+
if vec_first and not vec_first.almost_equals(Vector(1, 0)):
|
|
255
|
+
assert (
|
|
256
|
+
vec_first.norm() > tolerance
|
|
257
|
+
), f"vec_first too close to 0-magnitude: {vec_first}"
|
|
258
|
+
affinex = _affine_vec2vec(vec_first, Vector(1, 0))
|
|
259
|
+
path.walk(lambda *args: _affine_callback(affinex, *args))
|
|
260
|
+
|
|
261
|
+
# Normalize first y activity to 1.0; eliminates mirroring and non-uniform scaling
|
|
262
|
+
_, vecy = _first_significant(_vectors(path), lambda v: v.y, tolerance)
|
|
263
|
+
if vecy and not almost_equal(vecy.y, 1.0):
|
|
264
|
+
assert vecy.norm() > tolerance, f"vecy too close to 0-magnitude: {vecy}"
|
|
265
|
+
affine2 = Affine2D.identity().scale(1, 1 / vecy.y)
|
|
266
|
+
path.walk(lambda *args: _affine_callback(affine2, *args))
|
|
267
|
+
|
|
268
|
+
# TODO: what if shapes are the same but different, or different ordering, drawing cmds
|
|
269
|
+
# This DOES happen in Noto; extent unclear
|
|
270
|
+
|
|
271
|
+
path.round_multiple(tolerance, inplace=True)
|
|
272
|
+
return path
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _apply_affine(affine: Affine2D, s: SVGPath) -> SVGPath:
|
|
276
|
+
s_prime = copy.deepcopy(s)
|
|
277
|
+
s_prime.walk(lambda *args: _affine_callback(affine, *args))
|
|
278
|
+
return s_prime
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _try_affine(
|
|
282
|
+
affine: Affine2D, s1: SVGPath, s2: SVGPath, tolerance: float, comment: str
|
|
283
|
+
):
|
|
284
|
+
s1_prime = _apply_affine(affine, s1)
|
|
285
|
+
return s1_prime.almost_equals(s2, tolerance)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _round(affine, s1, s2, tolerance):
|
|
289
|
+
# TODO bsearch?
|
|
290
|
+
for i in _ROUND_RANGE:
|
|
291
|
+
rounded = affine.round(i)
|
|
292
|
+
if _try_affine(rounded, s1, s2, tolerance, f"round {i}"):
|
|
293
|
+
return rounded
|
|
294
|
+
return affine # give up
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def affine_between(s1: SVGShape, s2: SVGShape, tolerance: float) -> Optional[Affine2D]:
|
|
298
|
+
"""Returns the Affine2D to change s1 into s2 or None if no solution was found.
|
|
299
|
+
|
|
300
|
+
Intended use is to call this only when the normalized versions of the shapes
|
|
301
|
+
are the same, in which case finding a solution is typical.
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
See reuse_example.html in root of picosvg for a visual explanation.
|
|
305
|
+
|
|
306
|
+
"""
|
|
307
|
+
s1 = dataclasses.replace(s1, id="")
|
|
308
|
+
s2 = dataclasses.replace(s2, id="")
|
|
309
|
+
|
|
310
|
+
if s1.almost_equals(s2, tolerance):
|
|
311
|
+
return Affine2D.identity()
|
|
312
|
+
|
|
313
|
+
s1 = _affine_friendly(s1)
|
|
314
|
+
s2 = _affine_friendly(s2)
|
|
315
|
+
|
|
316
|
+
s1x, s1y = _first_move(s1)
|
|
317
|
+
s2x, s2y = _first_move(s2)
|
|
318
|
+
|
|
319
|
+
affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y)
|
|
320
|
+
if _try_affine(affine, s1, s2, tolerance, "same start point"):
|
|
321
|
+
return _round(affine, s1, s2, tolerance)
|
|
322
|
+
|
|
323
|
+
# Align the first edge with a significant x part.
|
|
324
|
+
# Fixes rotation, x-scale, and uniform scaling.
|
|
325
|
+
s2_vec1x_idx, s2_vec1x = _first_significant(_vectors(s2), lambda v: v.x, tolerance)
|
|
326
|
+
if s2_vec1x_idx == -1:
|
|
327
|
+
# bail out if we find no first edge with significant x part
|
|
328
|
+
# https://github.com/googlefonts/picosvg/issues/246
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
s1_vec1 = _nth_vector(s1, s2_vec1x_idx)
|
|
332
|
+
|
|
333
|
+
s1_to_origin = Affine2D.identity().translate(-s1x, -s1y)
|
|
334
|
+
s2_to_origin = Affine2D.identity().translate(-s2x, -s2y)
|
|
335
|
+
s1_vec1_to_s2_vec1x = _affine_vec2vec(s1_vec1, s2_vec1x)
|
|
336
|
+
|
|
337
|
+
# Move to s2 start
|
|
338
|
+
origin_to_s2 = Affine2D.identity().translate(s2x, s2y)
|
|
339
|
+
|
|
340
|
+
affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1x, origin_to_s2))
|
|
341
|
+
if _try_affine(affine, s1, s2, tolerance, "align vec1x"):
|
|
342
|
+
return _round(affine, s1, s2, tolerance)
|
|
343
|
+
|
|
344
|
+
# Could be non-uniform scaling and/or mirroring
|
|
345
|
+
# Make the aligned edge the x axis then align the first edge with a significant y part.
|
|
346
|
+
|
|
347
|
+
# Rotate first edge to lie on x axis
|
|
348
|
+
s2_vec1_angle = _angle(s2_vec1x)
|
|
349
|
+
rotate_s2vec1_onto_x = Affine2D.identity().rotate(-s2_vec1_angle)
|
|
350
|
+
rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle)
|
|
351
|
+
|
|
352
|
+
affine = Affine2D.compose_ltr(
|
|
353
|
+
(s1_to_origin, s1_vec1_to_s2_vec1x, rotate_s2vec1_onto_x)
|
|
354
|
+
)
|
|
355
|
+
s1_prime = _apply_affine(affine, s1)
|
|
356
|
+
|
|
357
|
+
affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x))
|
|
358
|
+
s2_prime = _apply_affine(affine, s2)
|
|
359
|
+
|
|
360
|
+
# The first vector we aligned now lies on the x axis
|
|
361
|
+
# Find and align the first vector that heads off into y for both
|
|
362
|
+
idx, s1_vecy, s2_vecy = _first_significant_for_both(
|
|
363
|
+
s1_prime, s2_prime, lambda v: v.y, tolerance
|
|
364
|
+
)
|
|
365
|
+
if idx != -1:
|
|
366
|
+
affine = Affine2D.compose_ltr(
|
|
367
|
+
(
|
|
368
|
+
s1_to_origin,
|
|
369
|
+
s1_vec1_to_s2_vec1x,
|
|
370
|
+
# lie vec1 along x axis
|
|
371
|
+
rotate_s2vec1_onto_x,
|
|
372
|
+
# scale first y-vectors to match; x-parts should already match
|
|
373
|
+
Affine2D.identity().scale(1.0, s2_vecy.y / s1_vecy.y),
|
|
374
|
+
# restore the rotation we removed
|
|
375
|
+
rotate_s2vec1_off_x,
|
|
376
|
+
# drop into final position
|
|
377
|
+
origin_to_s2,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
if _try_affine(affine, s1, s2, tolerance, "align vecy"):
|
|
381
|
+
return _round(affine, s1, s2, tolerance)
|
|
382
|
+
|
|
383
|
+
# If we still aren't the same give up
|
|
384
|
+
return None
|