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 ADDED
@@ -0,0 +1,15 @@
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
+ __version__ = "0.1.0"
picosvgx/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.dev659+g00f0aab4b.d20260206'
32
+ __version_tuple__ = version_tuple = (0, 1, 'dev659', 'g00f0aab4b.d20260206')
33
+
34
+ __commit_id__ = commit_id = 'g00f0aab4b'
@@ -0,0 +1,210 @@
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
+ """Convert SVG Path's elliptical arcs to Bezier curves.
16
+
17
+ The code is adapted from FontTools fontTools/svgLib/path/arc.py, which in turn
18
+ is adapted from Blink's SVGPathNormalizer::DecomposeArcToCubic:
19
+ https://github.com/chromium/chromium/blob/93831f2/third_party/blink/renderer/core/svg/svg_path_parser.cc#L169-L278
20
+ """
21
+ from math import atan2, ceil, cos, fabs, isfinite, pi, radians, sin, sqrt, tan
22
+ from typing import Iterator, NamedTuple, Optional, Tuple
23
+ from picosvgx.geometric_types import Point, Vector
24
+ from picosvgx.svg_transform import Affine2D
25
+
26
+
27
+ TWO_PI = 2 * pi
28
+ PI_OVER_TWO = 0.5 * pi
29
+
30
+
31
+ class CenterParametrization(NamedTuple):
32
+ theta1: float
33
+ theta_arc: float
34
+ center_point: Point
35
+
36
+
37
+ class EllipticalArc(NamedTuple):
38
+ start_point: Point
39
+ rx: float
40
+ ry: float
41
+ rotation: float
42
+ large: int
43
+ sweep: int
44
+ end_point: Point
45
+
46
+ def is_straight_line(self) -> bool:
47
+ # If rx = 0 or ry = 0 then this arc is treated as a straight line segment (a
48
+ # "lineto") joining the endpoints.
49
+ # http://www.w3.org/TR/SVG/implnote.html#ArcOutOfRangeParameters
50
+ rx = fabs(self.rx)
51
+ ry = fabs(self.ry)
52
+ if not (rx and ry):
53
+ return True
54
+ return False
55
+
56
+ def is_zero_length(self):
57
+ return self.end_point == self.start_point
58
+
59
+ def correct_out_of_range_radii(self) -> "EllipticalArc":
60
+ # Check if the radii are big enough to draw the arc, scale radii if not.
61
+ # http://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii
62
+ if self.is_straight_line() or self.is_zero_length():
63
+ return self
64
+
65
+ mid_point_distance = (self.start_point - self.end_point) * 0.5
66
+
67
+ # SVG rotation is expressed in degrees, whereas Affin2D.rotate uses radians
68
+ angle = radians(self.rotation)
69
+ point_transform = Affine2D.identity().rotate(-angle)
70
+
71
+ transformed_mid_point = point_transform.map_vector(mid_point_distance)
72
+ rx = self.rx
73
+ ry = self.ry
74
+ square_rx = rx * rx
75
+ square_ry = ry * ry
76
+ square_x = transformed_mid_point.x * transformed_mid_point.x
77
+ square_y = transformed_mid_point.y * transformed_mid_point.y
78
+
79
+ radii_scale = square_x / square_rx + square_y / square_ry
80
+ if radii_scale > 1:
81
+ rx *= sqrt(radii_scale)
82
+ ry *= sqrt(radii_scale)
83
+ return self._replace(rx=rx, ry=ry)
84
+
85
+ return self
86
+
87
+ # https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
88
+ def end_to_center_parametrization(self) -> CenterParametrization:
89
+ if self.is_straight_line() or self.is_zero_length():
90
+ raise ValueError(f"Can't compute center parametrization for {self}")
91
+
92
+ angle = radians(self.rotation)
93
+ point_transform = (
94
+ Affine2D.identity().scale(1 / self.rx, 1 / self.ry).rotate(-angle)
95
+ )
96
+
97
+ point1 = point_transform.map_point(self.start_point)
98
+ point2 = point_transform.map_point(self.end_point)
99
+ delta = point2 - point1
100
+
101
+ d = delta.x * delta.x + delta.y * delta.y
102
+ scale_factor_squared = max(1 / d - 0.25, 0.0)
103
+
104
+ scale_factor = sqrt(scale_factor_squared)
105
+ if self.sweep == self.large:
106
+ scale_factor = -scale_factor
107
+
108
+ delta *= scale_factor
109
+ center_point = point1 + (point2 - point1) * 0.5 + Vector(-delta.y, delta.x)
110
+ v1 = point1 - center_point
111
+ v2 = point2 - center_point
112
+
113
+ theta1 = atan2(v1.y, v1.x)
114
+ theta2 = atan2(v2.y, v2.x)
115
+
116
+ theta_arc = theta2 - theta1
117
+ if theta_arc < 0 and self.sweep:
118
+ theta_arc += TWO_PI
119
+ elif theta_arc > 0 and not self.sweep:
120
+ theta_arc -= TWO_PI
121
+
122
+ center_point = point_transform.inverse().map_point(center_point)
123
+
124
+ return CenterParametrization(theta1, theta_arc, center_point)
125
+
126
+
127
+ def _arc_to_cubic(arc: EllipticalArc) -> Iterator[Tuple[Point, Point, Point]]:
128
+ arc = arc.correct_out_of_range_radii()
129
+ arc_params = arc.end_to_center_parametrization()
130
+
131
+ point_transform = (
132
+ Affine2D.identity()
133
+ .translate(arc_params.center_point.x, arc_params.center_point.y)
134
+ .rotate(radians(arc.rotation))
135
+ .scale(arc.rx, arc.ry)
136
+ )
137
+
138
+ # Some results of atan2 on some platform implementations are not exact
139
+ # enough. So that we get more cubic curves than expected here. Adding 0.001f
140
+ # reduces the count of sgements to the correct count.
141
+ num_segments = int(ceil(fabs(arc_params.theta_arc / (PI_OVER_TWO + 0.001))))
142
+ for i in range(num_segments):
143
+ start_theta = arc_params.theta1 + i * arc_params.theta_arc / num_segments
144
+ end_theta = arc_params.theta1 + (i + 1) * arc_params.theta_arc / num_segments
145
+
146
+ t = (4 / 3) * tan(0.25 * (end_theta - start_theta))
147
+ if not isfinite(t):
148
+ return
149
+
150
+ sin_start_theta = sin(start_theta)
151
+ cos_start_theta = cos(start_theta)
152
+ sin_end_theta = sin(end_theta)
153
+ cos_end_theta = cos(end_theta)
154
+
155
+ point1 = Point(
156
+ cos_start_theta - t * sin_start_theta, sin_start_theta + t * cos_start_theta
157
+ )
158
+ end_point = Point(cos_end_theta, sin_end_theta)
159
+ point2 = end_point + Vector(t * sin_end_theta, -t * cos_end_theta)
160
+
161
+ point1 = point_transform.map_point(point1)
162
+ point2 = point_transform.map_point(point2)
163
+
164
+ # by definition, the last bezier's end point == the arc end point
165
+ # by directly taking the end point we avoid floating point imprecision
166
+ if i == num_segments - 1:
167
+ end_point = arc.end_point
168
+ else:
169
+ end_point = point_transform.map_point(end_point)
170
+
171
+ yield point1, point2, end_point
172
+
173
+
174
+ def arc_to_cubic(
175
+ start_point: Tuple[float, float],
176
+ rx: float,
177
+ ry: float,
178
+ rotation: float,
179
+ large: int,
180
+ sweep: int,
181
+ end_point: Tuple[float, float],
182
+ ) -> Iterator[Tuple[Optional[Point], Optional[Point], Point]]:
183
+ """Convert arc to cubic(s).
184
+
185
+ start/end point are (x,y) tuples with absolute coordinates.
186
+ See https://skia.org/user/api/SkPath_Reference#SkPath_arcTo_4
187
+ Note in particular:
188
+ SVG sweep-flag value is opposite the integer value of sweep;
189
+ SVG sweep-flag uses 1 for clockwise, while kCW_Direction cast to int is zero.
190
+
191
+ Yields 3-tuples of Points for each Cubic bezier, i.e. two off-curve points and
192
+ one on-curve end point.
193
+
194
+ If either rx or ry is 0, the arc is treated as a straight line joining the end
195
+ points, and a (None, None, arc.end_point) tuple is yielded.
196
+
197
+ Yields empty iterator if arc has zero length.
198
+ """
199
+ if not isinstance(start_point, Point):
200
+ start_point = Point(*start_point)
201
+ if not isinstance(end_point, Point):
202
+ end_point = Point(*end_point)
203
+
204
+ arc = EllipticalArc(start_point, rx, ry, rotation, large, sweep, end_point)
205
+ if arc.is_zero_length():
206
+ return
207
+ elif arc.is_straight_line():
208
+ yield None, None, arc.end_point
209
+ else:
210
+ yield from _arc_to_cubic(arc)
@@ -0,0 +1,170 @@
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
+ import math
16
+ from typing import NamedTuple, Optional, Union
17
+
18
+
19
+ DEFAULT_ALMOST_EQUAL_TOLERANCE = 1e-9
20
+ _PointOrVec = Union["Point", "Vector"]
21
+
22
+
23
+ def almost_equal(c1, c2, tolerance=DEFAULT_ALMOST_EQUAL_TOLERANCE) -> bool:
24
+ return abs(c1 - c2) <= tolerance
25
+
26
+
27
+ class Point(NamedTuple):
28
+ x: float = 0
29
+ y: float = 0
30
+
31
+ def _sub_pt(self, other: "Point") -> "Vector":
32
+ return Vector(self.x - other.x, self.y - other.y)
33
+
34
+ def _sub_vec(self, other: "Vector") -> "Point":
35
+ return self.__class__(self.x - other.x, self.y - other.y)
36
+
37
+ def __sub__(self, other: _PointOrVec) -> _PointOrVec:
38
+ """Return a Point or Vector based on the type of other.
39
+
40
+ If other is a Point, return Vector from other to self.
41
+ If other is a Vector, return Point translated by -other Vector.
42
+ """
43
+ if isinstance(other, Point):
44
+ return self._sub_pt(other)
45
+ elif isinstance(other, Vector):
46
+ return self._sub_vec(other)
47
+ return NotImplemented # pytype: disable=bad-return-type
48
+
49
+ def __add__(self, other: "Vector") -> "Point":
50
+ """Return Point translated by other Vector"""
51
+ if isinstance(other, Vector):
52
+ return self.__class__(self.x + other.x, self.y + other.y)
53
+ return NotImplemented # pytype: disable=bad-return-type
54
+
55
+ def round(self, digits: int) -> "Point":
56
+ return Point(round(self.x, digits), round(self.y, digits))
57
+
58
+ def almost_equals(
59
+ self, other: "Point", tolerance=DEFAULT_ALMOST_EQUAL_TOLERANCE
60
+ ) -> bool:
61
+ return almost_equal(self.x, other.x, tolerance) and almost_equal(
62
+ self.y, other.y, tolerance
63
+ )
64
+
65
+
66
+ class Vector(NamedTuple):
67
+ x: float = 0
68
+ y: float = 0
69
+
70
+ def __add__(self, other: "Vector") -> "Vector":
71
+ return self.__class__(self.x + other.x, self.y + other.y)
72
+
73
+ def __sub__(self, other: "Vector") -> "Vector":
74
+ return self.__class__(self.x - other.x, self.y - other.y)
75
+
76
+ def __neg__(self) -> "Vector":
77
+ return self * -1.0
78
+
79
+ def __mul__(self, scalar: float) -> "Vector":
80
+ """Multiply vector by a scalar value."""
81
+ if not isinstance(scalar, (int, float)):
82
+ return NotImplemented
83
+ return self.__class__(self.x * scalar, self.y * scalar)
84
+
85
+ __rmul__ = __mul__
86
+
87
+ def perpendicular(self, clockwise: bool = False) -> "Vector":
88
+ """Return Vector rotated 90 degrees counter-clockwise from self.
89
+
90
+ If clockwise is True, return the other perpendicular vector.
91
+ """
92
+ # https://mathworld.wolfram.com/PerpendicularVector.html
93
+ if clockwise:
94
+ return self.__class__(self.y, -self.x)
95
+ else:
96
+ return self.__class__(-self.y, self.x)
97
+
98
+ def norm(self) -> float:
99
+ """Return the vector Euclidean norm (or length or magnitude)."""
100
+ return math.sqrt(self.x * self.x + self.y * self.y)
101
+
102
+ def unit(self) -> Optional["Vector"]:
103
+ """Return the Unit Vector (of length 1), or None if self is a zero vector."""
104
+ norm = self.norm()
105
+ if norm != 0:
106
+ return self.__class__(self.x / norm, self.y / norm)
107
+ return None
108
+
109
+ def dot(self, other: "Vector") -> float:
110
+ """Return the Dot Product of self with other vector."""
111
+ return self.x * other.x + self.y * other.y
112
+
113
+ def projection(self, other: "Vector") -> "Vector":
114
+ """Return the vector projection of self onto other vector."""
115
+ norm = other.norm()
116
+ if norm == 0:
117
+ # it is more helpful for projection onto 0 to be 0 than an error
118
+ return Vector()
119
+ return self.dot(other) / norm * other.unit()
120
+
121
+ def almost_equals(
122
+ self, other: "Vector", tolerance=DEFAULT_ALMOST_EQUAL_TOLERANCE
123
+ ) -> bool:
124
+ return almost_equal(self.x, other.x, tolerance) and almost_equal(
125
+ self.y, other.y, tolerance
126
+ )
127
+
128
+
129
+ class Rect(NamedTuple):
130
+ x: float = 0
131
+ y: float = 0
132
+ w: float = 0
133
+ h: float = 0
134
+
135
+ def empty(self) -> bool:
136
+ """Return True if the Rect's width or height is 0."""
137
+ return self.w == 0 or self.h == 0
138
+
139
+ def intersection(self, other: "Rect") -> Optional["Rect"]:
140
+ def _overlap(start1, end1, start2, end2):
141
+ start = max(start1, start2)
142
+ end = min(end1, end2)
143
+ if start >= end:
144
+ return (0.0, 0.0)
145
+ return (start, end)
146
+
147
+ x1, x2 = _overlap(self.x, self.x + self.w, other.x, other.x + other.w)
148
+ y1, y2 = _overlap(self.y, self.y + self.h, other.y, other.y + other.h)
149
+
150
+ if x1 != x2 and y1 != y2:
151
+ return Rect(x1, y1, x2 - x1, y2 - y1)
152
+ return None
153
+
154
+ @property
155
+ def x_max(self) -> float:
156
+ return self.x + self.w
157
+
158
+ @property
159
+ def y_max(self) -> float:
160
+ return self.y + self.h
161
+
162
+ def union(self, other: "Rect") -> "Rect":
163
+ x, y = min(self.x, other.x), min(self.y, other.y)
164
+ x_max, y_max = max(self.x_max, other.x_max), max(self.y_max, other.y_max)
165
+ return Rect(x=x, y=y, w=x_max - x, h=y_max - y)
166
+
167
+ def normalized_diagonal(self):
168
+ # used for computing percentages of lengths relative to the SVG viewport:
169
+ # https://www.w3.org/TR/SVG2/coords.html#Units
170
+ return math.hypot(self.w, self.h) / math.sqrt(2)
picosvgx/picosvgx.py ADDED
@@ -0,0 +1,85 @@
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
+ """Simplify svg.
16
+
17
+ Usage:
18
+ picosvg.py emoji_u1f469_1f3fd_200d_1f91d_200d_1f468_1f3fb.svg
19
+ <simplified svg dumped to stdout>
20
+ """
21
+ from absl import app
22
+ from absl import flags
23
+ from lxml import etree # pytype: disable=import-error
24
+ from picosvgx.svg import SVG
25
+ from picosvgx.svg_meta import svgns
26
+ import sys
27
+
28
+
29
+ FLAGS = flags.FLAGS
30
+
31
+
32
+ flags.DEFINE_bool(
33
+ "drop_unsupported",
34
+ False,
35
+ "Whether to blindly discard all elements we don't understand. Likely unwise.",
36
+ )
37
+ flags.DEFINE_bool("clip_to_viewbox", False, "Whether to clip content outside viewbox")
38
+ flags.DEFINE_string("output_file", "-", "Output SVG file ('-' means stdout)")
39
+ flags.DEFINE_bool(
40
+ "allow_text",
41
+ False,
42
+ "Whether to allow text elements. Note that they will not be converted to paths, just pass through to the output.",
43
+ )
44
+ flags.DEFINE_bool(
45
+ "allow_all_defs",
46
+ False,
47
+ "Allow all defs elements (filter, mask, pattern, etc.) and root-level switch/symbol to pass through.",
48
+ )
49
+
50
+
51
+ def _run(argv):
52
+ try:
53
+ input_file = argv[1]
54
+ except IndexError:
55
+ input_file = None
56
+
57
+ if input_file:
58
+ svg = SVG.parse(input_file)
59
+ else:
60
+ svg = SVG.fromstring(sys.stdin.read())
61
+
62
+ # Do the needful
63
+ svg = svg.topicosvg(
64
+ allow_text=FLAGS.allow_text, allow_all_defs=FLAGS.allow_all_defs, drop_unsupported=FLAGS.drop_unsupported
65
+ )
66
+
67
+ if FLAGS.clip_to_viewbox:
68
+ svg.clip_to_viewbox(inplace=True)
69
+
70
+ output = svg.tostring(pretty_print=True)
71
+
72
+ if FLAGS.output_file == "-":
73
+ print(output)
74
+ else:
75
+ with open(FLAGS.output_file, "w") as f:
76
+ f.write(output)
77
+
78
+
79
+ def main(argv=None):
80
+ # We don't seem to be __main__ when run as cli tool installed by setuptools
81
+ app.run(_run, argv=argv)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
picosvgx/py.typed ADDED
File without changes