svg-ultralight 0.28.0__py3-none-any.whl → 0.30.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.

Potentially problematic release.


This version of svg-ultralight might be problematic. Click here for more details.

@@ -10,6 +10,11 @@ from svg_ultralight.bounding_boxes.bound_helpers import (
10
10
  new_element_union,
11
11
  )
12
12
  from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
13
+ <<<<<<< HEAD
14
+ from svg_ultralight.bounding_boxes.type_bound_confederation import BoundConfederation
15
+ =======
16
+ from svg_ultralight.bounding_boxes.type_bound_collection import BoundCollection
17
+ >>>>>>> dev
13
18
  from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
14
19
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
15
20
  from svg_ultralight.bounding_boxes.type_padded_text import PaddedText
@@ -37,8 +42,14 @@ from svg_ultralight.string_conversion import (
37
42
  format_numbers,
38
43
  format_numbers_in_string,
39
44
  )
45
+ from svg_ultralight.transformations import transform_element
40
46
 
41
47
  __all__ = [
48
+ <<<<<<< HEAD
49
+ "BoundConfederation",
50
+ =======
51
+ "BoundCollection",
52
+ >>>>>>> dev
42
53
  "BoundElement",
43
54
  "BoundingBox",
44
55
  "NSMAP",
@@ -59,6 +70,7 @@ __all__ = [
59
70
  "new_svg_root",
60
71
  "new_svg_root_around_bounds",
61
72
  "pad_text",
73
+ "transform_element",
62
74
  "update_element",
63
75
  "write_pdf",
64
76
  "write_pdf_from_svg",
svg_ultralight/animate.py CHANGED
@@ -34,7 +34,7 @@ def write_gif(
34
34
  :param loop: how many times to loop gif. 0 -> forever
35
35
  :effects: write file to gif
36
36
  """
37
- images = [Image.open(x) for x in pngs]
38
- images[0].save(
37
+ images = [Image.open(x) for x in pngs] # type: ignore
38
+ images[0].save( # type: ignore
39
39
  gif, save_all=True, append_images=images[1:], duration=duration, loop=loop
40
40
  )
@@ -18,6 +18,8 @@ from svg_ultralight.constructors import new_element
18
18
  if TYPE_CHECKING:
19
19
  from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
20
20
 
21
+ _Matrix = tuple[float, float, float, float, float, float]
22
+
21
23
 
22
24
  def new_element_union(
23
25
  *elems: EtreeElement | SupportsBounds, **attributes: float | str
@@ -16,13 +16,19 @@ Attributes:
16
16
  :created: 2023-02-15
17
17
  """
18
18
 
19
+ from __future__ import annotations
20
+
19
21
  from typing import Protocol
20
22
 
23
+ _Matrix = tuple[float, float, float, float, float, float]
24
+
21
25
 
22
26
  class SupportsBounds(Protocol):
23
27
  """Protocol for objects that can have bounds.
24
28
 
25
29
  Attributes:
30
+ transformation (_Matrix): An svg-style transformation matrix.
31
+ transform (method): Apply a transformation to the object.
26
32
  x (float): The minimum x coordinate.
27
33
  x2 (float): The maximum x coordinate.
28
34
  cx (float): The center x coordinate.
@@ -39,6 +45,22 @@ class SupportsBounds(Protocol):
39
45
  set width and height.
40
46
  """
41
47
 
48
+ @property
49
+ def transformation(self) -> _Matrix:
50
+ """Return an svg-style transformation matrix."""
51
+ ...
52
+
53
+ def transform(
54
+ self,
55
+ transformation: _Matrix | None = None,
56
+ *,
57
+ scale: float | None = None,
58
+ dx: float | None = None,
59
+ dy: float | None = None,
60
+ ):
61
+ """Apply a transformation to the object."""
62
+ ...
63
+
42
64
  @property
43
65
  def x(self) -> float:
44
66
  """Return minimum x coordinate."""
@@ -0,0 +1,71 @@
1
+ """A class to hold a list of bound elements and transform them together.
2
+
3
+ :author: Shay Hill
4
+ :created: 2024-05-05
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ from typing import TYPE_CHECKING
11
+
12
+ from lxml.etree import _Element as EtreeElement # type: ignore
13
+
14
+ from svg_ultralight.bounding_boxes.bound_helpers import new_bbox_union
15
+ from svg_ultralight.bounding_boxes.type_bounding_box import HasBoundingBox
16
+ from svg_ultralight.transformations import new_transformation_matrix, transform_element
17
+
18
+ if TYPE_CHECKING:
19
+ from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
20
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
21
+
22
+ _Matrix = tuple[float, float, float, float, float, float]
23
+
24
+
25
+ @dataclasses.dataclass
26
+ class BoundCollection(HasBoundingBox):
27
+ """A class to hold a list of bound elements and transform them together.
28
+
29
+ This will transform the individual elements in place.
30
+ """
31
+
32
+ blems: list[SupportsBounds | EtreeElement] = dataclasses.field(init=False)
33
+ bbox: BoundingBox = dataclasses.field(init=False)
34
+
35
+ def __init__(self, *blems: SupportsBounds | EtreeElement) -> None:
36
+ """Initialize the bound collection.
37
+
38
+ :param blems: bound elements to be transformed together
39
+ """
40
+ self.blems = list(blems)
41
+ self.bbox = new_bbox_union(*self.blems)
42
+
43
+ def transform(
44
+ self,
45
+ transformation: _Matrix | None = None,
46
+ *,
47
+ scale: float | None = None,
48
+ dx: float | None = None,
49
+ dy: float | None = None,
50
+ ):
51
+ """Transform each bound element in self.blems.
52
+
53
+ :param transformation: 2D transformation matrix
54
+ :param scale: optional scale factor
55
+ :param dx: optional x translation
56
+ :param dy: optional y translation
57
+
58
+ Keep track of all compounding transformations in order to have a value for
59
+ self.scale (required for membersh and to provide access to cumulative
60
+ transforms should this be useful for any reason. This means all
61
+ transformations must be applied to two bounding boxes: a persistant bbox to
62
+ keep track of the scale property and a temporary bbox to isolate each
63
+ transformation.
64
+ """
65
+ tmat = new_transformation_matrix(transformation, scale=scale, dx=dx, dy=dy)
66
+ self.bbox.transform(tmat)
67
+ for blem in self.blems:
68
+ if isinstance(blem, EtreeElement):
69
+ _ = transform_element(blem, tmat)
70
+ else:
71
+ blem.transform(tmat)
@@ -0,0 +1,71 @@
1
+ """A class to hold a list of bound elements and transform them together.
2
+
3
+ :author: Shay Hill
4
+ :created: 2024-05-05
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ from typing import TYPE_CHECKING
11
+
12
+ from lxml.etree import _Element as EtreeElement # type: ignore
13
+
14
+ from svg_ultralight.bounding_boxes.bound_helpers import new_bbox_union
15
+ from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
16
+ from svg_ultralight.bounding_boxes.type_bounding_box import HasBoundingBox
17
+ from svg_ultralight.transformations import new_transformation_matrix, transform_element
18
+
19
+ if TYPE_CHECKING:
20
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
21
+
22
+ _Matrix = tuple[float, float, float, float, float, float]
23
+
24
+
25
+ @dataclasses.dataclass
26
+ class BoundConfederation(HasBoundingBox):
27
+ """A class to hold a list of bound elements and transform them together.
28
+
29
+ This will transform the individual elements in place.
30
+ """
31
+
32
+ blems: list[SupportsBounds | EtreeElement] = dataclasses.field(init=False)
33
+ bbox: BoundingBox = dataclasses.field(init=False)
34
+
35
+ def __init__(self, *blems: SupportsBounds | EtreeElement) -> None:
36
+ """Initialize the bound confederation.
37
+
38
+ :param blems: bound elements to be transformed together
39
+ """
40
+ self.blems = list(blems)
41
+ self.bbox = new_bbox_union(*self.blems)
42
+
43
+ def transform(
44
+ self,
45
+ transformation: _Matrix | None = None,
46
+ *,
47
+ scale: float | None = None,
48
+ dx: float | None = None,
49
+ dy: float | None = None,
50
+ ):
51
+ """Transform each bound element in self.blems.
52
+
53
+ :param transformation: 2D transformation matrix
54
+ :param scale: optional scale factor
55
+ :param dx: optional x translation
56
+ :param dy: optional y translation
57
+
58
+ Keep track of all compounding transformations in order to have a value for
59
+ self.scale (required for membersh and to provide access to cumulative
60
+ transforms should this be useful for any reason. This means all
61
+ transformations must be applied to two bounding boxes: a persistant bbox to
62
+ keep track of the scale property and a temporary bbox to isolate each
63
+ transformation.
64
+ """
65
+ tmat = new_transformation_matrix(transformation, scale=scale, dx=dx, dy=dy)
66
+ self.bbox.transform(tmat)
67
+ for blem in self.blems:
68
+ if isinstance(blem, EtreeElement):
69
+ _ = transform_element(blem, tmat)
70
+ else:
71
+ blem.transform(tmat)
@@ -15,15 +15,18 @@ from __future__ import annotations
15
15
 
16
16
  from typing import TYPE_CHECKING
17
17
 
18
- from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
18
+ from svg_ultralight.bounding_boxes.type_bounding_box import HasBoundingBox
19
+ from svg_ultralight.transformations import new_transformation_matrix, transform_element
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from lxml.etree import _Element as EtreeElement # type: ignore
22
23
 
23
24
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
24
25
 
26
+ _Matrix = tuple[float, float, float, float, float, float]
25
27
 
26
- class BoundElement(SupportsBounds):
28
+
29
+ class BoundElement(HasBoundingBox):
27
30
  """An element with a bounding box.
28
31
 
29
32
  Updates the element when x, y, x2, y2, width, or height are set.
@@ -43,154 +46,21 @@ class BoundElement(SupportsBounds):
43
46
  def _update_elem(self):
44
47
  self.elem.attrib["transform"] = self.bbox.transform_string
45
48
 
46
- @property
47
- def x(self) -> float:
48
- """The x coordinate of the left edge of the bounding box.
49
-
50
- :return: the x coordinate of the left edge of the bounding box
51
- """
52
- return self.bbox.x
53
-
54
- @x.setter
55
- def x(self, value: float):
56
- """Set the x coordinate of the left edge of the bounding box.
57
-
58
- :param value: the new x coordinate of the left edge of the bounding box
59
- """
60
- self.bbox.x = value
61
- self._update_elem()
62
-
63
- @property
64
- def x2(self) -> float:
65
- """The x coordinate of the right edge of the bounding box.
66
-
67
- :return: the x coordinate of the right edge of the bounding box
68
- """
69
- return self.bbox.x2
70
-
71
- @x2.setter
72
- def x2(self, value: float):
73
- """Set the x coordinate of the right edge of the bounding box.
74
-
75
- :param value: the new x coordinate of the right edge of the bounding box
76
- """
77
- self.bbox.x2 = value
78
- self._update_elem()
79
-
80
- @property
81
- def cx(self) -> float:
82
- """The x coordinate of the center of the bounding box.
83
-
84
- :return: the x coordinate of the center of the bounding box
85
- """
86
- return self.bbox.cx
87
-
88
- @cx.setter
89
- def cx(self, value: float):
90
- """Set the x coordinate of the center of the bounding box.
91
-
92
- :param value: the new x coordinate of the center of the bounding box
93
- """
94
- self.bbox.cx = value
95
- self._update_elem()
96
-
97
- @property
98
- def y(self) -> float:
99
- """The y coordinate of the top edge of the bounding box.
100
-
101
- :return: the y coordinate of the top edge of the bounding box
102
- """
103
- return self.bbox.y
104
-
105
- @y.setter
106
- def y(self, value: float):
107
- """Set the y coordinate of the top edge of the bounding box.
108
-
109
- :param value: the new y coordinate of the top edge of the bounding box
110
- """
111
- self.bbox.y = value
112
- self._update_elem()
113
-
114
- @property
115
- def y2(self) -> float:
116
- """The y coordinate of the bottom edge of the bounding box.
117
-
118
- :return: the y coordinate of the bottom edge of the bounding box
119
- """
120
- return self.bbox.y2
121
-
122
- @y2.setter
123
- def y2(self, value: float):
124
- """Set the y coordinate of the bottom edge of the bounding box.
125
-
126
- :param value: the new y coordinate of the bottom edge of the bounding box
127
- """
128
- self.bbox.y2 = value
129
- self._update_elem()
130
-
131
- @property
132
- def cy(self) -> float:
133
- """The y coordinate of the center of the bounding box.
134
-
135
- :return: the y coordinate of the center of the bounding box
136
- """
137
- return self.bbox.cy
138
-
139
- @cy.setter
140
- def cy(self, value: float):
141
- """Set the y coordinate of the center of the bounding box.
142
-
143
- :param value: the new y coordinate of the center of the bounding box
144
- """
145
- self.bbox.cy = value
146
- self._update_elem()
147
-
148
- @property
149
- def width(self) -> float:
150
- """The width of the bounding box.
151
-
152
- :return: the width of the bounding box
153
- """
154
- return self.bbox.width
155
-
156
- @width.setter
157
- def width(self, value: float):
158
- """Set the width of the bounding box.
159
-
160
- :param value: the new width of the bounding box
161
- """
162
- self.bbox.width = value
163
- self._update_elem()
164
-
165
- @property
166
- def height(self) -> float:
167
- """The height of the bounding box.
168
-
169
- :return: the height of the bounding box
170
- """
171
- return self.bbox.height
172
-
173
- @height.setter
174
- def height(self, value: float):
175
- """Set the height of the bounding box.
176
-
177
- :param value: the new height of the bounding box
178
- """
179
- self.bbox.height = value
180
- self._update_elem()
181
-
182
- @property
183
- def scale(self) -> float:
184
- """The scale of the bounding box.
185
-
186
- :return: the scale of the bounding box
187
- """
188
- return self.bbox.scale
189
-
190
- @scale.setter
191
- def scale(self, value: float):
192
- """Set the scale of the bounding box.
193
-
194
- :param value: the scale of the bounding box
195
- """
196
- self.bbox.scale = value
49
+ def transform(
50
+ self,
51
+ transformation: _Matrix | None = None,
52
+ *,
53
+ scale: float | None = None,
54
+ dx: float | None = None,
55
+ dy: float | None = None,
56
+ ):
57
+ """Transform the element and bounding box.
58
+
59
+ :param transformation: a 6-tuple transformation matrix
60
+ :param scale: a scaling factor
61
+ :param dx: the x translation
62
+ :param dy: the y translation
63
+ """
64
+ tmat = new_transformation_matrix(transformation, scale=scale, dx=dx, dy=dy)
65
+ self.bbox.transform(tmat)
66
+ _ = transform_element(self.elem, tmat)
@@ -10,44 +10,9 @@ import dataclasses
10
10
 
11
11
  from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
12
12
  from svg_ultralight.string_conversion import format_number
13
+ from svg_ultralight.transformations import mat_apply, mat_dot, new_transformation_matrix
13
14
 
14
- Matrix = tuple[float, float, float, float, float, float]
15
-
16
-
17
- def mat_dot(mat1: Matrix, mat2: Matrix) -> Matrix:
18
- """Matrix multiplication for svg-style matrices.
19
-
20
- :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
21
- :param mat2: transformation matrix (sx, 0, 0, sy, tx, ty)
22
-
23
- Svg uses an unusual matrix format. For 3x3 transformation matrix
24
-
25
- [[00, 01, 02],
26
- [10, 11, 12],
27
- [20, 21, 22]]
28
-
29
- The svg matrix is
30
- (00, 10, 01, 11, 02, 12)
31
-
32
- Values 10 and 01 are only used for skewing, which is not supported by a bounding
33
- box, but they're here if this function is used in other ways.
34
- """
35
- aa = sum(mat1[x] * mat2[y] for x, y in ((0, 0), (2, 1)))
36
- bb = sum(mat1[x] * mat2[y] for x, y in ((1, 0), (3, 1)))
37
- cc = sum(mat1[x] * mat2[y] for x, y in ((0, 2), (2, 3)))
38
- dd = sum(mat1[x] * mat2[y] for x, y in ((1, 2), (3, 3)))
39
- ee = sum(mat1[x] * mat2[y] for x, y in ((0, 4), (2, 5))) + mat1[4]
40
- ff = sum(mat1[x] * mat2[y] for x, y in ((1, 4), (3, 5))) + mat1[5]
41
- return (aa, bb, cc, dd, ee, ff)
42
-
43
-
44
- def mat_apply(mat1: Matrix, mat2: tuple[float, float]) -> tuple[float, float]:
45
- """Apply an svg-style transformation matrix to a point.
46
-
47
- :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
48
- :param mat2: point (x, y)
49
- """
50
- return mat1[0] * mat2[0] + mat1[4], mat1[3] * mat2[1] + mat1[5]
15
+ _Matrix = tuple[float, float, float, float, float, float]
51
16
 
52
17
 
53
18
  @dataclasses.dataclass
@@ -64,7 +29,7 @@ class BoundingBox(SupportsBounds):
64
29
  to initialize a transformed box with the same transform_string as another box.
65
30
  Under most circumstances, it will not be used.
66
31
 
67
- :param _transform: transformation matrix
32
+ :param _transformation: transformation matrix
68
33
 
69
34
  Functions that return a bounding box will return a BoundingBox instance. This
70
35
  instance can be transformed (uniform scale and translate only). Transformations
@@ -73,10 +38,10 @@ class BoundingBox(SupportsBounds):
73
38
  Define the bbox with x=, y=, width=, height=
74
39
 
75
40
  Transform the BoundingBox by setting these variables. Each time you set x, cx,
76
- x2, y, cy, y2, width, or height, private transformation value _transform will be
77
- updated.
41
+ x2, y, cy, y2, width, or height, private transformation value _transformation
42
+ will be updated.
78
43
 
79
- The ultimate transformation can be accessed through ``.transformation_string``.
44
+ The ultimate transformation can be accessed through ``.transform_string``.
80
45
  So the workflow will look like :
81
46
 
82
47
  1. Get the bounding box of an svg element
@@ -114,15 +79,40 @@ class BoundingBox(SupportsBounds):
114
79
  _y: float
115
80
  _width: float
116
81
  _height: float
117
- _transform: Matrix = (1, 0, 0, 1, 0, 0)
82
+ _transformation: _Matrix = (1, 0, 0, 1, 0, 0)
118
83
 
119
84
  @property
120
- def transform(self) -> Matrix:
121
- """Get read only tranformation matrix.
85
+ def transformation(self) -> _Matrix:
86
+ """Return transformation matrix.
122
87
 
123
- :return: transformation matrix of the bounding box
88
+ :return: transformation matrix
124
89
  """
125
- return self._transform
90
+ return self._transformation
91
+
92
+ def transform(
93
+ self,
94
+ transformation: _Matrix | None = None,
95
+ *,
96
+ scale: float | None = None,
97
+ dx: float | None = None,
98
+ dy: float | None = None,
99
+ ):
100
+ """Transform the bounding box by updating the transformation attribute.
101
+
102
+ :param transformation: 2D transformation matrix
103
+ :param scale: scale factor
104
+ :param dx: x translation
105
+ :param dy: y translation
106
+
107
+ All parameters are optional. Scale, dx, and dy are optional and applied after
108
+ the transformation matrix if both are given. This shouldn't be necessary in
109
+ most cases, the four parameters are there to allow transformation arguments
110
+ to be passed in a variety of ways. Scale, dx, and dy are the sensible values
111
+ to pass "by hand". The transformation matrix is the sensible argument to pass
112
+ when applying a transformation from another bounding box instance.
113
+ """
114
+ tmat = new_transformation_matrix(transformation, scale=scale, dx=dx, dy=dy)
115
+ self._transformation = mat_dot(tmat, self.transformation)
126
116
 
127
117
  @property
128
118
  def scale(self) -> float:
@@ -137,7 +127,7 @@ class BoundingBox(SupportsBounds):
137
127
  width*scale, height => height*scale, scale => scale*scale. This matches how
138
128
  scale works in almost every other context.
139
129
  """
140
- return self._transform[0]
130
+ return self.transformation[0]
141
131
 
142
132
  @scale.setter
143
133
  def scale(self, value: float) -> None:
@@ -152,7 +142,7 @@ class BoundingBox(SupportsBounds):
152
142
  `scale = 2` -> ignore whatever scale was previously defined and set scale to 2
153
143
  `scale *= 2` -> make it twice as big as it was.
154
144
  """
155
- self._add_transform(value / self.scale, 0, 0)
145
+ self.transform(scale=value / self.scale)
156
146
 
157
147
  @property
158
148
  def x(self) -> float:
@@ -160,15 +150,15 @@ class BoundingBox(SupportsBounds):
160
150
 
161
151
  :return: internal _x value transformed by scale and translation
162
152
  """
163
- return mat_apply(self._transform, (self._x, 0))[0]
153
+ return mat_apply(self.transformation, (self._x, 0))[0]
164
154
 
165
155
  @x.setter
166
156
  def x(self, value: float) -> None:
167
- """Update transform values (do not alter self._x).
157
+ """Update transformation values (do not alter self._x).
168
158
 
169
159
  :param value: new x value after transformation
170
160
  """
171
- self._add_transform(1, value - self.x, 0)
161
+ self.transform(dx=value - self.x)
172
162
 
173
163
  @property
174
164
  def cx(self) -> float:
@@ -196,7 +186,7 @@ class BoundingBox(SupportsBounds):
196
186
 
197
187
  @x2.setter
198
188
  def x2(self, value: float) -> None:
199
- """Update transform values (do not alter self._x2).
189
+ """Update transformation values (do not alter self._x2).
200
190
 
201
191
  :param value: new x2 value after transformation
202
192
  """
@@ -208,15 +198,15 @@ class BoundingBox(SupportsBounds):
208
198
 
209
199
  :return: internal _y value transformed by scale and translation
210
200
  """
211
- return mat_apply(self._transform, (0, self._y))[1]
201
+ return mat_apply(self.transformation, (0, self._y))[1]
212
202
 
213
203
  @y.setter
214
204
  def y(self, value: float) -> None:
215
- """Update transform values (do not alter self._y).
205
+ """Update transformation values (do not alter self._y).
216
206
 
217
207
  :param value: new y value after transformation
218
208
  """
219
- self._add_transform(1, 0, value - self.y)
209
+ self.transform(dy=value - self.y)
220
210
 
221
211
  @property
222
212
  def cy(self) -> float:
@@ -244,7 +234,7 @@ class BoundingBox(SupportsBounds):
244
234
 
245
235
  @y2.setter
246
236
  def y2(self, value: float) -> None:
247
- """Update transform values (do not alter self._y).
237
+ """Update transformation values (do not alter self._y).
248
238
 
249
239
  :param value: new y2 value after transformation
250
240
  """
@@ -260,7 +250,7 @@ class BoundingBox(SupportsBounds):
260
250
 
261
251
  @width.setter
262
252
  def width(self, value: float) -> None:
263
- """Update transform values, Do not alter self._width.
253
+ """Update transformation values, Do not alter self._width.
264
254
 
265
255
  :param value: new width value after transformation
266
256
 
@@ -283,7 +273,7 @@ class BoundingBox(SupportsBounds):
283
273
 
284
274
  @height.setter
285
275
  def height(self, value: float) -> None:
286
- """Update transform values, Do not alter self._height.
276
+ """Update transformation values, Do not alter self._height.
287
277
 
288
278
  :param value: new height value after transformation
289
279
 
@@ -292,26 +282,16 @@ class BoundingBox(SupportsBounds):
292
282
  """
293
283
  self.width = value * self.width / self.height
294
284
 
295
- def _add_transform(self, scale: float, dx: float, dy: float):
296
- """Transform the bounding box by updating the transformation attributes.
297
-
298
- :param scale: scale factor
299
- :param dx: x translation
300
- :param dy: y translation
301
- """
302
- tmat = (scale, 0, 0, scale, dx, dy)
303
- self._transform = mat_dot(tmat, self._transform)
304
-
305
285
  @property
306
286
  def transform_string(self) -> str:
307
287
  """Transformation property string value for svg element.
308
288
 
309
- :return: string value for an svg transform attribute.
289
+ :return: string value for an svg transformation attribute.
310
290
 
311
291
  Use with
312
292
  ``update_element(elem, transform=bbox.transform_string)``
313
293
  """
314
- return f"matrix({' '.join(map(format_number, self._transform))})"
294
+ return f"matrix({' '.join(map(format_number, self.transformation))})"
315
295
 
316
296
  def merge(self, *others: BoundingBox) -> BoundingBox:
317
297
  """Create a bounding box around all other bounding boxes.
@@ -341,3 +321,181 @@ class BoundingBox(SupportsBounds):
341
321
  min_y = min(x.y for x in bboxes)
342
322
  max_y = max(x.y + x.height for x in bboxes)
343
323
  return BoundingBox(min_x, min_y, max_x - min_x, max_y - min_y)
324
+
325
+
326
+ class HasBoundingBox(SupportsBounds):
327
+ """A parent class for BoundElement and others that have a bbox attribute."""
328
+
329
+ def __init__(self, bbox: BoundingBox) -> None:
330
+ """Initialize the HasBoundingBox instance."""
331
+ self.bbox = bbox
332
+
333
+ @property
334
+ def transformation(self) -> _Matrix:
335
+ """The transformation matrix of the bounding box."""
336
+ return self.bbox.transformation
337
+
338
+ def transform(
339
+ self,
340
+ transformation: _Matrix | None = None,
341
+ *,
342
+ scale: float | None = None,
343
+ dx: float | None = None,
344
+ dy: float | None = None,
345
+ ):
346
+ """Transform the element and bounding box.
347
+
348
+ :param transformation: a 6-tuple transformation matrix
349
+ :param scale: a scaling factor
350
+ :param dx: the x translation
351
+ :param dy: the y translation
352
+ """
353
+ self.bbox.transform(transformation, scale=scale, dx=dx, dy=dy)
354
+
355
+ @property
356
+ def scale(self) -> float:
357
+ """The scale of the bounding box.
358
+
359
+ :return: the scale of the bounding box
360
+ """
361
+ return self.transformation[0]
362
+
363
+ @scale.setter
364
+ def scale(self, value: float):
365
+ """Set the scale of the bounding box.
366
+
367
+ :param value: the scale of the bounding box
368
+ """
369
+ self.transform(scale=value / self.scale)
370
+
371
+ @property
372
+ def x(self) -> float:
373
+ """The x coordinate of the left edge of the bounding box.
374
+
375
+ :return: the x coordinate of the left edge of the bounding box
376
+ """
377
+ return self.bbox.x
378
+
379
+ @x.setter
380
+ def x(self, value: float):
381
+ """Set the x coordinate of the left edge of the bounding box.
382
+
383
+ :param value: the new x coordinate of the left edge of the bounding box
384
+ """
385
+ self.transform(dx=value - self.x)
386
+
387
+ @property
388
+ def x2(self) -> float:
389
+ """The x coordinate of the right edge of the bounding box.
390
+
391
+ :return: the x coordinate of the right edge of the bounding box
392
+ """
393
+ return self.bbox.x2
394
+
395
+ @x2.setter
396
+ def x2(self, value: float):
397
+ """Set the x coordinate of the right edge of the bounding box.
398
+
399
+ :param value: the new x coordinate of the right edge of the bounding box
400
+ """
401
+ self.x += value - self.x2
402
+
403
+ @property
404
+ def cx(self) -> float:
405
+ """The x coordinate of the center of the bounding box.
406
+
407
+ :return: the x coordinate of the center of the bounding box
408
+ """
409
+ return self.bbox.cx
410
+
411
+ @cx.setter
412
+ def cx(self, value: float):
413
+ """Set the x coordinate of the center of the bounding box.
414
+
415
+ :param value: the new x coordinate of the center of the bounding box
416
+ """
417
+ self.x += value - self.cx
418
+
419
+ @property
420
+ def y(self) -> float:
421
+ """The y coordinate of the top edge of the bounding box.
422
+
423
+ :return: the y coordinate of the top edge of the bounding box
424
+ """
425
+ return self.bbox.y
426
+
427
+ @y.setter
428
+ def y(self, value: float):
429
+ """Set the y coordinate of the top edge of the bounding box.
430
+
431
+ :param value: the new y coordinate of the top edge of the bounding box
432
+ """
433
+ self.transform(dy=value - self.y)
434
+
435
+ @property
436
+ def y2(self) -> float:
437
+ """The y coordinate of the bottom edge of the bounding box.
438
+
439
+ :return: the y coordinate of the bottom edge of the bounding box
440
+ """
441
+ return self.bbox.y2
442
+
443
+ @y2.setter
444
+ def y2(self, value: float):
445
+ """Set the y coordinate of the bottom edge of the bounding box.
446
+
447
+ :param value: the new y coordinate of the bottom edge of the bounding box
448
+ """
449
+ self.y += value - self.y2
450
+
451
+ @property
452
+ def cy(self) -> float:
453
+ """The y coordinate of the center of the bounding box.
454
+
455
+ :return: the y coordinate of the center of the bounding box
456
+ """
457
+ return self.bbox.cy
458
+
459
+ @cy.setter
460
+ def cy(self, value: float):
461
+ """Set the y coordinate of the center of the bounding box.
462
+
463
+ :param value: the new y coordinate of the center of the bounding box
464
+ """
465
+ self.y += value - self.cy
466
+
467
+ @property
468
+ def width(self) -> float:
469
+ """The width of the bounding box.
470
+
471
+ :return: the width of the bounding box
472
+ """
473
+ return self.bbox.width
474
+
475
+ @width.setter
476
+ def width(self, value: float):
477
+ """Set the width of the bounding box.
478
+
479
+ :param value: the new width of the bounding box
480
+ """
481
+ current_x = self.x
482
+ current_y = self.y
483
+ self.scale *= value / self.width
484
+ self.x = current_x
485
+ self.y = current_y
486
+
487
+ @property
488
+ def height(self) -> float:
489
+ """The height of the bounding box.
490
+
491
+ :return: the height of the bounding box
492
+ """
493
+ return self.bbox.height
494
+
495
+ @height.setter
496
+ def height(self, value: float):
497
+ """Set the height of the bounding box.
498
+
499
+ :param value: the new height of the bounding box
500
+ """
501
+ self.width *= value / self.height
@@ -72,6 +72,8 @@ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
72
72
  if TYPE_CHECKING:
73
73
  from lxml.etree import _Element as EtreeElement # type: ignore
74
74
 
75
+ _Matrix = tuple[float, float, float, float, float, float]
76
+
75
77
 
76
78
  class PaddedText(SupportsBounds):
77
79
  """A line of text with a bounding box and padding."""
@@ -116,11 +118,32 @@ class PaddedText(SupportsBounds):
116
118
  self.lmargin, self.capline, self.padded_width, self.padded_height
117
119
  )
118
120
 
119
- def _update(self, attrib: str, value: float) -> None:
120
- """Update bbox attribute and keep elem synced."""
121
- setattr(self.bbox, attrib, value)
121
+ @property
122
+ def transformation(self) -> _Matrix:
123
+ """The transformation matrix of the bounding box."""
124
+ return self.bbox.transformation
125
+
126
+ def _update_elem(self):
122
127
  self.elem.attrib["transform"] = self.bbox.transform_string
123
128
 
129
+ def transform(
130
+ self,
131
+ transformation: _Matrix | None = None,
132
+ *,
133
+ scale: float | None = None,
134
+ dx: float | None = None,
135
+ dy: float | None = None,
136
+ ):
137
+ """Transform the element and bounding box.
138
+
139
+ :param transformation: a 6-tuple transformation matrix
140
+ :param scale: a scaling factor
141
+ :param dx: the x translation
142
+ :param dy: the y translation
143
+ """
144
+ self.bbox.transform(transformation, scale=scale, dx=dx, dy=dy)
145
+ self._update_elem()
146
+
124
147
  @property
125
148
  def tpad(self) -> float:
126
149
  """The top padding of this line of text.
@@ -167,7 +190,7 @@ class PaddedText(SupportsBounds):
167
190
 
168
191
  :param value: The left margin of this line of text.
169
192
  """
170
- self._update("x", value + self.lpad)
193
+ self.transform(dx=value + self.lpad - self.bbox.x)
171
194
 
172
195
  @property
173
196
  def rmargin(self) -> float:
@@ -183,7 +206,7 @@ class PaddedText(SupportsBounds):
183
206
 
184
207
  :param value: The right margin of this line of text.
185
208
  """
186
- self._update("x2", value - self.rpad)
209
+ self.transform(dx=value - self.rpad - self.bbox.x2)
187
210
 
188
211
  @property
189
212
  def capline(self) -> float:
@@ -199,7 +222,7 @@ class PaddedText(SupportsBounds):
199
222
 
200
223
  :param value: The top of this line of text.
201
224
  """
202
- self._update("y", value + self.tpad)
225
+ self.transform(dy=value + self.tpad - self.bbox.y)
203
226
 
204
227
  @property
205
228
  def baseline(self) -> float:
@@ -215,7 +238,7 @@ class PaddedText(SupportsBounds):
215
238
 
216
239
  :param value: The bottom of this line of text.
217
240
  """
218
- self._update("y2", value - self.bpad)
241
+ self.transform(dy=value - self.bpad - self.bbox.y2)
219
242
 
220
243
  @property
221
244
  def padded_width(self) -> float:
@@ -239,8 +262,9 @@ class PaddedText(SupportsBounds):
239
262
  *and* y2) when scaling.
240
263
  """
241
264
  baseline = self.baseline
242
- self._update("width", width - self.lpad - self.rpad)
265
+ self.bbox.width = width - self.lpad - self.rpad
243
266
  self.baseline = baseline
267
+ self._update_elem()
244
268
 
245
269
  @property
246
270
  def padded_height(self) -> float:
@@ -0,0 +1,108 @@
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 re
10
+ from contextlib import suppress
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ from svg_ultralight.string_conversion import format_number
14
+
15
+ if TYPE_CHECKING:
16
+ from lxml.etree import _Element as EtreeElement # type: ignore
17
+
18
+
19
+ _RE_MATRIX = re.compile(r"matrix\(([^)]+)\)")
20
+
21
+ _Matrix = tuple[float, float, float, float, float, float]
22
+
23
+
24
+ def mat_dot(mat1: _Matrix, mat2: _Matrix) -> _Matrix:
25
+ """Matrix multiplication for svg-style matrices.
26
+
27
+ :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
28
+ :param mat2: transformation matrix (sx, 0, 0, sy, tx, ty)
29
+
30
+ Svg uses an unusual matrix format. For 3x3 transformation matrix
31
+
32
+ [[00, 01, 02],
33
+ [10, 11, 12],
34
+ [20, 21, 22]]
35
+
36
+ The svg matrix is
37
+ (00, 10, 01, 11, 02, 12)
38
+
39
+ Values 10 and 01 are only used for skewing, which is not supported by a bounding
40
+ box. Values 00 and 11 will always be identical for symmetric scaling, which is
41
+ the only scaling implemented in my BoundingBox classes. However, all six values
42
+ are implemented in case this function is used in other contexts.
43
+ """
44
+ aa = sum(mat1[x] * mat2[y] for x, y in ((0, 0), (2, 1)))
45
+ bb = sum(mat1[x] * mat2[y] for x, y in ((1, 0), (3, 1)))
46
+ cc = sum(mat1[x] * mat2[y] for x, y in ((0, 2), (2, 3)))
47
+ dd = sum(mat1[x] * mat2[y] for x, y in ((1, 2), (3, 3)))
48
+ ee = sum(mat1[x] * mat2[y] for x, y in ((0, 4), (2, 5))) + mat1[4]
49
+ ff = sum(mat1[x] * mat2[y] for x, y in ((1, 4), (3, 5))) + mat1[5]
50
+ return (aa, bb, cc, dd, ee, ff)
51
+
52
+
53
+ def mat_apply(mat1: _Matrix, mat2: tuple[float, float]) -> tuple[float, float]:
54
+ """Apply an svg-style transformation matrix to a point.
55
+
56
+ :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
57
+ :param mat2: point (x, y)
58
+ """
59
+ return mat1[0] * mat2[0] + mat1[4], mat1[3] * mat2[1] + mat1[5]
60
+
61
+
62
+ def get_transform_matrix(elem: EtreeElement) -> _Matrix:
63
+ """Get the transformation matrix from an svg element.
64
+
65
+ :param element: svg element
66
+ """
67
+ transform = elem.attrib.get("transform")
68
+ if not transform:
69
+ return (1, 0, 0, 1, 0, 0)
70
+ values_str = ""
71
+ with suppress(AttributeError):
72
+ values_str = cast(re.Match[str], _RE_MATRIX.match(transform)).group(1)
73
+ with suppress(ValueError):
74
+ aa, bb, cc, dd, ee, ff = (float(val) for val in values_str.split())
75
+ return (aa, bb, cc, dd, ee, ff)
76
+ msg = f"Could not parse transformation matrix from {transform}"
77
+ raise ValueError(msg)
78
+
79
+
80
+ def new_transformation_matrix(
81
+ transformation: _Matrix | None = None,
82
+ *,
83
+ scale: float | None = None,
84
+ dx: float | None = None,
85
+ dy: float | None = None,
86
+ ) -> _Matrix:
87
+ """Create a new transformation matrix.
88
+
89
+ This takes the standard arguments in the BoundingBox classes and returns an
90
+ svg-style transformation matrix.
91
+ """
92
+ transformation = transformation or (1, 0, 0, 1, 0, 0)
93
+ scale = scale or 1
94
+ dx = dx or 0
95
+ dy = dy or 0
96
+ return mat_dot((scale, 0, 0, scale, dx, dy), transformation)
97
+
98
+
99
+ def transform_element(elem: EtreeElement, matrix: _Matrix) -> EtreeElement:
100
+ """Apply a transformation matrix to an svg element.
101
+
102
+ :param elem: svg element
103
+ :param matrix: transformation matrix
104
+ """
105
+ current = get_transform_matrix(elem)
106
+ updated = map(format_number, mat_dot(matrix, current))
107
+ elem.attrib["transform"] = f"matrix({' '.join(updated)})"
108
+ return elem
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: svg-ultralight
3
- Version: 0.28.0
3
+ Version: 0.30.0
4
4
  Summary: a sensible way to create svg files with Python
5
5
  Author-email: Shay Hill <shay_public@hotmail.com>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
- svg_ultralight/__init__.py,sha256=Xyi-aBOAfVNoUg9rHXYVMELehe1kReEyQYL682AcQ1A,1877
2
- svg_ultralight/animate.py,sha256=JSrBm-59BcNXDF0cGgl4-C89eBunjevZnwZxIWt48TU,1112
1
+ svg_ultralight/__init__.py,sha256=KkTwZoRU2fOtStK38grA5Tjug5bJpUw78FZ9TLN-170,2256
2
+ svg_ultralight/animate.py,sha256=fE-zRU_uFrZIL9W78fcGz7qmrts8fz5UoWEy7b4xb44,1144
3
3
  svg_ultralight/inkscape.py,sha256=M8yTxXOu4NlXnhsMycvEJiIDpnDeiZ_bZakJBM38ZoU,9152
4
4
  svg_ultralight/layout.py,sha256=TTETT_8WLBXnQxDGXdAeczCFN5pFo5kKY3Q6zv4FPX4,12238
5
5
  svg_ultralight/main.py,sha256=6oNkZfD27UMdP-oYqp5agS_IGcYb8NkUZwM9Zdyb3SA,7287
@@ -9,18 +9,21 @@ svg_ultralight/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  svg_ultralight/query.py,sha256=_KQuk4IwhVVDgTT0GZm_gbqNUGp7lMEDM32b_CTTSUA,7320
10
10
  svg_ultralight/root_elements.py,sha256=pt9J6mPrnoTAZVF6vKTZoM_o947I8UCj6MbGcD2JUCk,2869
11
11
  svg_ultralight/string_conversion.py,sha256=WEmpf75RJmJ2lfJluagAz2wPsz6wM8XvTEwkq4U0vEc,7353
12
+ svg_ultralight/transformations.py,sha256=CT43zaWSmEoWf-rQpFgP_1KX9wL6AzpR0cF8E1fIhAU,3625
12
13
  svg_ultralight/unit_conversion.py,sha256=g07nhzXdjPvGcJmkhLdFbeDLrSmbI8uFoVgPo7G62Bg,9258
13
14
  svg_ultralight/bounding_boxes/__init__.py,sha256=qUEn3r4s-1QNHaguhWhhaNfdP4tl_B6YEqxtiTFuzhQ,78
14
- svg_ultralight/bounding_boxes/bound_helpers.py,sha256=D-Qp8yDmn5vIBdaSlBvaWR0F_iRCdotgQqjaRHXBq-8,3397
15
- svg_ultralight/bounding_boxes/supports_bounds.py,sha256=1yqmZ7PH1bBH-LTIUDzSvKFXkPLXfJM7jJNz0bXurZ4,3926
16
- svg_ultralight/bounding_boxes/type_bound_element.py,sha256=VKiN4UnC2XlPKapWRHxgtqhO4BuVoe6YkLrirlEP09w,5714
17
- svg_ultralight/bounding_boxes/type_bounding_box.py,sha256=8c9aNTVCjyZpHQMW5mFsxTkgOi_2yH7TyGxlmJPxUR0,11444
18
- svg_ultralight/bounding_boxes/type_padded_text.py,sha256=FnwHD54qPMlyOB0yhRgZShQWA_riaaTS9GwD9HnbPm4,14119
15
+ svg_ultralight/bounding_boxes/bound_helpers.py,sha256=YMClhdekeYbzD_ijXDAer-H3moWKN3lUNnZs1UNFFKc,3458
16
+ svg_ultralight/bounding_boxes/supports_bounds.py,sha256=fbHV6mGdeIVV3lS15vBKSrHiHKR7DMg4K5X__4LLmCE,4569
17
+ svg_ultralight/bounding_boxes/type_bound_collection.py,sha256=b89TM2UsdaeApyjTQeMbx_FG_WcCiLAImEiHiZ6EWPI,2600
18
+ svg_ultralight/bounding_boxes/type_bound_confederation.py,sha256=PAUa8LSfyw0v2eq8EU2B_V5dlsl7ddrrPIsD-5fWU8U,2602
19
+ svg_ultralight/bounding_boxes/type_bound_element.py,sha256=9RdxH8osOlAvPdWR0Ww9NsasHLPYFDs-MbydoV48x4E,2239
20
+ svg_ultralight/bounding_boxes/type_bounding_box.py,sha256=cDrMp6uwaA--KJIQS2puG10qh8n3TBmiscg-cfk1f3w,16149
21
+ svg_ultralight/bounding_boxes/type_padded_text.py,sha256=QA6PfeO_sQYc5pEXuyfyQ3lRUcZAc4B2BthWXpdt3qQ,14848
19
22
  svg_ultralight/constructors/__init__.py,sha256=YcnO0iBQc19aL8Iemw0Y452MBMBIT2AN5nZCnoGxpn0,327
20
23
  svg_ultralight/constructors/new_element.py,sha256=VtMz9sPn9rMk6rui5Poysy3vezlOaS-tGIcGbu-SXmY,3406
21
24
  svg_ultralight/strings/__init__.py,sha256=Zalrf-ThFz7b7xKELx5lb2gOlBgV-6jk_k_EeSdVCVk,295
22
25
  svg_ultralight/strings/svg_strings.py,sha256=RYKMxOHq9abbZyGcFqsElBGLrBX-EjjNxln3s_ibi30,1296
23
- svg_ultralight-0.28.0.dist-info/METADATA,sha256=ybepa8hEhsBKXUT_CtnqUoKBh5Eg5nQReYFyQwdNW60,8871
24
- svg_ultralight-0.28.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
25
- svg_ultralight-0.28.0.dist-info/top_level.txt,sha256=se-6yqM_0Yg5orJKvKWdjQZ4iR4G_EjhL7oRgju-fdY,15
26
- svg_ultralight-0.28.0.dist-info/RECORD,,
26
+ svg_ultralight-0.30.0.dist-info/METADATA,sha256=vAGCbcN3nh37O6b5qU8tJbMfogcORk0u5-77R_2H8HM,8871
27
+ svg_ultralight-0.30.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
28
+ svg_ultralight-0.30.0.dist-info/top_level.txt,sha256=se-6yqM_0Yg5orJKvKWdjQZ4iR4G_EjhL7oRgju-fdY,15
29
+ svg_ultralight-0.30.0.dist-info/RECORD,,