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.
- svg_ultralight/__init__.py +112 -0
- svg_ultralight/animate.py +40 -0
- svg_ultralight/attrib_hints.py +14 -0
- svg_ultralight/bounding_boxes/__init__.py +5 -0
- svg_ultralight/bounding_boxes/bound_helpers.py +200 -0
- svg_ultralight/bounding_boxes/padded_text_initializers.py +442 -0
- svg_ultralight/bounding_boxes/supports_bounds.py +167 -0
- svg_ultralight/bounding_boxes/type_bound_collection.py +74 -0
- svg_ultralight/bounding_boxes/type_bound_element.py +68 -0
- svg_ultralight/bounding_boxes/type_bounding_box.py +432 -0
- svg_ultralight/bounding_boxes/type_padded_list.py +208 -0
- svg_ultralight/bounding_boxes/type_padded_text.py +502 -0
- svg_ultralight/constructors/__init__.py +14 -0
- svg_ultralight/constructors/new_element.py +117 -0
- svg_ultralight/font_tools/__init__.py +5 -0
- svg_ultralight/font_tools/comp_results.py +291 -0
- svg_ultralight/font_tools/font_info.py +849 -0
- svg_ultralight/image_ops.py +156 -0
- svg_ultralight/inkscape.py +261 -0
- svg_ultralight/layout.py +291 -0
- svg_ultralight/main.py +183 -0
- svg_ultralight/metadata.py +122 -0
- svg_ultralight/nsmap.py +36 -0
- svg_ultralight/py.typed +5 -0
- svg_ultralight/query.py +254 -0
- svg_ultralight/read_svg.py +58 -0
- svg_ultralight/root_elements.py +96 -0
- svg_ultralight/string_conversion.py +244 -0
- svg_ultralight/strings/__init__.py +21 -0
- svg_ultralight/strings/svg_strings.py +106 -0
- svg_ultralight/transformations.py +152 -0
- svg_ultralight/unit_conversion.py +247 -0
- svg_ultralight-0.64.0.dist-info/METADATA +208 -0
- svg_ultralight-0.64.0.dist-info/RECORD +35 -0
- svg_ultralight-0.64.0.dist-info/WHEEL +4 -0
svg_ultralight/query.py
ADDED
|
@@ -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
|
+
]
|