svg-ultralight 0.64.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. svg_ultralight/__init__.py +112 -0
  2. svg_ultralight/animate.py +40 -0
  3. svg_ultralight/attrib_hints.py +14 -0
  4. svg_ultralight/bounding_boxes/__init__.py +5 -0
  5. svg_ultralight/bounding_boxes/bound_helpers.py +200 -0
  6. svg_ultralight/bounding_boxes/padded_text_initializers.py +442 -0
  7. svg_ultralight/bounding_boxes/supports_bounds.py +167 -0
  8. svg_ultralight/bounding_boxes/type_bound_collection.py +74 -0
  9. svg_ultralight/bounding_boxes/type_bound_element.py +68 -0
  10. svg_ultralight/bounding_boxes/type_bounding_box.py +432 -0
  11. svg_ultralight/bounding_boxes/type_padded_list.py +208 -0
  12. svg_ultralight/bounding_boxes/type_padded_text.py +502 -0
  13. svg_ultralight/constructors/__init__.py +14 -0
  14. svg_ultralight/constructors/new_element.py +117 -0
  15. svg_ultralight/font_tools/__init__.py +5 -0
  16. svg_ultralight/font_tools/comp_results.py +291 -0
  17. svg_ultralight/font_tools/font_info.py +849 -0
  18. svg_ultralight/image_ops.py +156 -0
  19. svg_ultralight/inkscape.py +261 -0
  20. svg_ultralight/layout.py +291 -0
  21. svg_ultralight/main.py +183 -0
  22. svg_ultralight/metadata.py +122 -0
  23. svg_ultralight/nsmap.py +36 -0
  24. svg_ultralight/py.typed +5 -0
  25. svg_ultralight/query.py +254 -0
  26. svg_ultralight/read_svg.py +58 -0
  27. svg_ultralight/root_elements.py +96 -0
  28. svg_ultralight/string_conversion.py +244 -0
  29. svg_ultralight/strings/__init__.py +21 -0
  30. svg_ultralight/strings/svg_strings.py +106 -0
  31. svg_ultralight/transformations.py +152 -0
  32. svg_ultralight/unit_conversion.py +247 -0
  33. svg_ultralight-0.64.0.dist-info/METADATA +208 -0
  34. svg_ultralight-0.64.0.dist-info/RECORD +35 -0
  35. svg_ultralight-0.64.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,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
+ 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.union(*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()
@@ -0,0 +1,58 @@
1
+ """Read SVG file and extract text content.
2
+
3
+ Note about svg resolution: Despite the fact that vector graphics have effectively
4
+ infinite resolution, ePub apparently uses the actual geometry size to determine the
5
+ resolution of the image. For images ike a business card drawn in real-world inches
6
+ (3.5" width), an ePub will assume a size of 3.5 pixels. There may be some unit for
7
+ the width and height variables (for InDesign, it's pnt) that addresses this, but I
8
+ don't trust it to be consistent across ePub readers. I adjust the units to something
9
+ large, then use CSS to scale it down to the correct size.
10
+
11
+ :author: Shay Hill
12
+ :created: 2025-07-28
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ from lxml import etree
19
+ from lxml.etree import _Element as EtreeElement # pyright: ignore[reportPrivateUsage]
20
+
21
+ import svg_ultralight as su
22
+
23
+
24
+ def get_bounding_box_from_root(root: EtreeElement) -> su.BoundingBox:
25
+ """Extract bounding box from SVG root element.
26
+
27
+ :param root: the root element of the SVG file
28
+ :raise ValueError: if the viewBox attribute is not present
29
+
30
+ """
31
+ viewbox = root.get("viewBox", "")
32
+ try:
33
+ x, y, width, height = map(float, viewbox.split())
34
+ except ValueError as e:
35
+ msg = f"Invalid or missing viewBox attribute: '{viewbox}'"
36
+ raise ValueError(msg) from e
37
+ return su.BoundingBox(x, y, width, height)
38
+
39
+
40
+ def parse(svg_file: str | os.PathLike[str]) -> su.BoundElement:
41
+ """Import an SVG file and return an SVG object.
42
+
43
+ :param svg_file: Path to the SVG file.
44
+ :return: A BoundElement containing the SVG content and the svg viewBox as a
45
+ BoundingBox.
46
+
47
+ Near equivalent to `etree.parse(file).getroot()`, but returns a BoundElement
48
+ instance. This will only work with SVG files that have a viewBox attribute.
49
+ """
50
+ with Path(svg_file).open("r", encoding="utf-8") as f:
51
+ root = etree.parse(f).getroot()
52
+ if len(root) == 1:
53
+ elem = root[0]
54
+ else:
55
+ elem = su.new_element("g")
56
+ elem.extend(list(root))
57
+ bbox = get_bounding_box_from_root(root)
58
+ return su.BoundElement(elem, bbox)
@@ -0,0 +1,96 @@
1
+ """Build root elements in various ways.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-09-23
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import suppress
10
+ from typing import TYPE_CHECKING
11
+
12
+ from svg_ultralight.bounding_boxes import bound_helpers as bound
13
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
14
+ from svg_ultralight.main import new_svg_root
15
+
16
+ if TYPE_CHECKING:
17
+ from lxml.etree import (
18
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
19
+ )
20
+
21
+ from svg_ultralight.attrib_hints import ElemAttrib, OptionalElemAttribMapping
22
+ from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
23
+ from svg_ultralight.layout import PadArg
24
+
25
+
26
+ def _viewbox_args_from_bboxes(*bboxes: BoundingBox) -> dict[str, float]:
27
+ """Create x_, y_, width_, height_ new_svg_root arguments from bounding boxes.
28
+
29
+ :param bbox: bounding boxes to merge
30
+ :return: dict of new_svg_root arguments
31
+ """
32
+ merged = BoundingBox.union(*bboxes)
33
+ return {
34
+ "x_": merged.x,
35
+ "y_": merged.y,
36
+ "width_": merged.width,
37
+ "height_": merged.height,
38
+ }
39
+
40
+
41
+ def new_svg_root_around_bounds(
42
+ *bounded: SupportsBounds | EtreeElement,
43
+ pad_: PadArg = 0,
44
+ print_width_: float | str | None = None,
45
+ print_height_: float | str | None = None,
46
+ dpu_: float = 1,
47
+ nsmap: dict[str | None, str] | None = None,
48
+ attrib: OptionalElemAttribMapping = None,
49
+ **attributes: ElemAttrib,
50
+ ) -> EtreeElement:
51
+ """Create svg root around BoundElements.
52
+
53
+ :param bounded: BoundingBox istances, BoundElement instances, PaddedText
54
+ instances, or any other EtreeElement instances. Anything that isn't a
55
+ bounding box or SupportsBounds will be ignored.
56
+ :param pad_: optionally increase viewBox by pad in all directions. Acceps a
57
+ single value or a tuple of values applied to (cycled over) top, right,
58
+ bottom, left. pad can be floats or dimension strings*
59
+ :param print_width_: optionally explicitly set unpadded width in units
60
+ (float) or a dimension string*
61
+ :param print_height_: optionally explicitly set unpadded height in units
62
+ (float) or a dimension string*
63
+ :param dpu_: dots per unit. Scale the output by this factor. This is
64
+ different from print_width_ and print_height_ in that dpu_ scales the
65
+ *padded* output.
66
+ :param nsmap: optionally pass a namespace map of your choosing
67
+ :param attrib: optionally pass additional attributes as a mapping instead of as
68
+ anonymous kwargs. This is useful for pleasing the linter when unpacking a
69
+ dictionary into a function call.
70
+ :param attributes: element attribute names and values
71
+ :return: root svg element
72
+ :raise ValueError: if no bounding boxes are found in bounded
73
+ """
74
+ attributes.update(attrib or {})
75
+ bbox = bound.new_bbox_union(*bounded)
76
+ elem: EtreeElement | None = None
77
+ with suppress(ValueError):
78
+ elem = bound.new_element_union(*bounded)
79
+ viewbox = _viewbox_args_from_bboxes(bbox)
80
+ root = new_svg_root(
81
+ x_=viewbox["x_"],
82
+ y_=viewbox["y_"],
83
+ width_=viewbox["width_"],
84
+ height_=viewbox["height_"],
85
+ pad_=pad_,
86
+ print_width_=print_width_,
87
+ print_height_=print_height_,
88
+ dpu_=dpu_,
89
+ nsmap=nsmap,
90
+ attrib=attributes,
91
+ )
92
+ if elem is None:
93
+ return root
94
+ for subelem in elem:
95
+ root.append(subelem)
96
+ return root
@@ -0,0 +1,244 @@
1
+ """Quasi-private functions for high-level string conversion.
2
+
3
+ :author: Shay Hill
4
+ :created: 7/26/2020
5
+
6
+ Rounding some numbers to ensure quality svg rendering:
7
+ * Rounding floats to six digits after the decimal
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import binascii
13
+ import re
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING, cast
16
+
17
+ import svg_path_data
18
+ from lxml import etree
19
+
20
+ from svg_ultralight.nsmap import NSMAP
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable
24
+
25
+ from lxml.etree import (
26
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
27
+ )
28
+
29
+ from svg_ultralight.attrib_hints import ElemAttrib
30
+
31
+
32
+ def format_number(num: float | str, resolution: int | None = 6) -> str:
33
+ """Format a number into an svg-readable float string with resolution = 6.
34
+
35
+ :param num: number to format (string or float)
36
+ :param resolution: number of digits after the decimal point, defaults to 6. None
37
+ to match behavior of `str(num)`.
38
+ :return: string representation of the number with six digits after the decimal
39
+ (if in fixed-point notation). Will return exponential notation when shorter.
40
+ """
41
+ return svg_path_data.format_number(num, resolution=resolution)
42
+
43
+
44
+ def format_numbers(
45
+ nums: Iterable[float] | Iterable[str] | Iterable[float | str],
46
+ ) -> list[str]:
47
+ """Format multiple strings to limited precision.
48
+
49
+ :param nums: iterable of floats
50
+ :return: list of formatted strings
51
+ """
52
+ return [format_number(num) for num in nums]
53
+
54
+
55
+ def _fix_key_and_format_val(key: str, val: ElemAttrib) -> tuple[str, str]:
56
+ """Format one key, value pair for an svg element.
57
+
58
+ :param key: element attribute name
59
+ :param val: element attribute value
60
+ :return: tuple of key, value
61
+ :raises ValueError: if key is 'd' and val is not a string
62
+
63
+ etree.Elements will only accept string
64
+ values. This saves having to convert input to strings.
65
+
66
+ * convert float values to formatted strings
67
+ * format datastring values when keyword is 'd'
68
+ * replace '_' with '-' in keywords
69
+ * remove trailing '_' from keywords
70
+ * will convert `namespace:tag` to a qualified name
71
+
72
+ SVG attribute names like `font-size` and `stroke-width` are not valid python
73
+ keywords, but can be passed as `font-size` and `stroke-width`.
74
+
75
+ Reserved Python keywords that are also valid and useful SVG attribute names (a
76
+ popular one will be 'class') can be passed with a trailing underscore (e.g.,
77
+ class_='body_text').
78
+ """
79
+ if "http:" in key or "https:" in key:
80
+ key_ = key
81
+ elif ":" in key:
82
+ namespace, tag = key.split(":")
83
+ key_ = str(etree.QName(NSMAP[namespace], tag))
84
+ else:
85
+ key_ = key.rstrip("_").replace("_", "-")
86
+
87
+ if val is None:
88
+ val_ = "none"
89
+ elif isinstance(val, (int, float)):
90
+ val_ = format_number(val)
91
+ else:
92
+ val_ = val
93
+
94
+ return key_, val_
95
+
96
+
97
+ def format_attr_dict(**attributes: ElemAttrib) -> dict[str, str]:
98
+ """Use svg_ultralight key / value fixer to create a dict of attributes.
99
+
100
+ :param attributes: element attribute names and values.
101
+ :return: dict of attributes, each key a valid svg attribute name, each value a str
102
+ """
103
+ return dict(_fix_key_and_format_val(key, val) for key, val in attributes.items())
104
+
105
+
106
+ def set_attributes(elem: EtreeElement, **attributes: ElemAttrib) -> None:
107
+ """Set name: value items as element attributes. Make every value a string.
108
+
109
+ :param elem: element to receive element.set(keyword, str(value)) calls
110
+ :param attributes: element attribute names and values. Knows what to do with 'text'
111
+ keyword.V :effects: updates ``elem``
112
+ """
113
+ attr_dict = format_attr_dict(**attributes)
114
+
115
+ dots = {"text"}
116
+ for dot in dots & set(attr_dict):
117
+ setattr(elem, dot, attr_dict.pop(dot))
118
+
119
+ for key, val in attr_dict.items():
120
+ elem.set(key, val)
121
+
122
+
123
+ class _TostringDefaults(Enum):
124
+ """Default values for an svg xml_header."""
125
+
126
+ DOCTYPE = (
127
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n'
128
+ + '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
129
+ )
130
+ ENCODING = "UTF-8"
131
+
132
+
133
+ def svg_tostring(xml: EtreeElement, **tostring_kwargs: str | bool | None) -> bytes:
134
+ """Contents of svg file with optional xml declaration.
135
+
136
+ :param xml: root node of your svg geometry
137
+ :param tostring_kwargs: keyword arguments to etree.tostring.
138
+ pass xml_header=True for sensible defaults, see further documentation on xml
139
+ header in write_svg docstring.
140
+ :return: bytestring of svg file contents
141
+ """
142
+ tostring_kwargs["pretty_print"] = tostring_kwargs.get("pretty_print", True)
143
+ if tostring_kwargs.get("xml_declaration"):
144
+ for default in _TostringDefaults:
145
+ arg_name = default.name.lower()
146
+ value = tostring_kwargs.get(arg_name, default.value)
147
+ tostring_kwargs[arg_name] = value
148
+ as_bytes = etree.tostring(etree.ElementTree(xml), **tostring_kwargs) # type: ignore
149
+ return cast("bytes", as_bytes)
150
+
151
+
152
+ def get_view_box_str(
153
+ x: float,
154
+ y: float,
155
+ width: float,
156
+ height: float,
157
+ pad: float | tuple[float, float, float, float] = 0,
158
+ ) -> str:
159
+ """Create a space-delimited string.
160
+
161
+ :param x: x value in upper-left corner
162
+ :param y: y value in upper-left corner
163
+ :param width: width of viewBox
164
+ :param height: height of viewBox
165
+ :param pad: optionally increase viewBox by pad in all directions
166
+ :return: space-delimited string "x y width height"
167
+ """
168
+ if not isinstance(pad, tuple):
169
+ pad = (pad, pad, pad, pad)
170
+ pad_t, pad_r, pad_b, pad_l = pad
171
+ pad_h = pad_l + pad_r
172
+ pad_v = pad_t + pad_b
173
+ dims = [
174
+ format_number(x) for x in (x - pad_l, y - pad_t, width + pad_h, height + pad_v)
175
+ ]
176
+ return " ".join(dims)
177
+
178
+
179
+ # ===================================================================================
180
+ # Encode and decode arbitrary strings to / from valid CSS class names.
181
+ # ===================================================================================
182
+
183
+
184
+ # insert his before any class name that would otherwise start with a digit
185
+ _NAME_PREFIX = "__"
186
+
187
+ _DELIMITED_HEX = re.compile(r"_[0-9a-fA-F]{2,8}_")
188
+
189
+
190
+ def _encode_class_name_invalid_char_to_hex(char: str) -> str:
191
+ """Encode any invalid single char to a hex representation prefixed with '_x'.
192
+
193
+ :param char: The character to encode.
194
+ :return: A string in the format '_x' followed by the hex value of the character.
195
+
196
+ Return valid css-class-name characters unchanged. Encode others. Exception: This
197
+ function encodes `_`, which *are* valid CSS class characters, in order to reserve
198
+ underscores for `_` hex delimiters and `__` -name prefixes.
199
+ """
200
+ if re.match(r"[a-zA-Z0-9-]", char):
201
+ return char
202
+ hex_ = binascii.hexlify(char.encode("utf-8")).decode("ascii")
203
+ return f"_{hex_}_"
204
+
205
+
206
+ def encode_to_css_class_name(text: str) -> str:
207
+ """Convert text to a valid CSS class name in a reversible way.
208
+
209
+ :param text: The text to convert.
210
+ :return: A valid CSS class name derived from the text. The intended use is to pass
211
+ a font filename, so the filename can be decoded from the contents of an SVG
212
+ file and each css class created from a font file will, if the style is not
213
+ altered, have a unique class name.
214
+
215
+ Non-ascii characters like `é` will be encoded as hex, even if they are, by
216
+ documentation, valid CSS class characters. The class name will be ascii only.
217
+ """
218
+ css_class = "".join(_encode_class_name_invalid_char_to_hex(c) for c in text)
219
+ # add a prefix if the name starts with a digit or is empty
220
+ if not css_class or not re.match(r"^[a-zA-Z_]", css_class):
221
+ css_class = _NAME_PREFIX + css_class
222
+ return css_class
223
+
224
+
225
+ def decode_from_css_class_name(css_class: str) -> str:
226
+ """Reverse the conversion from `filename_to_css_class`.
227
+
228
+ :param css_class: The CSS class name to convert back to text. This will not
229
+ be meaningful if the class name was not created by encode_css_class_name. If
230
+ you use another string, there is a potential for a hex decoding error.
231
+ :return: The original filename passed to `filename_to_css_class`.
232
+ """
233
+ css_class = css_class.removeprefix(_NAME_PREFIX)
234
+
235
+ result = ""
236
+ while css_class:
237
+ if match := _DELIMITED_HEX.match(css_class):
238
+ hex_str = match.group(0)[1:-1]
239
+ result += binascii.unhexlify(hex_str).decode("utf-8")
240
+ css_class = css_class[len(match.group(0)) :]
241
+ else:
242
+ result += css_class[0]
243
+ css_class = css_class[1:]
244
+ return result
@@ -0,0 +1,21 @@
1
+ """Import svg_strings into the svg_ultralight.strings namespace.
2
+
3
+ :author: Shay Hill
4
+ :created: 2021-12-09
5
+ """
6
+
7
+ from svg_ultralight.strings.svg_strings import (
8
+ svg_color_tuple,
9
+ svg_float_tuples,
10
+ svg_floats,
11
+ svg_ints,
12
+ svg_matrix,
13
+ )
14
+
15
+ __all__ = [
16
+ "svg_color_tuple",
17
+ "svg_float_tuples",
18
+ "svg_floats",
19
+ "svg_ints",
20
+ "svg_matrix",
21
+ ]