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

Files changed (37) hide show
  1. svg_ultralight/__init__.py +108 -105
  2. svg_ultralight/animate.py +40 -40
  3. svg_ultralight/attrib_hints.py +13 -14
  4. svg_ultralight/bounding_boxes/__init__.py +5 -5
  5. svg_ultralight/bounding_boxes/bound_helpers.py +189 -201
  6. svg_ultralight/bounding_boxes/padded_text_initializers.py +207 -206
  7. svg_ultralight/bounding_boxes/supports_bounds.py +166 -166
  8. svg_ultralight/bounding_boxes/type_bound_collection.py +71 -71
  9. svg_ultralight/bounding_boxes/type_bound_element.py +65 -65
  10. svg_ultralight/bounding_boxes/type_bounding_box.py +396 -396
  11. svg_ultralight/bounding_boxes/type_padded_text.py +411 -411
  12. svg_ultralight/constructors/__init__.py +14 -14
  13. svg_ultralight/constructors/new_element.py +115 -115
  14. svg_ultralight/font_tools/__init__.py +5 -5
  15. svg_ultralight/font_tools/comp_results.py +295 -293
  16. svg_ultralight/font_tools/font_info.py +793 -784
  17. svg_ultralight/image_ops.py +156 -156
  18. svg_ultralight/inkscape.py +261 -261
  19. svg_ultralight/layout.py +290 -291
  20. svg_ultralight/main.py +183 -198
  21. svg_ultralight/metadata.py +122 -122
  22. svg_ultralight/nsmap.py +36 -36
  23. svg_ultralight/py.typed +5 -0
  24. svg_ultralight/query.py +254 -249
  25. svg_ultralight/read_svg.py +58 -0
  26. svg_ultralight/root_elements.py +87 -87
  27. svg_ultralight/string_conversion.py +244 -244
  28. svg_ultralight/strings/__init__.py +21 -13
  29. svg_ultralight/strings/svg_strings.py +106 -67
  30. svg_ultralight/transformations.py +140 -141
  31. svg_ultralight/unit_conversion.py +247 -248
  32. {svg_ultralight-0.47.0.dist-info → svg_ultralight-0.50.1.dist-info}/METADATA +208 -214
  33. svg_ultralight-0.50.1.dist-info/RECORD +34 -0
  34. svg_ultralight-0.50.1.dist-info/WHEEL +4 -0
  35. svg_ultralight-0.47.0.dist-info/RECORD +0 -34
  36. svg_ultralight-0.47.0.dist-info/WHEEL +0 -5
  37. svg_ultralight-0.47.0.dist-info/top_level.txt +0 -1
svg_ultralight/nsmap.py CHANGED
@@ -1,36 +1,36 @@
1
- """xml namespace entries for svg files.
2
-
3
- :author: Shay Hill
4
- :created: 1/14/2021
5
-
6
- I started by copying out entries from Inkscape output. Added more as I found them
7
- necessary. This is a pretty robust list. Can be pared down as documented at
8
- https://shayallenhill.com/svg-with-css-in-python/
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from lxml.etree import QName
14
-
15
- _SVG_NAMESPACE = "http://www.w3.org/2000/svg"
16
- NSMAP = {
17
- None: _SVG_NAMESPACE,
18
- "dc": "http://purl.org/dc/elements/1.1/",
19
- "cc": "http://creativecommons.org/ns#",
20
- "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
21
- "svg": _SVG_NAMESPACE,
22
- "xlink": "http://www.w3.org/1999/xlink",
23
- "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
24
- "inkscape": "http://www.inkscape.org/namespaces/inkscape",
25
- }
26
-
27
-
28
- def new_qname(namespace_abbreviation: str | None, tag: str) -> QName:
29
- """Create a qualified name for an svg element.
30
-
31
- :param namespace_abbreviation: The namespace abbreviation. This
32
- will have to be a key in NSMAP (e.g., "dc", "cc", "rdf").
33
- :param tag: The tag name of the element.
34
- :return: A qualified name for the element.
35
- """
36
- return QName(NSMAP[namespace_abbreviation], tag)
1
+ """xml namespace entries for svg files.
2
+
3
+ :author: Shay Hill
4
+ :created: 1/14/2021
5
+
6
+ I started by copying out entries from Inkscape output. Added more as I found them
7
+ necessary. This is a pretty robust list. Can be pared down as documented at
8
+ https://shayallenhill.com/svg-with-css-in-python/
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from lxml.etree import QName
14
+
15
+ _SVG_NAMESPACE = "http://www.w3.org/2000/svg"
16
+ NSMAP = {
17
+ None: _SVG_NAMESPACE,
18
+ "dc": "http://purl.org/dc/elements/1.1/",
19
+ "cc": "http://creativecommons.org/ns#",
20
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
21
+ "svg": _SVG_NAMESPACE,
22
+ "xlink": "http://www.w3.org/1999/xlink",
23
+ "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
24
+ "inkscape": "http://www.inkscape.org/namespaces/inkscape",
25
+ }
26
+
27
+
28
+ def new_qname(namespace_abbreviation: str | None, tag: str) -> QName:
29
+ """Create a qualified name for an svg element.
30
+
31
+ :param namespace_abbreviation: The namespace abbreviation. This
32
+ will have to be a key in NSMAP (e.g., "dc", "cc", "rdf").
33
+ :param tag: The tag name of the element.
34
+ :return: A qualified name for the element.
35
+ """
36
+ return QName(NSMAP[namespace_abbreviation], tag)
svg_ultralight/py.typed CHANGED
@@ -0,0 +1,5 @@
1
+ """ This file is used to indicate to mypy that the package is typed.
2
+
3
+ Do not delete this comment, because empty files choke
4
+ some cloud drives on sync.
5
+ """
svg_ultralight/query.py CHANGED
@@ -1,249 +1,254 @@
1
- """Query an SVG file for bounding boxes.
2
-
3
- :author: Shay Hill
4
- :created: 7/25/2020
5
-
6
- Bounding boxes are generated with a command-line call to Inkscape, so an Inkscape
7
- installation is required for this to work. The bounding boxes are returned as
8
- BoundingBox instances, which are a big help with aligning objects (e.g., text on a
9
- business card). Getting bounding boxes from Inkscape is not exceptionally fast.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import hashlib
15
- import os
16
- import pickle
17
- import re
18
- import uuid
19
- from contextlib import suppress
20
- from copy import deepcopy
21
- from pathlib import Path
22
- from subprocess import PIPE, Popen
23
- from tempfile import NamedTemporaryFile, TemporaryFile
24
- from typing import TYPE_CHECKING, Literal
25
- from warnings import warn
26
-
27
- from lxml import etree
28
-
29
- from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
30
- from svg_ultralight.main import new_svg_root, write_svg
31
-
32
- if TYPE_CHECKING:
33
- from collections.abc import Iterator
34
-
35
- from lxml.etree import (
36
- _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
37
- )
38
-
39
-
40
- with TemporaryFile() as f:
41
- _CACHE_DIR = Path(f.name).parent / "svg_ultralight_cache"
42
-
43
- _CACHE_DIR.mkdir(exist_ok=True)
44
-
45
- _TEMP_ID_PREFIX = "svg_ultralight-temp_query_module-"
46
-
47
-
48
- def _iter_elems(*elem_args: EtreeElement) -> Iterator[EtreeElement]:
49
- """Yield element and sub-elements."""
50
- for elem in elem_args:
51
- yield from elem.iter()
52
-
53
-
54
- def _fill_ids(*elem_args: EtreeElement) -> None:
55
- """Set the id attribute of an element and all its children. Keep existing ids.
56
-
57
- :param elem: an etree element, accepts multiple arguments
58
- """
59
- for elem in _iter_elems(*elem_args):
60
- if elem.get("id") is None:
61
- elem.set("id", f"{_TEMP_ID_PREFIX}-{uuid.uuid4()}")
62
-
63
-
64
- def _normalize_views(elem: EtreeElement) -> None:
65
- """Create a square viewBox for any element with an svg tag.
66
-
67
- :param elem: an etree element
68
-
69
- This prevents the bounding boxes from being distorted. Only do this to copies,
70
- because there's no way to undo it.
71
- """
72
- for child in elem:
73
- _normalize_views(child)
74
- if str(elem.tag).endswith("svg"):
75
- elem.set("viewBox", "0 0 1 1")
76
- elem.set("width", "1")
77
- elem.set("height", "1")
78
-
79
-
80
- def _envelop_copies(*elem_args: EtreeElement) -> EtreeElement:
81
- """Create an svg root element enveloping all elem_args.
82
-
83
- :param elem_args: one or more etree elements
84
- :return: an etree element enveloping copies of elem_args with all views normalized
85
- """
86
- envelope = new_svg_root(0, 0, 1, 1)
87
- envelope.extend([deepcopy(e) for e in elem_args])
88
- _normalize_views(envelope)
89
- return envelope
90
-
91
-
92
- def _split_bb_string(bb_string: str) -> tuple[str, BoundingBox]:
93
- """Split a bounding box string into id and BoundingBox instance.
94
-
95
- :param bb_string: "id,x,y,width,height"
96
- :return: (id, BoundingBox(x, y, width, height))
97
- """
98
- id_, *bounds = bb_string.split(",")
99
- x, y, width, height = (float(x) for x in bounds)
100
- return id_, BoundingBox(x, y, width, height)
101
-
102
-
103
- def map_elems_to_bounding_boxes(
104
- inkscape: str | os.PathLike[str], *elem_args: EtreeElement
105
- ) -> dict[EtreeElement | Literal["svg"], BoundingBox]:
106
- r"""Query an svg file for bounding-box dimensions.
107
-
108
- :param inkscape: path to an inkscape executable on your local file system
109
- IMPORTANT: path cannot end with ``.exe``.
110
- Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
111
- :param elem_args: xml element (written to a temporary file then queried)
112
- :return: input svg elements and any descendents of those elements mapped
113
- `BoundingBox(x, y, width, height)`
114
- So return dict keys are the input elements themselves with one exception: a
115
- string key, "svg", is mapped to a bounding box around all input elements.
116
- :effects: temporarily adds an id attribute if any ids are missing. These are
117
- removed if the function completes. Existing, non-unique ids will break this
118
- function.
119
-
120
- Bounding boxes are relative to svg viewBox. If, for instance, viewBox x == -10,
121
- all bounding-box x values will be offset -10. So, everything is wrapped in a root
122
- element, `envelope` with a "normalized" viewBox, `viewBox=(0, 0, 1, 1)`. That
123
- way, any child root elements ("child root elements" sounds wrong, but it works)
124
- viewBoxes are normalized as well. This works even with a root element around a
125
- root element, so input elem_args can be root elements or "normal" elements like
126
- "rect", "circle", or "text" or a mixture of both. Bounding boxes output here will
127
- work as expected in any viewBox.
128
-
129
- The ``inkscape --query-all svg`` call will return a tuple:
130
-
131
- (b'svg1,x,y,width,height\\r\\elem1,x,y,width,height\\r\\n', None)
132
- where x, y, width, and height are strings of numbers.
133
-
134
- This calls the command and formats the output into a dictionary. There is a
135
- little extra complexity to handle cases with duplicate elements. Inkscape will
136
- map bounding boxes to element ids *if* those ids are unique. If Inkscape
137
- encounters a duplicate ID, Inkscape will map the bounding box of that element to
138
- a string like "rect1". If you pass unequal elements with the same id, I can't
139
- help you, but you might pass the same element multiple times. If you do this,
140
- Inkscape will find a bounding box for each occurrence, map the first occurrence
141
- to the id, then map subsequent occurrences to a string like "rect1". This
142
- function will handle that.
143
- """
144
- if not elem_args:
145
- return {}
146
- _fill_ids(*elem_args)
147
-
148
- envelope = _envelop_copies(*elem_args)
149
- with NamedTemporaryFile(mode="wb", delete=False, suffix=".svg") as svg_file:
150
- svg = write_svg(svg_file, envelope)
151
- with Popen(f'"{inkscape}" --query-all {svg}', stdout=PIPE) as bb_process:
152
- bb_data = str(bb_process.communicate()[0])[2:-1]
153
- os.unlink(svg_file.name)
154
-
155
- bb_strings = re.split(r"[\\r]*\\n", bb_data)[:-1]
156
- id2bbox = dict(map(_split_bb_string, bb_strings))
157
-
158
- elem2bbox: dict[EtreeElement | Literal["svg"], BoundingBox] = {}
159
- for elem in _iter_elems(*elem_args):
160
- elem_id = elem.attrib.get("id")
161
- if not (elem_id): # id removed in a previous loop
162
- continue
163
- with suppress(KeyError):
164
- # some elems like <style> don't have a bounding box
165
- elem2bbox[elem] = id2bbox[elem_id]
166
- if elem_id.startswith(_TEMP_ID_PREFIX):
167
- del elem.attrib["id"]
168
- elem2bbox["svg"] = BoundingBox.merged(*id2bbox.values())
169
- return elem2bbox
170
-
171
-
172
- def _hash_elem(elem: EtreeElement) -> str:
173
- """Hash an EtreeElement.
174
-
175
- Will match identical (excepting id) elements.
176
- """
177
- elem_copy = deepcopy(elem)
178
- with suppress(KeyError):
179
- _ = elem_copy.attrib.pop("id")
180
- hash_object = hashlib.sha256(etree.tostring(elem_copy))
181
- return hash_object.hexdigest()
182
-
183
-
184
- def _try_bbox_cache(elem_hash: str) -> BoundingBox | None:
185
- """Try to load a cached bounding box."""
186
- cache_path = _CACHE_DIR / elem_hash
187
- if not cache_path.exists():
188
- return None
189
- try:
190
- with cache_path.open("rb") as f:
191
- return pickle.load(f)
192
- except (EOFError, pickle.UnpicklingError) as e:
193
- msg = f"Error loading cache file {cache_path}: {e}"
194
- warn(msg)
195
- except Exception as e:
196
- msg = f"Unexpected error loading cache file {cache_path}: {e}"
197
- warn(msg)
198
- return None
199
-
200
-
201
- def get_bounding_boxes(
202
- inkscape: str | os.PathLike[str], *elem_args: EtreeElement
203
- ) -> tuple[BoundingBox, ...]:
204
- r"""Get bounding box around a single element (or multiple elements).
205
-
206
- :param inkscape: path to an inkscape executable on your local file system
207
- IMPORTANT: path cannot end with ``.exe``.
208
- Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
209
- :param elem_args: xml elements
210
- :return: a BoundingBox instance around a each elem_arg
211
-
212
- This will work most of the time, but if you're missing an nsmap, you'll need to
213
- create an entire xml file with a custom nsmap (using
214
- `svg_ultralight.new_svg_root`) then call `map_elems_to_bounding_boxes` directly.
215
- """
216
- elem2hash = {elem: _hash_elem(elem) for elem in elem_args}
217
- cached = [_try_bbox_cache(h) for h in elem2hash.values()]
218
- if None not in cached:
219
- return tuple(filter(None, cached))
220
-
221
- hash2bbox = {h: c for h, c in zip(elem2hash.values(), cached) if c is not None}
222
- remainder = [e for e, c in zip(elem_args, cached) if c is None]
223
- id2bbox = map_elems_to_bounding_boxes(inkscape, *remainder)
224
- for elem in remainder:
225
- hash_ = elem2hash[elem]
226
- hash2bbox[hash_] = id2bbox[elem]
227
- with (_CACHE_DIR / hash_).open("wb") as f:
228
- pickle.dump(hash2bbox[hash_], f)
229
- return tuple(hash2bbox[h] for h in elem2hash.values())
230
-
231
-
232
- def get_bounding_box(
233
- inkscape: str | os.PathLike[str], elem: EtreeElement
234
- ) -> BoundingBox:
235
- r"""Get bounding box around a single element.
236
-
237
- :param inkscape: path to an inkscape executable on your local file system
238
- IMPORTANT: path cannot end with ``.exe``.
239
- Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
240
- :param elem: xml element
241
- :return: a BoundingBox instance around a single elem
242
- """
243
- return get_bounding_boxes(inkscape, elem)[0]
244
-
245
-
246
- def clear_svg_ultralight_cache() -> None:
247
- """Clear all cached bounding boxes."""
248
- for cache_file in _CACHE_DIR.glob("*"):
249
- cache_file.unlink()
1
+ """Query an SVG file for bounding boxes.
2
+
3
+ :author: Shay Hill
4
+ :created: 7/25/2020
5
+
6
+ Bounding boxes are generated with a command-line call to Inkscape, so an Inkscape
7
+ installation is required for this to work. The bounding boxes are returned as
8
+ BoundingBox instances, which are a big help with aligning objects (e.g., text on a
9
+ business card). Getting bounding boxes from Inkscape is not exceptionally fast.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+ import pickle
17
+ import re
18
+ import uuid
19
+ from contextlib import suppress
20
+ from copy import deepcopy
21
+ from pathlib import Path
22
+ from subprocess import PIPE, Popen
23
+ from tempfile import NamedTemporaryFile, TemporaryFile
24
+ from typing import TYPE_CHECKING, Literal
25
+ from warnings import warn
26
+
27
+ from lxml import etree
28
+ from lxml.etree import _Comment as EtreeComment # pyright: ignore[reportPrivateUsage]
29
+
30
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
31
+ from svg_ultralight.main import new_svg_root, write_svg
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Iterator
35
+
36
+ from lxml.etree import (
37
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
38
+ )
39
+
40
+
41
+ with TemporaryFile() as f:
42
+ _CACHE_DIR = Path(f.name).parent / "svg_ultralight_cache"
43
+
44
+ _CACHE_DIR.mkdir(exist_ok=True)
45
+
46
+ _TEMP_ID_PREFIX = "svg_ultralight-temp_query_module-"
47
+
48
+
49
+ def _iter_elems(*elem_args: EtreeElement) -> Iterator[EtreeElement]:
50
+ """Yield element and sub-elements."""
51
+ for elem in elem_args:
52
+ yield from elem.iter()
53
+
54
+
55
+ def _fill_ids(*elem_args: EtreeElement) -> None:
56
+ """Set the id attribute of an element and all its children. Keep existing ids.
57
+
58
+ :param elem: an etree element, accepts multiple arguments
59
+ """
60
+ for elem in _iter_elems(*elem_args):
61
+ if isinstance(elem, EtreeComment):
62
+ continue
63
+ if elem.get("id") is None:
64
+ elem.set("id", f"{_TEMP_ID_PREFIX}-{uuid.uuid4()}")
65
+
66
+
67
+ def _normalize_views(elem: EtreeElement) -> None:
68
+ """Create a square viewBox for any element with an svg tag.
69
+
70
+ :param elem: an etree element
71
+
72
+ This prevents the bounding boxes from being distorted. Only do this to copies,
73
+ because there's no way to undo it.
74
+ """
75
+ for child in elem:
76
+ _normalize_views(child)
77
+ if str(elem.tag).endswith("svg"):
78
+ elem.set("viewBox", "0 0 1 1")
79
+ elem.set("width", "1")
80
+ elem.set("height", "1")
81
+
82
+
83
+ def _envelop_copies(*elem_args: EtreeElement) -> EtreeElement:
84
+ """Create an svg root element enveloping all elem_args.
85
+
86
+ :param elem_args: one or more etree elements
87
+ :return: an etree element enveloping copies of elem_args with all views normalized
88
+ """
89
+ envelope = new_svg_root(0, 0, 1, 1)
90
+ envelope.extend([deepcopy(e) for e in elem_args])
91
+ _normalize_views(envelope)
92
+ return envelope
93
+
94
+
95
+ def _split_bb_string(bb_string: str) -> tuple[str, BoundingBox]:
96
+ """Split a bounding box string into id and BoundingBox instance.
97
+
98
+ :param bb_string: "id,x,y,width,height"
99
+ :return: (id, BoundingBox(x, y, width, height))
100
+ """
101
+ id_, *bounds = bb_string.split(",")
102
+ x, y, width, height = (float(x) for x in bounds)
103
+ return id_, BoundingBox(x, y, width, height)
104
+
105
+
106
+ def map_elems_to_bounding_boxes(
107
+ inkscape: str | os.PathLike[str], *elem_args: EtreeElement
108
+ ) -> dict[EtreeElement | Literal["svg"], BoundingBox]:
109
+ r"""Query an svg file for bounding-box dimensions.
110
+
111
+ :param inkscape: path to an inkscape executable on your local file system
112
+ IMPORTANT: path cannot end with ``.exe``.
113
+ Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
114
+ :param elem_args: xml element (written to a temporary file then queried)
115
+ :return: input svg elements and any descendents of those elements mapped
116
+ `BoundingBox(x, y, width, height)`
117
+ So return dict keys are the input elements themselves with one exception: a
118
+ string key, "svg", is mapped to a bounding box around all input elements.
119
+ :effects: temporarily adds an id attribute if any ids are missing. These are
120
+ removed if the function completes. Existing, non-unique ids will break this
121
+ function.
122
+
123
+ Bounding boxes are relative to svg viewBox. If, for instance, viewBox x == -10,
124
+ all bounding-box x values will be offset -10. So, everything is wrapped in a root
125
+ element, `envelope` with a "normalized" viewBox, `viewBox=(0, 0, 1, 1)`. That
126
+ way, any child root elements ("child root elements" sounds wrong, but it works)
127
+ viewBoxes are normalized as well. This works even with a root element around a
128
+ root element, so input elem_args can be root elements or "normal" elements like
129
+ "rect", "circle", or "text" or a mixture of both. Bounding boxes output here will
130
+ work as expected in any viewBox.
131
+
132
+ The ``inkscape --query-all svg`` call will return a tuple:
133
+
134
+ (b'svg1,x,y,width,height\\r\\elem1,x,y,width,height\\r\\n', None)
135
+ where x, y, width, and height are strings of numbers.
136
+
137
+ This calls the command and formats the output into a dictionary. There is a
138
+ little extra complexity to handle cases with duplicate elements. Inkscape will
139
+ map bounding boxes to element ids *if* those ids are unique. If Inkscape
140
+ encounters a duplicate ID, Inkscape will map the bounding box of that element to
141
+ a string like "rect1". If you pass unequal elements with the same id, I can't
142
+ help you, but you might pass the same element multiple times. If you do this,
143
+ Inkscape will find a bounding box for each occurrence, map the first occurrence
144
+ to the id, then map subsequent occurrences to a string like "rect1". This
145
+ function will handle that.
146
+ """
147
+ if not elem_args:
148
+ return {}
149
+ _fill_ids(*elem_args)
150
+
151
+ envelope = _envelop_copies(*elem_args)
152
+ with NamedTemporaryFile(mode="wb", delete=False, suffix=".svg") as svg_file:
153
+ svg = write_svg(svg_file, envelope)
154
+ with Popen(f'"{inkscape}" --query-all {svg}', stdout=PIPE) as bb_process:
155
+ bb_data = str(bb_process.communicate()[0])[2:-1]
156
+ os.unlink(svg_file.name)
157
+
158
+ bb_strings = re.split(r"[\\r]*\\n", bb_data)[:-1]
159
+ id2bbox = dict(map(_split_bb_string, bb_strings))
160
+
161
+ elem2bbox: dict[EtreeElement | Literal["svg"], BoundingBox] = {}
162
+ for elem in _iter_elems(*elem_args):
163
+ elem_id = elem.attrib.get("id")
164
+ if not (elem_id): # id removed in a previous loop
165
+ continue
166
+ with suppress(KeyError):
167
+ # some elems like <style> don't have a bounding box
168
+ elem2bbox[elem] = id2bbox[elem_id]
169
+ if elem_id.startswith(_TEMP_ID_PREFIX):
170
+ del elem.attrib["id"]
171
+ elem2bbox["svg"] = BoundingBox.merged(*id2bbox.values())
172
+ return elem2bbox
173
+
174
+
175
+ def _hash_elem(elem: EtreeElement) -> str:
176
+ """Hash an EtreeElement.
177
+
178
+ Will match identical (excepting id) elements.
179
+ """
180
+ elem_copy = deepcopy(elem)
181
+ with suppress(KeyError):
182
+ _ = elem_copy.attrib.pop("id")
183
+ hash_object = hashlib.sha256(etree.tostring(elem_copy))
184
+ return hash_object.hexdigest()
185
+
186
+
187
+ def _try_bbox_cache(elem_hash: str) -> BoundingBox | None:
188
+ """Try to load a cached bounding box."""
189
+ cache_path = _CACHE_DIR / elem_hash
190
+ if not cache_path.exists():
191
+ return None
192
+ try:
193
+ with cache_path.open("rb") as f:
194
+ return pickle.load(f)
195
+ except (EOFError, pickle.UnpicklingError) as e:
196
+ msg = f"Error loading cache file {cache_path}: {e}"
197
+ warn(msg, stacklevel=2)
198
+ except Exception as e:
199
+ msg = f"Unexpected error loading cache file {cache_path}: {e}"
200
+ warn(msg, stacklevel=2)
201
+ return None
202
+
203
+
204
+ def get_bounding_boxes(
205
+ inkscape: str | os.PathLike[str], *elem_args: EtreeElement
206
+ ) -> tuple[BoundingBox, ...]:
207
+ r"""Get bounding box around a single element (or multiple elements).
208
+
209
+ :param inkscape: path to an inkscape executable on your local file system
210
+ IMPORTANT: path cannot end with ``.exe``.
211
+ Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
212
+ :param elem_args: xml elements
213
+ :return: a BoundingBox instance around a each elem_arg
214
+
215
+ This will work most of the time, but if you're missing an nsmap, you'll need to
216
+ create an entire xml file with a custom nsmap (using
217
+ `svg_ultralight.new_svg_root`) then call `map_elems_to_bounding_boxes` directly.
218
+ """
219
+ elem2hash = {elem: _hash_elem(elem) for elem in elem_args}
220
+ cached = [_try_bbox_cache(h) for h in elem2hash.values()]
221
+ if None not in cached:
222
+ return tuple(filter(None, cached))
223
+
224
+ hash2bbox = {
225
+ h: c for h, c in zip(elem2hash.values(), cached, strict=True) if c is not None
226
+ }
227
+ remainder = [e for e, c in zip(elem_args, cached, strict=True) if c is None]
228
+ id2bbox = map_elems_to_bounding_boxes(inkscape, *remainder)
229
+ for elem in remainder:
230
+ hash_ = elem2hash[elem]
231
+ hash2bbox[hash_] = id2bbox[elem]
232
+ with (_CACHE_DIR / hash_).open("wb") as f:
233
+ pickle.dump(hash2bbox[hash_], f)
234
+ return tuple(hash2bbox[h] for h in elem2hash.values())
235
+
236
+
237
+ def get_bounding_box(
238
+ inkscape: str | os.PathLike[str], elem: EtreeElement
239
+ ) -> BoundingBox:
240
+ r"""Get bounding box around a single element.
241
+
242
+ :param inkscape: path to an inkscape executable on your local file system
243
+ IMPORTANT: path cannot end with ``.exe``.
244
+ Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
245
+ :param elem: xml element
246
+ :return: a BoundingBox instance around a single elem
247
+ """
248
+ return get_bounding_boxes(inkscape, elem)[0]
249
+
250
+
251
+ def clear_svg_ultralight_cache() -> None:
252
+ """Clear all cached bounding boxes."""
253
+ for cache_file in _CACHE_DIR.glob("*"):
254
+ cache_file.unlink()