svg-ultralight 0.38.0__py3-none-any.whl → 0.39.1__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.

@@ -12,7 +12,7 @@ There is a getter and setter for each of the four padding values. These *do not*
12
12
  the text element. For instance, if you decrease the left padding, the left margin
13
13
  will move, *not* the text element.
14
14
 
15
- There is a getter and setter for each of lmargin, rmargin, baseline, and capline.
15
+ _There is a getter and setter for each of lmargin, rmargin, baseline, and capline.
16
16
  These *do* move the element, but do not scale it. For instance, if you move the
17
17
  leftmargin to the left, the right margin (and the text element with it) will move to
18
18
  the left.
@@ -66,8 +66,9 @@ from __future__ import annotations
66
66
 
67
67
  from typing import TYPE_CHECKING
68
68
 
69
- from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
69
+ from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
70
70
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
71
+ from svg_ultralight.transformations import new_transformation_matrix, transform_element
71
72
 
72
73
  if TYPE_CHECKING:
73
74
  from lxml.etree import (
@@ -77,7 +78,7 @@ if TYPE_CHECKING:
77
78
  _Matrix = tuple[float, float, float, float, float, float]
78
79
 
79
80
 
80
- class PaddedText(SupportsBounds):
81
+ class PaddedText(BoundElement):
81
82
  """A line of text with a bounding box and padding."""
82
83
 
83
84
  def __init__(
@@ -99,14 +100,14 @@ class PaddedText(SupportsBounds):
99
100
  :param lpad: Left padding.
100
101
  """
101
102
  self.elem = elem
102
- self.bbox = bbox
103
+ self.unpadded_bbox = bbox
103
104
  self.base_tpad = tpad
104
105
  self.rpad = rpad
105
106
  self.base_bpad = bpad
106
107
  self.lpad = lpad
107
108
 
108
109
  @property
109
- def padded_bbox(self) -> BoundingBox:
110
+ def bbox(self) -> BoundingBox:
110
111
  """Return a BoundingBox around the margins and cap/baseline.
111
112
 
112
113
  :return: A BoundingBox around the margins and cap/baseline.
@@ -117,22 +118,27 @@ class PaddedText(SupportsBounds):
117
118
  instance around multiple text elements (a <g> elem).
118
119
  """
119
120
  return BoundingBox(
120
- self.lmargin, self.capline, self.padded_width, self.padded_height
121
+ self.x,
122
+ self.y,
123
+ self.width,
124
+ self.height,
121
125
  )
122
126
 
123
- @property
124
- def transformation(self) -> _Matrix:
125
- """The transformation matrix of the bounding box."""
126
- return self.bbox.transformation
127
+ @bbox.setter
128
+ def bbox(self, value: BoundingBox) -> None:
129
+ """Set the bounding box of this PaddedText.
127
130
 
128
- def _update_elem(self):
129
- self.elem.attrib["transform"] = self.bbox.transform_string
131
+ :param value: The new bounding box.
132
+ :effects: The text element is transformed to fit the new bounding box.
133
+ """
134
+ msg = "Cannot set bbox of PaddedText, use transform() instead."
135
+ raise NotImplementedError(msg)
130
136
 
131
137
  def transform(
132
138
  self,
133
139
  transformation: _Matrix | None = None,
134
140
  *,
135
- scale: float | None = None,
141
+ scale: tuple[float, float] | float | None = None,
136
142
  dx: float | None = None,
137
143
  dy: float | None = None,
138
144
  ):
@@ -143,8 +149,9 @@ class PaddedText(SupportsBounds):
143
149
  :param dx: the x translation
144
150
  :param dy: the y translation
145
151
  """
146
- self.bbox.transform(transformation, scale=scale, dx=dx, dy=dy)
147
- self._update_elem()
152
+ tmat = new_transformation_matrix(transformation, scale=scale, dx=dx, dy=dy)
153
+ self.unpadded_bbox.transform(tmat)
154
+ _ = transform_element(self.elem, tmat)
148
155
 
149
156
  @property
150
157
  def tpad(self) -> float:
@@ -152,7 +159,7 @@ class PaddedText(SupportsBounds):
152
159
 
153
160
  :return: The scaled top padding of this line of text.
154
161
  """
155
- return self.base_tpad * self.bbox.scale
162
+ return self.base_tpad * self.unpadded_bbox.scale[1]
156
163
 
157
164
  @tpad.setter
158
165
  def tpad(self, value: float) -> None:
@@ -160,7 +167,7 @@ class PaddedText(SupportsBounds):
160
167
 
161
168
  :param value: The new top padding.
162
169
  """
163
- self.base_tpad = value / self.bbox.scale
170
+ self.base_tpad = value / self.unpadded_bbox.scale[1]
164
171
 
165
172
  @property
166
173
  def bpad(self) -> float:
@@ -168,7 +175,7 @@ class PaddedText(SupportsBounds):
168
175
 
169
176
  :return: The scaled bottom padding of this line of text.
170
177
  """
171
- return self.base_bpad * self.bbox.scale
178
+ return self.base_bpad * self.unpadded_bbox.scale[1]
172
179
 
173
180
  @bpad.setter
174
181
  def bpad(self, value: float) -> None:
@@ -176,82 +183,18 @@ class PaddedText(SupportsBounds):
176
183
 
177
184
  :param value: The new bottom padding.
178
185
  """
179
- self.base_bpad = value / self.bbox.scale
186
+ self.base_bpad = value / self.unpadded_bbox.scale[1]
180
187
 
181
188
  @property
182
- def lmargin(self) -> float:
183
- """The left margin of this line of text.
184
-
185
- :return: The left margin of this line of text.
186
- """
187
- return self.bbox.x - self.lpad
188
-
189
- @lmargin.setter
190
- def lmargin(self, value: float) -> None:
191
- """Set the left margin of this line of text.
192
-
193
- :param value: The left margin of this line of text.
194
- """
195
- self.transform(dx=value + self.lpad - self.bbox.x)
196
-
197
- @property
198
- def rmargin(self) -> float:
199
- """The right margin of this line of text.
200
-
201
- :return: The right margin of this line of text.
202
- """
203
- return self.bbox.x2 + self.rpad
204
-
205
- @rmargin.setter
206
- def rmargin(self, value: float) -> None:
207
- """Set the right margin of this line of text.
208
-
209
- :param value: The right margin of this line of text.
210
- """
211
- self.transform(dx=value - self.rpad - self.bbox.x2)
212
-
213
- @property
214
- def capline(self) -> float:
215
- """The top of this line of text.
216
-
217
- :return: The top of this line of text.
218
- """
219
- return self.bbox.y - self.tpad
220
-
221
- @capline.setter
222
- def capline(self, value: float) -> None:
223
- """Set the top of this line of text.
224
-
225
- :param value: The top of this line of text.
226
- """
227
- self.transform(dy=value + self.tpad - self.bbox.y)
228
-
229
- @property
230
- def baseline(self) -> float:
231
- """The bottom of this line of text.
232
-
233
- :return: The bottom of this line of text.
234
- """
235
- return self.bbox.y2 + self.bpad
236
-
237
- @baseline.setter
238
- def baseline(self, value: float) -> None:
239
- """Set the bottom of this line of text.
240
-
241
- :param value: The bottom of this line of text.
242
- """
243
- self.transform(dy=value - self.bpad - self.bbox.y2)
244
-
245
- @property
246
- def padded_width(self) -> float:
189
+ def width(self) -> float:
247
190
  """The width of this line of text with padding.
248
191
 
249
192
  :return: The scaled width of this line of text with padding.
250
193
  """
251
- return self.bbox.width + self.lpad + self.rpad
194
+ return self.unpadded_bbox.width + self.lpad + self.rpad
252
195
 
253
- @padded_width.setter
254
- def padded_width(self, width: float) -> None:
196
+ @width.setter
197
+ def width(self, value: float) -> None:
255
198
  """Scale to padded_width = width without scaling padding.
256
199
 
257
200
  :param width: The new width of this line of text.
@@ -263,27 +206,34 @@ class PaddedText(SupportsBounds):
263
206
  baseline is near y2 (y + height) not y. So, we preserve baseline (alter y
264
207
  *and* y2) when scaling.
265
208
  """
266
- baseline = self.baseline
267
- self.bbox.width = width - self.lpad - self.rpad
268
- self.baseline = baseline
269
- self._update_elem()
209
+ y2 = self.y2
210
+
211
+ no_margins_old = self.unpadded_bbox.width
212
+ no_margins_new = value - self.lpad - self.rpad
213
+ scale = no_margins_new / no_margins_old
214
+ self.transform(scale=(scale, scale))
215
+
216
+ self.y2 = y2
270
217
 
271
218
  @property
272
- def padded_height(self) -> float:
219
+ def height(self) -> float:
273
220
  """The height of this line of text with padding.
274
221
 
275
222
  :return: The scaled height of this line of text with padding.
276
223
  """
277
- return self.bbox.height + self.tpad + self.bpad
224
+ return self.unpadded_bbox.height + self.tpad + self.bpad
278
225
 
279
- @padded_height.setter
280
- def padded_height(self, height: float) -> None:
281
- """Scale to padded_height = height without scaling padding.
226
+ @height.setter
227
+ def height(self, value: float) -> None:
228
+ """Scale to height without scaling padding.
282
229
 
283
230
  :param height: The new height of this line of text.
284
231
  :effects: the text_element bounding box is scaled to height - tpad - bpad.
285
232
  """
286
- self.padded_width *= height / self.padded_height
233
+ y2 = self.y2
234
+ scale = value / self.height
235
+ self.transform(scale=(scale, scale))
236
+ self.y2 = y2
287
237
 
288
238
  @property
289
239
  def x(self) -> float:
@@ -291,15 +241,31 @@ class PaddedText(SupportsBounds):
291
241
 
292
242
  :return: The left margin of this line of text.
293
243
  """
294
- return self.lmargin
244
+ return self.unpadded_bbox.x - self.lpad
295
245
 
296
246
  @x.setter
297
247
  def x(self, value: float) -> None:
298
248
  """Set the left margin of this line of text.
299
249
 
300
- :param value: The new left margin of this line of text.
250
+ :param value: The left margin of this line of text.
251
+ """
252
+ self.transform(dx=value + self.lpad - self.unpadded_bbox.x)
253
+
254
+ @property
255
+ def cx(self) -> float:
256
+ """The horizontal center of this line of text.
257
+
258
+ :return: The horizontal center of this line of text.
259
+ """
260
+ return self.x + self.width / 2
261
+
262
+ @cx.setter
263
+ def cx(self, value: float) -> None:
264
+ """Set the horizontal center of this line of text.
265
+
266
+ :param value: The horizontal center of this line of text.
301
267
  """
302
- self.lmargin = value
268
+ self.x += value - self.cx
303
269
 
304
270
  @property
305
271
  def x2(self) -> float:
@@ -307,134 +273,65 @@ class PaddedText(SupportsBounds):
307
273
 
308
274
  :return: The right margin of this line of text.
309
275
  """
310
- return self.rmargin
276
+ return self.unpadded_bbox.x2 + self.rpad
311
277
 
312
278
  @x2.setter
313
279
  def x2(self, value: float) -> None:
314
280
  """Set the right margin of this line of text.
315
281
 
316
- :param value: The new right margin of this line of this text.
282
+ :param value: The right margin of this line of text.
317
283
  """
318
- self.rmargin = value
284
+ self.transform(dx=value - self.rpad - self.unpadded_bbox.x2)
319
285
 
320
286
  @property
321
287
  def y(self) -> float:
322
- """The capline of this line of text.
288
+ """The top of this line of text.
323
289
 
324
- :return: The capline of this line of text.
290
+ :return: The top of this line of text.
325
291
  """
326
- return self.capline
292
+ return self.unpadded_bbox.y - self.tpad
327
293
 
328
294
  @y.setter
329
295
  def y(self, value: float) -> None:
330
- """Set the capline of this line of text.
331
-
332
- :param value: The new capline of this line of text.
333
- """
334
- self.capline = value
335
-
336
- @property
337
- def y2(self) -> float:
338
- """The baseline of this line of text.
339
-
340
- :return: The baseline of this line of text.
341
- """
342
- return self.baseline
343
-
344
- @y2.setter
345
- def y2(self, value: float) -> None:
346
- """Set the baseline of this line of text.
347
-
348
- :param value: The new baseline of this line of text.
349
- """
350
- self.baseline = value
351
-
352
- @property
353
- def width(self) -> float:
354
- """The width of this line of text with padding.
355
-
356
- :return: The scaled width of this line of text with padding.
357
- """
358
- return self.padded_width
359
-
360
- @width.setter
361
- def width(self, value: float) -> None:
362
- """Scale to width without scaling padding.
363
-
364
- :param value: The new width of this line of text.
365
- :effects: the text_element bounding box is scaled to width - lpad - rpad.
366
-
367
- Svg_Ultralight BoundingBoxes preserve x and y when scaling. This is
368
- consistent with how rectangles, viewboxes, and anything else defined by x, y,
369
- width, height behaves in SVG. This is unintuitive for text, because the
370
- baseline is near y2 (y + height) not y. So, we preserve baseline (alter y
371
- *and* y2) when scaling.
372
- """
373
- baseline = self.baseline
374
- self.padded_width = value
375
- self.baseline = baseline
376
-
377
- @property
378
- def height(self) -> float:
379
- """The height of this line of text with padding.
380
-
381
- :return: The scaled height of this line of text with padding.
382
- """
383
- return self.padded_height
384
-
385
- @height.setter
386
- def height(self, value: float) -> None:
387
- """Scale to height without scaling padding.
388
-
389
- :param value: The new height of this line of text.
390
- :effects: the text_element bounding box is scaled to height - tpad - bpad.
391
- """
392
- self.padded_height = value
393
-
394
- @property
395
- def cx(self) -> float:
396
- """The x coordinate of the center between margins.
397
-
398
- :return: the x coordinate of the center between margins
399
- """
400
- return self.lmargin + self.padded_width / 2
401
-
402
- @cx.setter
403
- def cx(self, value: float):
404
- """Set the x coordinate of the center between margins.
296
+ """Set the top of this line of text.
405
297
 
406
- :param value: the new x coordinate of the center between margins
298
+ :param value: The top of this line of text.
407
299
  """
408
- self.lmargin = value - self.padded_width / 2
300
+ self.transform(dy=value + self.tpad - self.unpadded_bbox.y)
409
301
 
410
302
  @property
411
303
  def cy(self) -> float:
412
- """The y coordinate of the center between baseline and capline.
304
+ """The horizontal center of this line of text.
413
305
 
414
- :return: the y coordinate of the center between baseline and capline
306
+ :return: The horizontal center of this line of text.
415
307
  """
416
- return self.capline + self.padded_height / 2
308
+ return self.y + self.height / 2
417
309
 
418
310
  @cy.setter
419
- def cy(self, value: float):
420
- """Set the y coordinate of the center between baseline and capline.
311
+ def cy(self, value: float) -> None:
312
+ """Set the horizontal center of this line of text.
421
313
 
422
- :param value: the new y coordinate of the center between baseline and capline
314
+ :param value: The horizontal center of this line of text.
423
315
  """
424
- self.capline = value - self.padded_height / 2
316
+ self.y += value - self.cy
425
317
 
426
318
  @property
427
- def scale(self) -> float:
428
- """The scale of the text element.
319
+ def y2(self) -> float:
320
+ """The bottom of this line of text.
429
321
 
430
- :return: the scale of the text element
322
+ :return: The bottom of this line of text.
431
323
  """
432
- return self.bbox.scale
324
+ return self.unpadded_bbox.y2 + self.bpad
433
325
 
434
- @scale.setter
435
- def scale(self, value: float):
436
- """Set the scale of the text element.
326
+ @y2.setter
327
+ def y2(self, value: float) -> None:
328
+ """Set the bottom of this line of text.
437
329
 
438
- :param value: the new scale of the text element
330
+ :param value: The bottom of this line of text.
439
331
  """
440
- self.bbox.scale = value
332
+ self.transform(dy=value - self.bpad - self.unpadded_bbox.y2)
333
+
334
+ lmargin = x
335
+ rmargin = x2
336
+ capline = y
337
+ baseline = y2
svg_ultralight/layout.py CHANGED
@@ -144,11 +144,11 @@ def pad_and_scale(
144
144
  """Expand and scale the pad argument. If necessary, scale image.
145
145
 
146
146
  :param viewbox: viewbox to pad (x, y, width height)
147
- :param pad: padding to add around image, in user units or inches. if a
147
+ :param pad: padding to add around image, in user units or inches. If a
148
148
  sequence, it should be (top, right, bottom, left). if a single float or
149
- string, it will be applied to all sides. if two floats, top and bottom
150
- then left and right. if three floats, top, left and right, then bottom.
151
- if four floats, top, right, bottom, left.
149
+ string, it will be applied to all sides. If two floats, top and bottom
150
+ then left and right. If three floats, top, left and right, then bottom.
151
+ If four floats, top, right, bottom, left.
152
152
  :param print_width: width of print area, in user units (float), a string
153
153
  with a unit specifier (e.g., "452mm"), or just a unit specifier (e.g.,
154
154
  "pt")
@@ -169,7 +169,7 @@ def pad_and_scale(
169
169
  If the width and height *are* specified, the user units become whatever they
170
170
  need to be to fit that requirement. For instance, if the viewbox width is 96
171
171
  and the width argument is "1in", then the user units are *still* pixels,
172
- because there are 96 pixels in an inch. If the viewbox with is 2 and the
172
+ because there are 96 pixels in an inch. If the viewbox width is 2 and the
173
173
  width argument is "1in", then the user units are 1/2 of an inch (i.e., 48
174
174
  pixels) each, because there are 2 user units in an inch. If the viewbox
175
175
  width is 3 and the width argument is "1yd", the each user unit is 1 foot.
@@ -195,17 +195,26 @@ def pad_and_scale(
195
195
  the unit designators without changing the scale.
196
196
 
197
197
  Print aspect ratio is ignored. Viewbox aspect ratio is preserved. For
198
- instance, If you take a 100x100 unit image then pass pad="0.25in" and
199
- print_width="12in", the output image will be 12.5 inches across. Whatever
200
- geometry was visible in the original viewbox will be much larger, but the
201
- padding will still be 0.25 inches. If you want to use padding and need a
202
- specific output image size, remember to subtract the padding width from your
203
- print_width or print_height.
198
+ instance, if you created two images
199
+
200
+ * x_=0, y_=0, width_=1, height_=2, pad_="0.25in", print_width_="6in"
201
+
202
+ * x_=0, y_=0, width_=1, height_=2, pad_="0.25in", print_width_="12in"
203
+
204
+ ... (note that the images only vary in print_width_), the first image would be
205
+ rendered at 6.5x12.5 inches and the second at 12.5x24.5 inches. The visible
206
+ content in the viewbox would be exactly twice as wide in the larger image, but
207
+ the padding would remain 0.25 in both images. Despite setting `print_width_` to
208
+ exactly 6 or 12 inches, you would not get an image exactly 6 or 12 inches wide.
209
+ Despite a viewbox aspect ratio of 1:2, you would not get an output image of
210
+ exactly 1:2. If you want to use padding and need a specific output image size or
211
+ aspect ratio, remember to subtract the padding width from your print_width or
212
+ print_height.
204
213
 
205
214
  Scaling attributes are returned as a dictonary that can be "exploded" into
206
215
  the element constructor, e.g., {"width": "12.5in", "height": "12.5in"}.
207
216
 
208
- * If neighther a print_width nor print_height is specified, no scaling
217
+ * If neither a print_width nor print_height is specified, no scaling
209
218
  attributes will be returned.
210
219
 
211
220
  * If either is specified, both a width and height will be returned (even if
@@ -242,7 +251,7 @@ def pad_and_scale(
242
251
  a 16" x 9" image with viwebox(0, 0, 14, 7), pad_="1in", print_width_="14in"
243
252
  ... then scale the printout with dpu_=2 to get a 32" x 18" image with the
244
253
  same viewbox. This means the padding will be 2" on all sides, but the image
245
- will be identical (just twice as large) as the 16" x 9" image.
254
+ will be identical (just twice as wide and twice as high) as the 16" x 9" image.
246
255
  """
247
256
  pads = expand_pad_arg(pad)
248
257
 
svg_ultralight/query.py CHANGED
@@ -63,7 +63,7 @@ def _fill_ids(*elem_args: EtreeElement) -> None:
63
63
 
64
64
 
65
65
  def _normalize_views(elem: EtreeElement) -> None:
66
- """Create a square viewbox for any element with an svg tag.
66
+ """Create a square viewBox for any element with an svg tag.
67
67
 
68
68
  :param elem: an etree element
69
69
 
@@ -110,51 +110,61 @@ def map_elems_to_bounding_boxes(
110
110
  IMPORTANT: path cannot end with ``.exe``.
111
111
  Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
112
112
  :param elem_args: xml element (written to a temporary file then queried)
113
- :return: svg elements (and a bounding box for the entire svg file as ``svg``)
114
- mapped to BoundingBox(x, y, width, height)
115
- :effects: temporarily adds an id attribute if any ids are missing. Non-unique ids
116
- will break this function.
117
-
118
- Bounding boxes are relative to svg viewbox. If viewbox x == -10,
113
+ :return: input svg elements and any descendents of those elements mapped
114
+ `BoundingBox(x, y, width, height)`
115
+ So return dict keys are the input elements themselves with one exception: a
116
+ string key, "svg", is mapped to a bounding box around all input elements.
117
+ :effects: temporarily adds an id attribute if any ids are missing. These are
118
+ removed if the function completes. Existing, non-unique ids will break this
119
+ function.
120
+
121
+ Bounding boxes are relative to svg viewBox. If, for instance, viewBox x == -10,
119
122
  all bounding-box x values will be offset -10. So, everything is wrapped in a root
120
- element with a "normalized" viewbox, (viewbox=(0, 0, 1, 1)) then any child root
121
- elements ("child root elements" sounds wrong, but it works) viewboxes are
122
- normalized as well. This works even with a root element around a root element, so
123
- input elem_args can be root elements or "normal" elements like "rect", "circle",
124
- or "text" or a mixture of both.
123
+ element, `envelope` with a "normalized" viewBox, `viewBox=(0, 0, 1, 1)`. That
124
+ way, any child root elements ("child root elements" sounds wrong, but it works)
125
+ viewBoxes are normalized as well. This works even with a root element around a
126
+ root element, so input elem_args can be root elements or "normal" elements like
127
+ "rect", "circle", or "text" or a mixture of both. Bounding boxes output here will
128
+ work as expected in any viewBox.
125
129
 
126
130
  The ``inkscape --query-all svg`` call will return a tuple:
127
131
 
128
132
  (b'svg1,x,y,width,height\\r\\elem1,x,y,width,height\\r\\n', None)
129
133
  where x, y, width, and height are strings of numbers.
130
134
 
131
- This calls the command and formats the output into a dictionary.
132
-
133
- Scaling arguments ("width", "height") to new_svg_root transform the bounding
134
- boxes in non-useful ways. This copies all elements except the root element in to
135
- a (0, 0, 1, 1) root. This will put the boxes where you'd expect them to be, no
136
- matter what root you use.
135
+ This calls the command and formats the output into a dictionary. There is a
136
+ little extra complexity to handle cases with duplicate elements. Inkscape will
137
+ map bounding boxes to element ids *if* those ids are unique. If Inkscape
138
+ encounters a duplicate ID, Inkscape will map the bounding box of that element to
139
+ a string like "rect1". If you pass unequal elements with the same id, I can't
140
+ help you, but you might pass the same element multiple times. If you do this,
141
+ Inkscape will find a bounding box for each occurrence, map the first occurrence
142
+ to the id, then map subsequent occurrences to a string like "rect1". This
143
+ function will handle that.
137
144
  """
138
145
  if not elem_args:
139
146
  return {}
140
147
  _fill_ids(*elem_args)
141
- envelope = _envelop_copies(*elem_args)
142
148
 
149
+ envelope = _envelop_copies(*elem_args)
143
150
  with NamedTemporaryFile(mode="wb", delete=False, suffix=".svg") as svg_file:
144
151
  svg = write_svg(svg_file, envelope)
145
152
  with Popen(f'"{inkscape}" --query-all {svg}', stdout=PIPE) as bb_process:
146
153
  bb_data = str(bb_process.communicate()[0])[2:-1]
147
154
  os.unlink(svg_file.name)
155
+
148
156
  bb_strings = re.split(r"[\\r]*\\n", bb_data)[:-1]
149
157
  id2bbox = dict(map(_split_bb_string, bb_strings))
150
158
 
151
159
  elem2bbox: dict[EtreeElement | Literal["svg"], BoundingBox] = {}
152
160
  for elem in _iter_elems(*elem_args):
153
- elem2bbox[elem] = id2bbox.pop(elem.attrib["id"])
154
- if elem.attrib["id"].startswith(_TEMP_ID_PREFIX):
161
+ elem_id = elem.attrib.get("id")
162
+ if not (elem_id): # id removed in a previous loop
163
+ continue
164
+ elem2bbox[elem] = id2bbox[elem_id]
165
+ if elem_id.startswith(_TEMP_ID_PREFIX):
155
166
  del elem.attrib["id"]
156
- ((_, scene_bbox),) = id2bbox.items()
157
- elem2bbox["svg"] = scene_bbox
167
+ elem2bbox["svg"] = BoundingBox.merged(*id2bbox.values())
158
168
  return elem2bbox
159
169
 
160
170
 
@@ -52,13 +52,19 @@ def mat_dot(mat1: _Matrix, mat2: _Matrix) -> _Matrix:
52
52
  return (aa, bb, cc, dd, ee, ff)
53
53
 
54
54
 
55
- def mat_apply(mat1: _Matrix, mat2: tuple[float, float]) -> tuple[float, float]:
55
+ def mat_apply(matrix: _Matrix, point: tuple[float, float]) -> tuple[float, float]:
56
56
  """Apply an svg-style transformation matrix to a point.
57
57
 
58
- :param mat1: transformation matrix (sx, 0, 0, sy, tx, ty)
58
+ :param mat1: transformation matrix (a, b, c, d, e, f) describing a 3x3 matrix
59
+ with an implied third row of (0, 0, 1)
60
+ [[a, c, e], [b, d, f], [0, 0, 1]]
59
61
  :param mat2: point (x, y)
60
62
  """
61
- return mat1[0] * mat2[0] + mat1[4], mat1[3] * mat2[1] + mat1[5]
63
+ a, b, c, d, e, f = matrix
64
+ x, y = point
65
+ result_x = a * x + c * y + e
66
+ result_y = b * x + d * y + f
67
+ return result_x, result_y
62
68
 
63
69
 
64
70
  def mat_invert(tmat: _Matrix) -> _Matrix:
@@ -99,7 +105,7 @@ def get_transform_matrix(elem: EtreeElement) -> _Matrix:
99
105
  def new_transformation_matrix(
100
106
  transformation: _Matrix | None = None,
101
107
  *,
102
- scale: float | None = None,
108
+ scale: tuple[float, float] | float | None = None,
103
109
  dx: float | None = None,
104
110
  dy: float | None = None,
105
111
  ) -> _Matrix:
@@ -109,10 +115,17 @@ def new_transformation_matrix(
109
115
  svg-style transformation matrix.
110
116
  """
111
117
  transformation = transformation or (1, 0, 0, 1, 0, 0)
112
- scale = scale or 1
118
+
119
+ if isinstance(scale, float):
120
+ scale_x, scale_y = (scale, scale)
121
+ elif scale is None:
122
+ scale_x, scale_y = (1, 1)
123
+ else:
124
+ scale_x, scale_y = cast("tuple[float, float]", scale)
125
+
113
126
  dx = dx or 0
114
127
  dy = dy or 0
115
- return mat_dot((scale, 0, 0, scale, dx, dy), transformation)
128
+ return mat_dot((scale_x, 0, 0, scale_y, dx, dy), transformation)
116
129
 
117
130
 
118
131
  def transform_element(elem: EtreeElement, matrix: _Matrix) -> EtreeElement: