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/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