svg-ultralight 0.64.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.
Files changed (35) hide show
  1. svg_ultralight/__init__.py +112 -0
  2. svg_ultralight/animate.py +40 -0
  3. svg_ultralight/attrib_hints.py +14 -0
  4. svg_ultralight/bounding_boxes/__init__.py +5 -0
  5. svg_ultralight/bounding_boxes/bound_helpers.py +200 -0
  6. svg_ultralight/bounding_boxes/padded_text_initializers.py +442 -0
  7. svg_ultralight/bounding_boxes/supports_bounds.py +167 -0
  8. svg_ultralight/bounding_boxes/type_bound_collection.py +74 -0
  9. svg_ultralight/bounding_boxes/type_bound_element.py +68 -0
  10. svg_ultralight/bounding_boxes/type_bounding_box.py +432 -0
  11. svg_ultralight/bounding_boxes/type_padded_list.py +208 -0
  12. svg_ultralight/bounding_boxes/type_padded_text.py +502 -0
  13. svg_ultralight/constructors/__init__.py +14 -0
  14. svg_ultralight/constructors/new_element.py +117 -0
  15. svg_ultralight/font_tools/__init__.py +5 -0
  16. svg_ultralight/font_tools/comp_results.py +291 -0
  17. svg_ultralight/font_tools/font_info.py +849 -0
  18. svg_ultralight/image_ops.py +156 -0
  19. svg_ultralight/inkscape.py +261 -0
  20. svg_ultralight/layout.py +291 -0
  21. svg_ultralight/main.py +183 -0
  22. svg_ultralight/metadata.py +122 -0
  23. svg_ultralight/nsmap.py +36 -0
  24. svg_ultralight/py.typed +5 -0
  25. svg_ultralight/query.py +254 -0
  26. svg_ultralight/read_svg.py +58 -0
  27. svg_ultralight/root_elements.py +96 -0
  28. svg_ultralight/string_conversion.py +244 -0
  29. svg_ultralight/strings/__init__.py +21 -0
  30. svg_ultralight/strings/svg_strings.py +106 -0
  31. svg_ultralight/transformations.py +152 -0
  32. svg_ultralight/unit_conversion.py +247 -0
  33. svg_ultralight-0.64.0.dist-info/METADATA +208 -0
  34. svg_ultralight-0.64.0.dist-info/RECORD +35 -0
  35. svg_ultralight-0.64.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,106 @@
1
+ """Explicit string formatting calls for arguments that aren't floats or strings.
2
+
3
+ :author: Shay Hill
4
+ :created: 10/30/2020
5
+
6
+ The `string_conversion` module will format floats or strings. Some other formatters can
7
+ make things easier.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from svg_ultralight.string_conversion import format_number
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable
18
+
19
+
20
+ _MAX_8BIT = 255
21
+ _BIG_INT = 2**32 - 1
22
+
23
+
24
+ def _float_to_8bit_int(clipped_float: float) -> int:
25
+ """Convert a float between 0 and 255 to an int between 0 and 255.
26
+
27
+ :param float_: a float in the closed interval [0 .. 255]. Values outside this
28
+ range will be clipped.
29
+ :return: an int in the closed interval [0 .. 255]
30
+
31
+ Convert color floats [0 .. 255] to ints [0 .. 255] without rounding, which "short
32
+ changes" 0 and 255.
33
+ """
34
+ clipped_float = min(_MAX_8BIT, max(0, clipped_float))
35
+ if clipped_float % 1:
36
+ high_int = int(clipped_float / _MAX_8BIT * _BIG_INT)
37
+ return high_int >> 24
38
+ return int(clipped_float)
39
+
40
+
41
+ def svg_ints(floats: Iterable[float]) -> str:
42
+ """Space-delimited ints.
43
+
44
+ :param floats: and number of floats
45
+ :return: each float rounded to an int, space delimited
46
+ """
47
+ return " ".join(str(round(x)) for x in floats)
48
+
49
+
50
+ def svg_floats(floats: Iterable[float]) -> str:
51
+ """Space-delimited floats.
52
+
53
+ :param floats: and number of floats
54
+ :return: each float formatted, space delimited
55
+
56
+ matrix strings, svg viewBox, and other attributes need space-delimited floats.
57
+ """
58
+ return " ".join(format_number(x) for x in floats)
59
+
60
+
61
+ def svg_float_tuples(tuples: Iterable[tuple[float, float]]) -> str:
62
+ """Space-delimited tuples.
63
+
64
+ :param tuples: [(a, b), (c, d)]
65
+ :return: "a,b c,d"
66
+ """
67
+ tuple_strings = [",".join(format_number(n) for n in t) for t in tuples]
68
+ return " ".join(tuple_strings)
69
+
70
+
71
+ # ===================================================================================
72
+ # Specific string formats
73
+ # ===================================================================================
74
+
75
+
76
+ def svg_color_tuple(rgb_floats: tuple[float, float, float]) -> str:
77
+ """Turn an rgb tuple (0-255, 0-255, 0-255) into an svg color definition.
78
+
79
+ :param rgb_floats: (0-255, 0-255, 0-255)
80
+ :return: "rgb(128,128,128)"
81
+ """
82
+ r, g, b = map(_float_to_8bit_int, rgb_floats)
83
+ return f"rgb({r},{g},{b})"
84
+
85
+
86
+ def svg_matrix(floats: Iterable[float]) -> str:
87
+ """Create a matrix string for the svg transform attribute.
88
+
89
+ a: scale x
90
+ b: skew y
91
+ c: skew x
92
+ d: scale y
93
+ e: translate x
94
+ f: translate y
95
+ :return: "matrix(a,b,c,d,e,f)"
96
+
97
+ The matrix() function defines a transformation in the 2D space. The six values
98
+ represent a 3x3 matrix that is used to perform linear transformations such as
99
+ translation, scaling, rotation, and skewing on SVG elements.
100
+ """
101
+ try:
102
+ a, b, c, d, e, f = floats
103
+ except ValueError as e:
104
+ msg = "svg_matrix() needs exactly 6 floats."
105
+ raise ValueError(msg) from e
106
+ return f"matrix({svg_floats((a, b, c, d, e, f))})"
@@ -0,0 +1,152 @@
1
+ """Math and conversion for svg-style transformation matrices.
2
+
3
+ :author: Shay Hill
4
+ :created: 2024-05-05
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numbers
10
+ import re
11
+ from contextlib import suppress
12
+ from typing import TYPE_CHECKING, TypeAlias, cast
13
+
14
+ from svg_ultralight.strings import svg_matrix
15
+
16
+ if TYPE_CHECKING:
17
+ from lxml.etree import (
18
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
19
+ )
20
+
21
+
22
+ RE_MATRIX = re.compile(r"matrix\(([^)]+)\)")
23
+
24
+ _Matrix: TypeAlias = tuple[float, float, float, float, float, float]
25
+
26
+
27
+ def mat_dot(mat1: _Matrix, mat2: _Matrix) -> _Matrix:
28
+ """Matrix multiplication for svg-style matrices.
29
+
30
+ :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
31
+ :param mat2: transformation matrix (sx, 0, 0, sy, tx, ty)
32
+
33
+ Svg uses an unusual matrix format. For 3x3 transformation matrix
34
+
35
+ [[00, 01, 02],
36
+ [10, 11, 12],
37
+ [20, 21, 22]]
38
+
39
+ The svg matrix is
40
+ (00, 10, 01, 11, 02, 12)
41
+
42
+ Values 10 and 01 are only used for skewing, which is not supported by a bounding
43
+ box. Values 00 and 11 will always be identical for symmetric scaling, which is
44
+ the only scaling implemented in my BoundingBox classes. However, all six values
45
+ are implemented in case this function is used in other contexts.
46
+ """
47
+ aa = sum(mat1[x] * mat2[y] for x, y in ((0, 0), (2, 1)))
48
+ bb = sum(mat1[x] * mat2[y] for x, y in ((1, 0), (3, 1)))
49
+ cc = sum(mat1[x] * mat2[y] for x, y in ((0, 2), (2, 3)))
50
+ dd = sum(mat1[x] * mat2[y] for x, y in ((1, 2), (3, 3)))
51
+ ee = sum(mat1[x] * mat2[y] for x, y in ((0, 4), (2, 5))) + mat1[4]
52
+ ff = sum(mat1[x] * mat2[y] for x, y in ((1, 4), (3, 5))) + mat1[5]
53
+ return (aa, bb, cc, dd, ee, ff)
54
+
55
+
56
+ def mat_apply(matrix: _Matrix, point: tuple[float, float]) -> tuple[float, float]:
57
+ """Apply an svg-style transformation matrix to a point.
58
+
59
+ :param mat1: transformation matrix (a, b, c, d, e, f) describing a 3x3 matrix
60
+ with an implied third row of (0, 0, 1)
61
+ [[a, c, e], [b, d, f], [0, 0, 1]]
62
+ :param mat2: point (x, y)
63
+ """
64
+ a, b, c, d, e, f = matrix
65
+ x, y = point
66
+ result_x = a * x + c * y + e
67
+ result_y = b * x + d * y + f
68
+ return result_x, result_y
69
+
70
+
71
+ def mat_invert(tmat: _Matrix) -> _Matrix:
72
+ """Invert a 2D transformation matrix in svg format."""
73
+ a, b, c, d, e, f = tmat
74
+ det = a * d - b * c
75
+ if det == 0:
76
+ msg = "Matrix is not invertible"
77
+ raise ValueError(msg)
78
+ return (
79
+ d / det,
80
+ -b / det,
81
+ -c / det,
82
+ a / det,
83
+ (c * f - d * e) / det,
84
+ (b * e - a * f) / det,
85
+ )
86
+
87
+
88
+ def get_transform_matrix(elem: EtreeElement) -> _Matrix:
89
+ """Get the transformation matrix from an svg element.
90
+
91
+ :param element: svg element
92
+ """
93
+ transform = elem.attrib.get("transform")
94
+ if not transform:
95
+ return (1, 0, 0, 1, 0, 0)
96
+ values_str = ""
97
+ with suppress(AttributeError):
98
+ values_str = cast("re.Match[str]", RE_MATRIX.match(transform)).group(1)
99
+ with suppress(ValueError):
100
+ aa, bb, cc, dd, ee, ff = (float(val) for val in values_str.split())
101
+ return (aa, bb, cc, dd, ee, ff)
102
+ msg = f"Could not parse transformation matrix from {transform}"
103
+ raise ValueError(msg)
104
+
105
+
106
+ def new_transformation_matrix(
107
+ transformation: _Matrix | None = None,
108
+ *,
109
+ scale: tuple[float, float] | float | None = None,
110
+ dx: float | None = None,
111
+ dy: float | None = None,
112
+ ) -> _Matrix:
113
+ """Create a new transformation matrix.
114
+
115
+ This takes the standard arguments in the BoundingBox classes and returns an
116
+ svg-style transformation matrix.
117
+ """
118
+ transformation = transformation or (1, 0, 0, 1, 0, 0)
119
+
120
+ if isinstance(scale, (float, int, numbers.Real)):
121
+ scale_x, scale_y = (scale, scale)
122
+ elif scale is None:
123
+ scale_x, scale_y = (1, 1)
124
+ else:
125
+ scale_x, scale_y = scale
126
+
127
+ dx = dx or 0
128
+ dy = dy or 0
129
+ return mat_dot((float(scale_x), 0, 0, float(scale_y), dx, dy), transformation)
130
+
131
+
132
+ def transform_element(
133
+ elem: EtreeElement, matrix: _Matrix, *, reverse: bool = False
134
+ ) -> EtreeElement:
135
+ """Apply a transformation matrix to an svg element.
136
+
137
+ :param elem: svg element
138
+ :par m matrix: transformation matrix
139
+
140
+ :param reverse: If you have a transformation matrix, A, and wish to apply an
141
+ additional transform, B, the result is B @ A. This is how an element can be
142
+ cumulatively transformed in svg.
143
+
144
+ If the element is transformed by A and is a part of a GROUP transformed by B,
145
+ then the result is the reverse: A @ B.
146
+ """
147
+ current = get_transform_matrix(elem)
148
+ if reverse:
149
+ elem.attrib["transform"] = svg_matrix(mat_dot(current, matrix))
150
+ else:
151
+ elem.attrib["transform"] = svg_matrix(mat_dot(matrix, current))
152
+ return elem
@@ -0,0 +1,247 @@
1
+ """Convert between absolute units.
2
+
3
+ Model everything in user units, then use "width" and "height" in the svg root element
4
+ to scale these units to the desired size.
5
+
6
+ I borrowed test values and and conventions from Inkscape's `inkek.units.py`.
7
+
8
+ :author: Shay Hill
9
+ :created: 2023-02-12
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import enum
16
+ import re
17
+
18
+ from svg_ultralight.string_conversion import format_number
19
+
20
+ # units per inch
21
+ _UPI = 96
22
+
23
+ # units per centimeter
24
+ _UPC = 96 / 2.54
25
+
26
+
27
+ class Unit(enum.Enum):
28
+ """SVG Units of measurement.
29
+
30
+ Value is (unit conversion, unit specifier)
31
+
32
+ The unit specifier string are how various units are identified in SVG.
33
+ e.g., "44in"
34
+ """
35
+
36
+ IN = "in", _UPI # inches
37
+ PT = "pt", 4 / 3 # points
38
+ PX = "px", 1 # pixels
39
+ MM = "mm", _UPC / 10 # millimeters
40
+ CM = "cm", _UPC # centimeters
41
+ M = "m", _UPC * 100 # meters
42
+ KM = "km", _UPC * 100000 # kilometers
43
+ Q = "Q", _UPC / 40 # quarter-millimeters
44
+ PC = "pc", _UPI / 6 # picas
45
+ YD = "yd", _UPI * 36 # yards
46
+ FT = "ft", _UPI * 12 # feet
47
+ USER = "", 1 # "user units" without a unit specifier
48
+
49
+
50
+ # the arguments this module will attempt to interpret as a string with a unit specifier
51
+ MeasurementArg = (
52
+ float
53
+ | str
54
+ | tuple[str, str]
55
+ | tuple[float, str]
56
+ | tuple[str, Unit]
57
+ | tuple[float, Unit]
58
+ | Unit
59
+ )
60
+
61
+ _UNIT_SPECIFIER2UNIT = {x.value[0]: x for x in Unit}
62
+
63
+ _UNIT_SPECIFIERS = [x.value[0] for x in Unit]
64
+ _NUMBER = r"([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?"
65
+ _UNIT_RE = re.compile(rf"(?P<unit>{'|'.join(_UNIT_SPECIFIERS)})")
66
+ _NUMBER_RE = re.compile(rf"(?P<number>{_NUMBER})")
67
+ _NUMBER_AND_UNIT = re.compile(rf"^{_NUMBER_RE.pattern}{_UNIT_RE.pattern}$")
68
+
69
+
70
+ def _parse_unit(measurement_arg: MeasurementArg) -> tuple[float, Unit]:
71
+ """Split the value and unit from a string.
72
+
73
+ :param measurement_arg: The value to parse (e.g. "55.32px")
74
+ :return: A tuple of the value and Unit
75
+ :raise ValueError: If the value cannot be parsed
76
+
77
+ Take a value such as "55.32px" and return (55.32, Unit.PX). Preserves non-units,
78
+ so "55.32" returns (55.32, Unit.USER) These are actually pixels, but you don't
79
+ want "px" in your viewbox calls. It is best to work in non-specified "user units"
80
+ and then set the svg width and height to an specified unit.
81
+
82
+ Can handle a lot of args:
83
+
84
+ | arg type | example | result |
85
+ | ------------- | ------------------ | ------------------ |
86
+ | float | 55.32 | (55.32, Unit.USER) |
87
+ | str | "55.32px" | (55.32, Unit.PX) |
88
+ | str | "55.32" | (55.32, Unit.USER) |
89
+ | str | "px" | (0.0, Unit.PX) |
90
+ | (str, str) | ("55.32", "px") | (55.32, Unit.PX) |
91
+ | (float, str) | (55.32, "px") | (55.32, Unit.PX) |
92
+ | (str, Unit) | ("55.32", Unit.PX) | (55.32, Unit.PX) |
93
+ | (float, Unit) | (55.32, Unit.PX) | (55.32, Unit.PX) |
94
+ | Unit | Unit.PX | (0.0, Unit.PX) |
95
+ | Measurement | Measurement("3in") | (3.0, Unit.IN) |
96
+
97
+ """
98
+ failure_msg = f"Cannot parse value and unit from {measurement_arg}"
99
+ unit: str | Unit
100
+ try:
101
+ if isinstance(measurement_arg, tuple):
102
+ number, unit = float(measurement_arg[0]), measurement_arg[1]
103
+ if isinstance(unit, Unit):
104
+ return number, unit
105
+ return number, _UNIT_SPECIFIER2UNIT[unit]
106
+
107
+ if isinstance(measurement_arg, (int, float)):
108
+ return _parse_unit((measurement_arg, ""))
109
+
110
+ if isinstance(measurement_arg, Unit):
111
+ return _parse_unit((0, measurement_arg))
112
+
113
+ if number_unit := _NUMBER_AND_UNIT.match(str(measurement_arg)):
114
+ return _parse_unit((number_unit["number"], number_unit["unit"]))
115
+
116
+ if unit_only := _UNIT_RE.match(str(measurement_arg)):
117
+ return _parse_unit((0, unit_only["unit"]))
118
+
119
+ except (ValueError, KeyError) as e:
120
+ raise ValueError(failure_msg) from e
121
+
122
+ raise ValueError(failure_msg)
123
+
124
+
125
+ @dataclasses.dataclass
126
+ class Measurement:
127
+ """Measurement with unit of measurement.
128
+
129
+ Converts to and stores the value in user units. Also retains the input units so
130
+ you can update the value then convert back.
131
+ """
132
+
133
+ value: float
134
+ native_unit: Unit
135
+
136
+ def __init__(self, measurement_arg: MeasurementArg) -> None:
137
+ """Create a measurement from a string or float.
138
+
139
+ :param measurement_arg: a float (user units) or string with unit specifier.
140
+ :raises ValueError: if the input units cannot be identified
141
+ """
142
+ value, self.native_unit = _parse_unit(measurement_arg)
143
+ self.value = value * self.native_unit.value[1]
144
+
145
+ def get_value(self, unit: Unit | None = None) -> float:
146
+ """Get the measurement in the specified unit.
147
+
148
+ :param unit: optional unit to convert to
149
+ :return: value in specified units
150
+
151
+ It's best to do all math with self.value, but this is here for conversion
152
+ with less precision loss.
153
+ """
154
+ if unit is None:
155
+ return self.value
156
+ if isinstance(unit, str):
157
+ unit = _UNIT_SPECIFIER2UNIT[unit]
158
+ return self.value / unit.value[1]
159
+
160
+ def get_tuple(self, unit: Unit | None = None) -> tuple[float, Unit]:
161
+ """Get the measurement as a tuple of value and unit.
162
+
163
+ :param unit: optional unit to convert to
164
+ :return: value in specified as a tuple
165
+ """
166
+ return self.get_value(unit), unit or Unit.USER
167
+
168
+ def get_str(self, unit: Unit | None = None) -> str:
169
+ """Get the measurement in the specified unit as a string.
170
+
171
+ :param optional unit: the unit to convert to
172
+ :return: the measurement in the specified unit as a string
173
+
174
+ The input arguments for groups of measurements are less flexible than for
175
+ single measurements. Single measurements can be defined by something like
176
+ `(1, "in")`, but groups can be passed as single or tuples, so there is no way
177
+ to differentiate between (1, "in") and "1in" or (1, "in") as ("1", "0in").
178
+ That is a limitation, but doint it that way preserved the flexibility (and
179
+ backwards compatibility) of being able to define padding as "1in" everywhere
180
+ or (1, 2, 3, 4) for top, right, bottom, left.
181
+
182
+ The string from this method is different from the string in the `get_svg`
183
+ method, because this string will print a full printable precision, while the
184
+ svg string will print a reduced precision. So this string can be used to as
185
+ an argument to pad or print_width without losing precision.
186
+ """
187
+ value, unit = self.get_tuple(unit)
188
+ return f"{value}{unit.value[0]}"
189
+
190
+ def get_svg(self, unit: Unit | None = None) -> str:
191
+ """Get the measurement in the specified unit as it would be written in svg.
192
+
193
+ :param optional unit: the unit to convert to
194
+ :return: the measurement in the specified unit, always as a string
195
+
196
+ Rounds values to 6 decimal places as recommended by svg guidance online.
197
+ Higher precision just changes file size without imroving quality.
198
+ """
199
+ _, unit = self.get_tuple(unit)
200
+ value_as_str = format_number(self.get_value(unit))
201
+ return f"{value_as_str}{unit.value[0]}"
202
+
203
+ def __add__(self, other: Measurement) -> Measurement:
204
+ """Add two measurements.
205
+
206
+ :param other: the other measurement
207
+ :return: the sum of the two measurements in self native unit
208
+ """
209
+ result = Measurement(self.native_unit)
210
+ result.value = self.value + other.value
211
+ return result
212
+
213
+ def __sub__(self, other: Measurement) -> Measurement:
214
+ """Subtract two measurements.
215
+
216
+ :param other: the other measurement
217
+ :return: the difference of the two measurements in self native unit
218
+ """
219
+ result = Measurement(self.native_unit)
220
+ result.value = self.value - other.value
221
+ return result
222
+
223
+ def __mul__(self, scalar: float) -> Measurement:
224
+ """Multiply a measurement by a scalar.
225
+
226
+ :param scalar: the scalar to multiply by
227
+ :return: the measurement multiplied by the scalar in self native unit
228
+ """
229
+ result = Measurement(self.native_unit)
230
+ result.value = self.value * scalar
231
+ return result
232
+
233
+ def __rmul__(self, scalar: float) -> Measurement:
234
+ """Multiply a measurement by a scalar.
235
+
236
+ :param scalar: the scalar to multiply by
237
+ :return: the measurement multiplied by the scalar in self native unit
238
+ """
239
+ return self.__mul__(scalar)
240
+
241
+ def __truediv__(self, scalar: float) -> Measurement:
242
+ """Divide a measurement by a scalar.
243
+
244
+ :param scalar: the scalar to divide by
245
+ :return: the measurement divided by the scalar in self native unit
246
+ """
247
+ return self.__mul__(1.0 / scalar)