svg-ultralight 0.28.0__tar.gz → 0.29.0__tar.gz

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.

Files changed (48) hide show
  1. {svg_ultralight-0.28.0/src/svg_ultralight.egg-info → svg_ultralight-0.29.0}/PKG-INFO +1 -1
  2. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/pyproject.toml +2 -2
  3. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/__init__.py +4 -0
  4. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/animate.py +2 -2
  5. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/bounding_boxes/bound_helpers.py +2 -0
  6. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/bounding_boxes/supports_bounds.py +22 -0
  7. svg_ultralight-0.29.0/src/svg_ultralight/bounding_boxes/type_bound_confederation.py +71 -0
  8. svg_ultralight-0.29.0/src/svg_ultralight/bounding_boxes/type_bound_element.py +66 -0
  9. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/bounding_boxes/type_bounding_box.py +228 -70
  10. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/bounding_boxes/type_padded_text.py +32 -8
  11. svg_ultralight-0.29.0/src/svg_ultralight/transformations.py +108 -0
  12. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0/src/svg_ultralight.egg-info}/PKG-INFO +1 -1
  13. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight.egg-info/SOURCES.txt +3 -0
  14. svg_ultralight-0.29.0/tests/test_bounding.py +180 -0
  15. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_queries.py +1 -1
  16. svg_ultralight-0.28.0/src/svg_ultralight/bounding_boxes/type_bound_element.py +0 -196
  17. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/.gitignore +0 -0
  18. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/.pre-commit-config.yaml +0 -0
  19. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/README.md +0 -0
  20. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/setup.cfg +0 -0
  21. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/bounding_boxes/__init__.py +0 -0
  22. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/constructors/__init__.py +0 -0
  23. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/constructors/new_element.py +0 -0
  24. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/inkscape.py +0 -0
  25. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/layout.py +0 -0
  26. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/main.py +0 -0
  27. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/metadata.py +0 -0
  28. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/nsmap.py +0 -0
  29. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/py.typed +0 -0
  30. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/query.py +0 -0
  31. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/root_elements.py +0 -0
  32. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/string_conversion.py +0 -0
  33. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/strings/__init__.py +0 -0
  34. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/strings/svg_strings.py +0 -0
  35. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight/unit_conversion.py +0 -0
  36. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight.egg-info/dependency_links.txt +0 -0
  37. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight.egg-info/requires.txt +0 -0
  38. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/src/svg_ultralight.egg-info/top_level.txt +0 -0
  39. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/__init__.py +0 -0
  40. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/conftest.py +0 -0
  41. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_inkscape.py +0 -0
  42. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_layout.py +0 -0
  43. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_metadata.py +0 -0
  44. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_new_element.py +0 -0
  45. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_root_elements.py +0 -0
  46. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_string_conversion.py +0 -0
  47. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tests/test_svg_ultralight.py +0 -0
  48. {svg_ultralight-0.28.0 → svg_ultralight-0.29.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: svg-ultralight
3
- Version: 0.28.0
3
+ Version: 0.29.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "svg-ultralight"
3
- version = "0.28.0"
3
+ version = "0.29.0"
4
4
  description = "a sensible way to create svg files with Python"
5
5
  authors = [{ name = "Shay Hill", email = "shay_public@hotmail.com" }]
6
6
  license = { text = "MIT" }
@@ -37,7 +37,7 @@ legacy_tox_ini = """
37
37
 
38
38
  [tool.commitizen]
39
39
  name = "cz_conventional_commits"
40
- version = "0.28.0"
40
+ version = "0.29.0"
41
41
  tag_format = "$version"
42
42
  version_files = ["pyproject.toml:^version"]
43
43
  annotated_tag = true
@@ -10,6 +10,7 @@ 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
+ from svg_ultralight.bounding_boxes.type_bound_confederation import BoundConfederation
13
14
  from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
14
15
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
15
16
  from svg_ultralight.bounding_boxes.type_padded_text import PaddedText
@@ -37,8 +38,10 @@ from svg_ultralight.string_conversion import (
37
38
  format_numbers,
38
39
  format_numbers_in_string,
39
40
  )
41
+ from svg_ultralight.transformations import transform_element
40
42
 
41
43
  __all__ = [
44
+ "BoundConfederation",
42
45
  "BoundElement",
43
46
  "BoundingBox",
44
47
  "NSMAP",
@@ -59,6 +62,7 @@ __all__ = [
59
62
  "new_svg_root",
60
63
  "new_svg_root_around_bounds",
61
64
  "pad_text",
65
+ "transform_element",
62
66
  "update_element",
63
67
  "write_pdf",
64
68
  "write_pdf_from_svg",
@@ -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.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)
@@ -0,0 +1,66 @@
1
+ """An element tied to a BoundingBox instance.
2
+
3
+ Take an element, associate it to a BoundingBox instance, transform the BoundingBox
4
+ instance. The element will be transformed accordingly.
5
+
6
+ It is critical to remember that self.elem is a reference. It is not necessary to
7
+ access self.elem through the BoundElement instance. Earlier and later references will
8
+ all be updated as the BoundElement instance is updated.
9
+
10
+ :author: Shay Hill
11
+ :created: 2022-12-09
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from svg_ultralight.bounding_boxes.type_bounding_box import HasBoundingBox
19
+ from svg_ultralight.transformations import new_transformation_matrix, transform_element
20
+
21
+ if TYPE_CHECKING:
22
+ from lxml.etree import _Element as EtreeElement # type: ignore
23
+
24
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
25
+
26
+ _Matrix = tuple[float, float, float, float, float, float]
27
+
28
+
29
+ class BoundElement(HasBoundingBox):
30
+ """An element with a bounding box.
31
+
32
+ Updates the element when x, y, x2, y2, width, or height are set.
33
+
34
+ Can access these BoundingBox attributes (plus scale) as attributes of this object.
35
+ """
36
+
37
+ def __init__(self, element: EtreeElement, bounding_box: BoundingBox) -> None:
38
+ """Initialize a BoundElement instance.
39
+
40
+ :param element: the element to be bound
41
+ :param bounding_box: the bounding box around the element
42
+ """
43
+ self.elem = element
44
+ self.bbox = bounding_box
45
+
46
+ def _update_elem(self):
47
+ self.elem.attrib["transform"] = self.bbox.transform_string
48
+
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