svg-ultralight 0.32.2__tar.gz → 0.34.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svg-ultralight might be problematic. Click here for more details.

Files changed (51) hide show
  1. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/.pre-commit-config.yaml +10 -34
  2. {svg_ultralight-0.32.2/src/svg_ultralight.egg-info → svg_ultralight-0.34.0}/PKG-INFO +1 -1
  3. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/pyproject.toml +2 -2
  4. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/__init__.py +17 -3
  5. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/animate.py +2 -2
  6. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/bound_helpers.py +74 -0
  7. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/layout.py +5 -2
  8. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/metadata.py +2 -2
  9. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/query.py +80 -26
  10. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/transformations.py +3 -0
  11. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0/src/svg_ultralight.egg-info}/PKG-INFO +1 -1
  12. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight.egg-info/SOURCES.txt +1 -4
  13. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_bounding.py +68 -1
  14. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_layout.py +21 -0
  15. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_matrices.py +5 -3
  16. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_queries.py +49 -14
  17. svg_ultralight-0.32.2/src/svg_ultralight/import_svg.py +0 -50
  18. svg_ultralight-0.32.2/tests/resources/arrow.svg +0 -21
  19. svg_ultralight-0.32.2/tests/test_import_svg.py +0 -32
  20. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/.gitignore +0 -0
  21. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/README.md +0 -0
  22. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/setup.cfg +0 -0
  23. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/__init__.py +0 -0
  24. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/supports_bounds.py +0 -0
  25. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/type_bound_collection.py +0 -0
  26. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/type_bound_element.py +0 -0
  27. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/type_bounding_box.py +0 -0
  28. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/bounding_boxes/type_padded_text.py +0 -0
  29. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/constructors/__init__.py +0 -0
  30. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/constructors/new_element.py +0 -0
  31. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/inkscape.py +0 -0
  32. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/main.py +0 -0
  33. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/nsmap.py +0 -0
  34. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/py.typed +0 -0
  35. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/root_elements.py +0 -0
  36. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/string_conversion.py +0 -0
  37. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/strings/__init__.py +0 -0
  38. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/strings/svg_strings.py +0 -0
  39. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight/unit_conversion.py +0 -0
  40. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight.egg-info/dependency_links.txt +0 -0
  41. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight.egg-info/requires.txt +0 -0
  42. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/src/svg_ultralight.egg-info/top_level.txt +0 -0
  43. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/__init__.py +0 -0
  44. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/conftest.py +0 -0
  45. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_inkscape.py +0 -0
  46. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_metadata.py +0 -0
  47. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_new_element.py +0 -0
  48. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_root_elements.py +0 -0
  49. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_string_conversion.py +0 -0
  50. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tests/test_svg_ultralight.py +0 -0
  51. {svg_ultralight-0.32.2 → svg_ultralight-0.34.0}/tox.ini +0 -0
@@ -6,7 +6,7 @@ exclude: "tests"
6
6
  repos:
7
7
 
8
8
  - repo: https://github.com/pre-commit/pre-commit-hooks
9
- rev: v4.6.0
9
+ rev: v5.0.0
10
10
  hooks:
11
11
  - id: check-added-large-files
12
12
  - id: check-ast
@@ -42,7 +42,7 @@ repos:
42
42
  # files: .pre-commit-config.yaml
43
43
 
44
44
  - repo: https://github.com/pre-commit/mirrors-mypy
45
- rev: v1.10.0
45
+ rev: v1.13.0
46
46
  hooks:
47
47
  - id: mypy
48
48
  name: mypy
@@ -64,14 +64,14 @@ repos:
64
64
  args: ["--profile", "black", "--filter-files", "--combine-as"]
65
65
 
66
66
  - repo: https://github.com/psf/black
67
- rev: 24.4.2
67
+ rev: 24.10.0
68
68
  hooks:
69
69
  - id: black
70
70
  language_version: python3.9
71
71
  args: ["--skip-magic-trailing-comma"]
72
72
 
73
73
  - repo: https://github.com/asottile/pyupgrade
74
- rev: v3.15.2
74
+ rev: v3.19.0
75
75
  hooks:
76
76
  - args:
77
77
  - --py39-plus
@@ -82,33 +82,6 @@ repos:
82
82
  hooks:
83
83
  - id: remove-tabs
84
84
 
85
- # - repo: https://github.com/commitizen-tools/commitizen
86
- # rev: v2.40.0
87
- # hooks:
88
- # - id: commitizen
89
-
90
- # Pylint is still broken in Python 3.12
91
- # - repo: https://github.com/pre-commit/mirrors-pylint
92
- # rev: v3.0.0a5
93
- # hooks:
94
- # - id: pylint
95
- # exclude: "tests"
96
- # name: pylint
97
- # args:
98
- # - --good-names=i,j,_,f,x,y,x2,y2,cx,cy,x_,y_,r,g,b,e,get_viewBox_str
99
- # - --disable=protected-access
100
- # - --disable=no-member
101
- # - --disable=import-error
102
- # - --disable=no-name-in-module
103
- # - --disable=too-many-arguments
104
- # - --disable=too-many-instance-attributes
105
- # - --disable=unnecessary-ellipsis
106
- # - --load-plugins=pylint.extensions.docparams
107
- # - --accept-no-param-doc=n
108
- # - --accept-no-raise-doc=n
109
- # - --accept-no-return-doc=n
110
- # - --accept-no-yields-doc=n
111
-
112
85
  - repo: https://github.com/charliermarsh/ruff-pre-commit
113
86
  # ignores
114
87
  # ANN101 Missing type annotation for self in method
@@ -134,18 +107,21 @@ repos:
134
107
  # D407 [*] Missing dashed underline after section ("Attributes")
135
108
  # D406 [*] Section name should end with a newline ("Attributes")
136
109
  # S320 Using `lxml` to parse untrusted data is known to be vulnerable to XML attacks
137
- rev: 'v0.4.6'
110
+ # S301 don't use pickle
111
+ # B028 wants explicit stacklevel on warn
112
+ # BLE001 Use of `except Exception:` detected
113
+ rev: 'v0.7.4'
138
114
  hooks:
139
115
  - id: ruff
140
116
  exclude: "tests"
141
117
  args:
142
118
  - --target-version=py39
143
119
  - --select=ALL
144
- - --ignore=ANN101,ANN102,ANN201,ANN202,B905,COM812,D203,D213,I001,ISC003,N802,N806,PGH003,PLR0913,PTH108,S101,S603,PLR2004,D413,D407,D406,S320
120
+ - --ignore=ANN101,ANN102,ANN201,ANN202,B905,COM812,D203,D213,I001,ISC003,N802,N806,PGH003,PLR0913,PTH108,S101,S603,PLR2004,D413,D407,D406,S320,S301,B028,BLE001
145
121
  # - --fix
146
122
 
147
123
  # reads pyproject.toml for additional config
148
124
  - repo: https://github.com/RobertCraigie/pyright-python
149
- rev: v1.1.365
125
+ rev: v1.1.389
150
126
  hooks:
151
127
  - id: pyright
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: svg-ultralight
3
- Version: 0.32.2
3
+ Version: 0.34.0
4
4
  Summary: a sensible way to create svg files with Python
5
5
  Author-email: Shay Hill <shay_public@hotmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "svg-ultralight"
3
- version = "0.32.2"
3
+ version = "0.34.0"
4
4
  description = "a sensible way to create svg files with Python"
5
5
  authors = [{ name = "Shay Hill", email = "shay_public@hotmail.com" }]
6
6
  license = { text = "MIT" }
@@ -37,7 +37,7 @@ legacy_tox_ini = """
37
37
 
38
38
  [tool.commitizen]
39
39
  name = "cz_conventional_commits"
40
- version = "0.32.2"
40
+ version = "0.34.0"
41
41
  tag_format = "$version"
42
42
  version_files = ["pyproject.toml:^version"]
43
43
  annotated_tag = true
@@ -8,6 +8,10 @@ from svg_ultralight.bounding_boxes.bound_helpers import (
8
8
  new_bbox_union,
9
9
  new_bound_union,
10
10
  new_element_union,
11
+ cut_bbox,
12
+ pad_bbox,
13
+ bbox_dict,
14
+ new_bbox_rect,
11
15
  )
12
16
  from svg_ultralight.bounding_boxes.supports_bounds import SupportsBounds
13
17
  from svg_ultralight.bounding_boxes.type_bound_collection import BoundCollection
@@ -20,7 +24,6 @@ from svg_ultralight.constructors.new_element import (
20
24
  new_sub_element,
21
25
  update_element,
22
26
  )
23
- from svg_ultralight.import_svg import import_svg
24
27
  from svg_ultralight.inkscape import (
25
28
  write_pdf,
26
29
  write_pdf_from_svg,
@@ -31,7 +34,12 @@ from svg_ultralight.inkscape import (
31
34
  from svg_ultralight.main import new_svg_root, write_svg
32
35
  from svg_ultralight.metadata import new_metadata
33
36
  from svg_ultralight.nsmap import NSMAP, new_qname
34
- from svg_ultralight.query import pad_text
37
+ from svg_ultralight.query import (
38
+ get_bounding_box,
39
+ get_bounding_boxes,
40
+ pad_text,
41
+ clear_svg_ultralight_cache,
42
+ )
35
43
  from svg_ultralight.root_elements import new_svg_root_around_bounds
36
44
  from svg_ultralight.string_conversion import (
37
45
  format_attr_dict,
@@ -53,15 +61,20 @@ __all__ = [
53
61
  "NSMAP",
54
62
  "PaddedText",
55
63
  "SupportsBounds",
64
+ "bbox_dict",
65
+ "clear_svg_ultralight_cache",
66
+ "cut_bbox",
56
67
  "deepcopy_element",
57
68
  "format_attr_dict",
58
69
  "format_number",
59
70
  "format_numbers",
60
71
  "format_numbers_in_string",
61
- "import_svg",
72
+ "get_bounding_box",
73
+ "get_bounding_boxes",
62
74
  "mat_apply",
63
75
  "mat_dot",
64
76
  "mat_invert",
77
+ "new_bbox_rect",
65
78
  "new_bbox_union",
66
79
  "new_bound_union",
67
80
  "new_element",
@@ -71,6 +84,7 @@ __all__ = [
71
84
  "new_sub_element",
72
85
  "new_svg_root",
73
86
  "new_svg_root_around_bounds",
87
+ "pad_bbox",
74
88
  "pad_text",
75
89
  "transform_element",
76
90
  "update_element",
@@ -34,7 +34,7 @@ def write_gif(
34
34
  :param loop: how many times to loop gif. 0 -> forever
35
35
  :effects: write file to gif
36
36
  """
37
- images = [Image.open(x) for x in pngs] # type: ignore
38
- images[0].save( # type: ignore
37
+ images = [Image.open(x) for x in pngs]
38
+ images[0].save(
39
39
  gif, save_all=True, append_images=images[1:], duration=duration, loop=loop
40
40
  )
@@ -95,3 +95,77 @@ def new_bound_union(*blems: SupportsBounds | EtreeElement) -> BoundElement:
95
95
  group = new_element_union(*blems)
96
96
  bbox = new_bbox_union(*blems)
97
97
  return BoundElement(group, bbox)
98
+
99
+
100
+ def _expand_pad(pad: float | tuple[float, ...]) -> tuple[float, float, float, float]:
101
+ """Expand a float pad argument into a 4-tuple."""
102
+ if isinstance(pad, (int, float)):
103
+ return pad, pad, pad, pad
104
+ if len(pad) == 1:
105
+ return pad[0], pad[0], pad[0], pad[0]
106
+ if len(pad) == 2:
107
+ return pad[0], pad[1], pad[0], pad[1]
108
+ if len(pad) == 3:
109
+ return pad[0], pad[1], pad[2], pad[1]
110
+ return pad[0], pad[1], pad[2], pad[3]
111
+
112
+
113
+ def cut_bbox(
114
+ bbox: SupportsBounds,
115
+ *,
116
+ x: float | None = None,
117
+ y: float | None = None,
118
+ x2: float | None = None,
119
+ y2: float | None = None,
120
+ ) -> BoundingBox:
121
+ """Return a new bounding box with updated limits.
122
+
123
+ :param bbox: the original bounding box or bounded element.
124
+ :param x: the new x-coordinate.
125
+ :param y: the new y-coordinate.
126
+ :param x2: the new x2-coordinate.
127
+ :param y2: the new y2-coordinate.
128
+ :return: a new bounding box with the updated limits.
129
+ """
130
+ x = bbox.x if x is None else x
131
+ y = bbox.y if y is None else y
132
+ x2 = bbox.x2 if x2 is None else x2
133
+ y2 = bbox.y2 if y2 is None else y2
134
+ width = x2 - x
135
+ height = y2 - y
136
+ return BoundingBox(x, y, width, height)
137
+
138
+
139
+ def pad_bbox(bbox: SupportsBounds, pad: float | tuple[float, ...]) -> BoundingBox:
140
+ """Return a new bounding box with padding.
141
+
142
+ :param bbox: the original bounding box or bounded element.
143
+ :param pad: the padding to apply.
144
+ If a single number, the same padding will be applied to all sides.
145
+ If a tuple, will be applied per css rules.
146
+ len = 1 : 0, 0, 0, 0
147
+ len = 2 : 0, 1, 0, 1
148
+ len = 3 : 0, 1, 2, 1
149
+ len = 4 : 0, 1, 2, 3
150
+ :return: a new bounding box with padding applied.
151
+ """
152
+ t, r, b, l = _expand_pad(pad)
153
+ return cut_bbox(bbox, x=bbox.x - l, y=bbox.y - t, x2=bbox.x2 + r, y2=bbox.y2 + b)
154
+
155
+
156
+ def bbox_dict(bbox: SupportsBounds) -> dict[str, float]:
157
+ """Return a dictionary representation of a bounding box.
158
+
159
+ :param bbox: the bounding box or bound element from which to extract dimensions.
160
+ :return: a dictionary with keys x, y, width, and height.
161
+ """
162
+ return {"x": bbox.x, "y": bbox.y, "width": bbox.width, "height": bbox.height}
163
+
164
+
165
+ def new_bbox_rect(bbox: BoundingBox, **kwargs: float | str) -> EtreeElement:
166
+ """Return a new rect element with the same dimensions as the bounding box.
167
+
168
+ :param bbox: the bounding box or bound element from which to extract dimensions.
169
+ :param kwargs: additional attributes for the rect element.
170
+ """
171
+ return new_element("rect", **bbox_dict(bbox), **kwargs)
@@ -43,7 +43,10 @@ def expand_pad_arg(pad: PadArg) -> tuple[float, float, float, float]:
43
43
  return expand_pad_arg([pad])
44
44
  as_ms = [m if isinstance(m, Measurement) else Measurement(m) for m in pad]
45
45
  as_units = [m.value for m in as_ms]
46
- as_units = [as_units[i % len(as_units)] for i in range(4)]
46
+ if len(as_units) == 3:
47
+ as_units = [*as_units, as_units[1]]
48
+ else:
49
+ as_units = [as_units[i % len(as_units)] for i in range(4)]
47
50
  return as_units[0], as_units[1], as_units[2], as_units[3]
48
51
 
49
52
 
@@ -99,7 +102,7 @@ def _infer_scale(
99
102
  * print_h == 0 / viewbox_h > 0
100
103
 
101
104
  The print area is invalid, but there is special handling for this. Interpret
102
- viewbox units as print_w.native_unit and determine print area from viewbox area 1
105
+ viewbox units as print_w.native_unit and determe print area from viewbox area 1
103
106
  to 1.
104
107
 
105
108
  >>> _infer_scale(Measurement("in"), Measurement("in"), 1, 2)
@@ -65,8 +65,8 @@ def _wrap_bag(title: str) -> EtreeElement:
65
65
  """
66
66
  items = title.split(",")
67
67
  agent = new_element(new_qname("rdf", "Bag"))
68
- for title in items:
69
- _ = new_sub_element(agent, new_qname("rdf", "li"), text=title)
68
+ for title_item in items:
69
+ _ = new_sub_element(agent, new_qname("rdf", "li"), text=title_item)
70
70
  return agent
71
71
 
72
72
 
@@ -11,24 +11,36 @@ business card). Getting bounding boxes from Inkscape is not exceptionally fast.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import hashlib
14
15
  import os
16
+ import pickle
15
17
  import re
16
18
  import uuid
19
+ from contextlib import suppress
17
20
  from copy import deepcopy
21
+ from pathlib import Path
18
22
  from subprocess import PIPE, Popen
19
- from tempfile import NamedTemporaryFile
23
+ from tempfile import NamedTemporaryFile, TemporaryFile
20
24
  from typing import TYPE_CHECKING
25
+ from warnings import warn
26
+
27
+ from lxml import etree
21
28
 
22
29
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
23
30
  from svg_ultralight.bounding_boxes.type_padded_text import PaddedText
24
31
  from svg_ultralight.main import new_svg_root, write_svg
25
32
 
26
33
  if TYPE_CHECKING:
27
- from pathlib import Path
28
34
 
29
35
  from lxml.etree import _Element as EtreeElement # type: ignore
30
36
 
31
37
 
38
+ with TemporaryFile() as f:
39
+ _CACHE_DIR = Path(f.name).parent / "svg_ultralight_cache"
40
+
41
+ _CACHE_DIR.mkdir(exist_ok=True)
42
+
43
+
32
44
  def _fill_ids(*elem_args: EtreeElement) -> None:
33
45
  """Set the id attribute of an element and all its children. Keep existing ids.
34
46
 
@@ -66,8 +78,8 @@ def _envelop_copies(*elem_args: EtreeElement) -> EtreeElement:
66
78
  :param elem_args: one or more etree elements
67
79
  :return: an etree element enveloping copies of elem_args with all views normalized
68
80
  """
69
- envelope = new_svg_root(0, 0, 1, 1, id_=f"envelope_{uuid.uuid4()}")
70
- envelope.extend(deepcopy(e) for e in elem_args)
81
+ envelope = new_svg_root(0, 0, 1, 1)
82
+ envelope.extend([deepcopy(e) for e in elem_args])
71
83
  _normalize_views(envelope)
72
84
  return envelope
73
85
 
@@ -106,6 +118,8 @@ def map_ids_to_bounding_boxes(
106
118
  a (0, 0, 1, 1) root. This will put the boxes where you'd expect them to be, no
107
119
  matter what root you use.
108
120
  """
121
+ if not elem_args:
122
+ return {}
109
123
  _fill_ids(*elem_args)
110
124
  envelope = _envelop_copies(*elem_args)
111
125
 
@@ -123,40 +137,82 @@ def map_ids_to_bounding_boxes(
123
137
  return id2bbox
124
138
 
125
139
 
126
- def get_bounding_box(
140
+ def _hash_elem(elem: EtreeElement) -> str:
141
+ """Hash an EtreeElement.
142
+
143
+ Will match identical (excepting id) elements.
144
+ """
145
+ elem_copy = deepcopy(elem)
146
+ with suppress(KeyError):
147
+ _ = elem_copy.attrib.pop("id")
148
+ hash_object = hashlib.sha256(etree.tostring(elem_copy))
149
+ return hash_object.hexdigest()
150
+
151
+
152
+ def _try_bbox_cache(elem_hash: str) -> BoundingBox | None:
153
+ """Try to load a cached bounding box."""
154
+ cache_path = _CACHE_DIR / elem_hash
155
+ if not cache_path.exists():
156
+ return None
157
+ try:
158
+ with cache_path.open("rb") as f:
159
+ return pickle.load(f)
160
+ except (EOFError, pickle.UnpicklingError) as e:
161
+ msg = f"Error loading cache file {cache_path}: {e}"
162
+ warn(msg)
163
+ except Exception as e:
164
+ msg = f"Unexpected error loading cache file {cache_path}: {e}"
165
+ warn(msg)
166
+ return None
167
+
168
+
169
+ def get_bounding_boxes(
127
170
  inkscape: str | Path, *elem_args: EtreeElement
128
- ) -> BoundingBox | tuple[BoundingBox, ...]:
171
+ ) -> tuple[BoundingBox, ...]:
129
172
  r"""Get bounding box around a single element (or multiple elements).
130
173
 
131
174
  :param inkscape: path to an inkscape executable on your local file system
132
175
  IMPORTANT: path cannot end with ``.exe``.
133
176
  Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
134
177
  :param elem_args: xml elements
135
- :return: a BoundingBox instance around a single elem or a tuple of BoundingBox
136
- instances if multiple elem_args are passed.
178
+ :return: a BoundingBox instance around a each elem_arg
137
179
 
138
180
  This will work most of the time, but if you're missing an nsmap, you'll need to
139
181
  create an entire xml file with a custom nsmap (using
140
182
  `svg_ultralight.new_svg_root`) then call `map_ids_to_bounding_boxes` directly.
141
183
  """
142
- id2bbox = map_ids_to_bounding_boxes(inkscape, *elem_args)
143
- bboxes = [id2bbox[x.get("id", "")] for x in elem_args]
144
- if len(bboxes) == 1:
145
- return bboxes[0]
146
- return tuple(bboxes)
184
+ elem2hash = {elem: _hash_elem(elem) for elem in elem_args}
185
+ cached = [_try_bbox_cache(h) for h in elem2hash.values()]
186
+ if None not in cached:
187
+ return tuple(filter(None, cached))
147
188
 
189
+ hash2bbox = {h: c for h, c in zip(elem2hash.values(), cached) if c is not None}
190
+ remainder = [e for e, c in zip(elem_args, cached) if c is None]
191
+ id2bbox = map_ids_to_bounding_boxes(inkscape, *remainder)
192
+ for elem in remainder:
193
+ hash_ = elem2hash[elem]
194
+ hash2bbox[hash_] = id2bbox[elem.attrib["id"]]
195
+ with (_CACHE_DIR / hash_).open("wb") as f:
196
+ pickle.dump(hash2bbox[hash_], f)
197
+ return tuple(hash2bbox[h] for h in elem2hash.values())
148
198
 
149
- def _replace_text(text_elem: EtreeElement, new_text: str) -> None:
150
- """Replace the text in a text element.
151
199
 
152
- :param text_elem: an etree element with a text tag
153
- :param new_text: the new text to insert
200
+ def get_bounding_box(inkscape: str | Path, elem: EtreeElement) -> BoundingBox:
201
+ r"""Get bounding box around a single element.
154
202
 
155
- If the text element has tspans, replace each tspan.text with new_text.
203
+ :param inkscape: path to an inkscape executable on your local file system
204
+ IMPORTANT: path cannot end with ``.exe``.
205
+ Use something like ``"C:\\Program Files\\Inkscape\\inkscape"``
206
+ :param elem: xml element
207
+ :return: a BoundingBox instance around a single elem
156
208
  """
157
- text_elem.text = new_text
158
- for sub_elem in text_elem:
159
- sub_elem.text = new_text
209
+ return get_bounding_boxes(inkscape, elem)[0]
210
+
211
+
212
+ def clear_svg_ultralight_cache() -> None:
213
+ """Clear all cached bounding boxes."""
214
+ for cache_file in _CACHE_DIR.glob("*"):
215
+ cache_file.unlink()
160
216
 
161
217
 
162
218
  def pad_text(
@@ -179,12 +235,10 @@ def pad_text(
179
235
  _ = rmargin_ref.attrib.pop("id", None)
180
236
  _ = capline_ref.attrib.pop("id", None)
181
237
  rmargin_ref.attrib["text-anchor"] = "end"
182
- _replace_text(capline_ref, capline_reference_char)
183
- id2bbox = map_ids_to_bounding_boxes(inkscape, text_elem, rmargin_ref, capline_ref)
238
+ capline_ref.text = capline_reference_char
184
239
 
185
- bbox = id2bbox[text_elem.attrib["id"]]
186
- rmargin_bbox = id2bbox[rmargin_ref.attrib["id"]]
187
- capline_bbox = id2bbox[capline_ref.attrib["id"]]
240
+ bboxes = get_bounding_boxes(inkscape, text_elem, rmargin_ref, capline_ref)
241
+ bbox, rmargin_bbox, capline_bbox = bboxes
188
242
 
189
243
  tpad = bbox.y - capline_bbox.y
190
244
  rpad = -rmargin_bbox.x2
@@ -63,6 +63,9 @@ def mat_invert(tmat: _Matrix) -> _Matrix:
63
63
  """Invert a 2D transformation matrix in svg format."""
64
64
  a, b, c, d, e, f = tmat
65
65
  det = a * d - b * c
66
+ if det == 0:
67
+ msg = "Matrix is not invertible"
68
+ raise ValueError(msg)
66
69
  return (
67
70
  d / det,
68
71
  -b / det,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: svg-ultralight
3
- Version: 0.32.2
3
+ Version: 0.34.0
4
4
  Summary: a sensible way to create svg files with Python
5
5
  Author-email: Shay Hill <shay_public@hotmail.com>
6
6
  License: MIT
@@ -5,7 +5,6 @@ pyproject.toml
5
5
  tox.ini
6
6
  src/svg_ultralight/__init__.py
7
7
  src/svg_ultralight/animate.py
8
- src/svg_ultralight/import_svg.py
9
8
  src/svg_ultralight/inkscape.py
10
9
  src/svg_ultralight/layout.py
11
10
  src/svg_ultralight/main.py
@@ -36,7 +35,6 @@ src/svg_ultralight/strings/svg_strings.py
36
35
  tests/__init__.py
37
36
  tests/conftest.py
38
37
  tests/test_bounding.py
39
- tests/test_import_svg.py
40
38
  tests/test_inkscape.py
41
39
  tests/test_layout.py
42
40
  tests/test_matrices.py
@@ -45,5 +43,4 @@ tests/test_new_element.py
45
43
  tests/test_queries.py
46
44
  tests/test_root_elements.py
47
45
  tests/test_string_conversion.py
48
- tests/test_svg_ultralight.py
49
- tests/resources/arrow.svg
46
+ tests/test_svg_ultralight.py
@@ -10,9 +10,16 @@ from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
10
10
  from svg_ultralight.bounding_boxes.type_padded_text import PaddedText
11
11
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
12
12
  from svg_ultralight.bounding_boxes.type_bound_collection import BoundCollection
13
+ from svg_ultralight.bounding_boxes.bound_helpers import (
14
+ pad_bbox,
15
+ cut_bbox,
16
+ bbox_dict,
17
+ new_bbox_rect,
18
+ )
13
19
  import copy
14
20
  from svg_ultralight.constructors import new_element
15
21
 
22
+
16
23
  class TestBoundElement:
17
24
  @pytest.fixture
18
25
  def bound_element(self) -> BoundElement:
@@ -84,7 +91,6 @@ class TestBoundElement:
84
91
  assert bound_element.y2 == 250.0
85
92
 
86
93
 
87
-
88
94
  class TestPaddedText:
89
95
  @pytest.fixture
90
96
  def bound_element(self) -> PaddedText:
@@ -155,6 +161,7 @@ class TestPaddedText:
155
161
  assert math.isclose(bound_element.height, 252.76)
156
162
  assert bound_element.y2 == 203.0
157
163
 
164
+
158
165
  class TestBoundCollection:
159
166
 
160
167
  @pytest.fixture
@@ -178,3 +185,63 @@ class TestBoundCollection:
178
185
  blem_trans = blem.elem.attrib["transform"]
179
186
  elem_trans = elem.attrib["transform"]
180
187
  assert blem_trans == elem_trans
188
+
189
+
190
+ class TestBoundHelpers:
191
+ def test_pad_bbox(self):
192
+ bbox = BoundingBox(0, 0, 4, 4)
193
+ padded = pad_bbox(bbox, 1)
194
+ assert padded.x == -1
195
+ assert padded.y == -1
196
+ assert padded.width == 6
197
+ assert padded.height == 6
198
+
199
+ def test_pad_bbox_t1(self):
200
+ bbox = BoundingBox(0, 0, 4, 4)
201
+ padded = pad_bbox(bbox, (1,))
202
+ assert padded.x == -1
203
+ assert padded.y == -1
204
+ assert padded.width == 6
205
+ assert padded.height == 6
206
+
207
+ def test_pad_bbox_t2(self):
208
+ bbox = BoundingBox(0, 0, 4, 4)
209
+ padded = pad_bbox(bbox, (1, 2))
210
+ assert padded.x == -2
211
+ assert padded.y == -1
212
+ assert padded.width == 8
213
+ assert padded.height == 6
214
+
215
+ def test_pad_bbox_t3(self):
216
+ bbox = BoundingBox(0, 0, 4, 4)
217
+ padded = pad_bbox(bbox, (1, 2, 3))
218
+ assert padded.x == -2
219
+ assert padded.y == -1
220
+ assert padded.width == 8
221
+ assert padded.height == 8
222
+
223
+ def test_pad_bbox_t4(self):
224
+ bbox = BoundingBox(0, 0, 4, 4)
225
+ padded = pad_bbox(bbox, (1, 2, 3, 4))
226
+ assert padded.x == -4
227
+ assert padded.y == -1
228
+ assert padded.width == 10
229
+ assert padded.height == 8
230
+
231
+ def test_cut_bbox(self):
232
+ bbox = BoundingBox(0, 0, 4, 4)
233
+ cut = cut_bbox(bbox, x=1)
234
+ assert cut.x == 1
235
+ assert cut.y == 0
236
+ assert cut.width == 3
237
+ assert cut.height == 4
238
+
239
+ def test_bbox_dict(self):
240
+ bbox = BoundingBox(0, 1, 2, 3)
241
+ assert bbox_dict(bbox) == {"x": 0, "y": 1, "width": 2, "height": 3}
242
+
243
+ def test_new_bbox_rect(self):
244
+ bbox = BoundingBox(0, 1, 2, 3)
245
+ elem = new_bbox_rect(bbox)
246
+ assert elem.attrib == {"x": "0", "y": "1", "width": "2", "height": "3"}
247
+
@@ -123,6 +123,27 @@ class TestMeasurement:
123
123
  assert (Measurement((1, unit)) / 4).value == Measurement((1 / 4, unit)).value
124
124
 
125
125
 
126
+ class TestExpandPadArg:
127
+ def test_expand_val(self):
128
+ """Test that a single value is expanded to a 4-tuple."""
129
+ assert layout.expand_pad_arg(1) == (1, 1, 1, 1)
130
+
131
+ def test_expand_1tuple(self):
132
+ """Test that a single value is expanded to a 4-tuple."""
133
+ assert layout.expand_pad_arg(1) == (1, 1, 1, 1)
134
+
135
+ def test_expand_2tuple(self):
136
+ """Test that a single value is expanded to a 4-tuple."""
137
+ assert layout.expand_pad_arg((1, 2)) == (1, 2, 1, 2)
138
+
139
+ def test_expand_3tuple(self):
140
+ """Test that a single value is expanded to a 4-tuple per css rules."""
141
+ assert layout.expand_pad_arg((1, 2, 3)) == (1, 2, 3, 2)
142
+
143
+ def test_expand_4tuple(self):
144
+ """Test that a single value is expanded to a 4-tuple per css rules."""
145
+ assert layout.expand_pad_arg((1, 2, 3, 4)) == (1, 2, 3, 4)
146
+
126
147
  class TestLayout:
127
148
  def test_standard(self):
128
149
  """No print dimensions give expanded pad argument
@@ -7,6 +7,7 @@
7
7
  from svg_ultralight.transformations import mat_dot, mat_apply, mat_invert
8
8
  import random
9
9
  import math
10
+ from contextlib import suppress
10
11
 
11
12
 
12
13
  class TestMat:
@@ -29,6 +30,7 @@ class TestMat:
29
30
  random.randint(-10, 10),
30
31
  random.randint(-10, 10),
31
32
  )
32
- result = mat_dot(tmat, mat_invert(tmat))
33
- for x, y in zip(result, identity):
34
- assert math.isclose(x, y, abs_tol=0.0001)
33
+ with suppress(ValueError):
34
+ result = mat_dot(tmat, mat_invert(tmat))
35
+ for x, y in zip(result, identity):
36
+ assert math.isclose(x, y, abs_tol=0.0001)
@@ -13,8 +13,8 @@ from pathlib import Path
13
13
  import pytest
14
14
 
15
15
  from svg_ultralight import BoundingBox, new_svg_root
16
- from svg_ultralight.constructors import new_element, new_sub_element
17
- from svg_ultralight.query import map_ids_to_bounding_boxes, pad_text
16
+ from svg_ultralight.constructors import new_sub_element
17
+ from svg_ultralight.query import map_ids_to_bounding_boxes, get_bounding_boxes, get_bounding_box
18
18
 
19
19
  INKSCAPE = Path(r"C:\Program Files\Inkscape\bin\inkscape")
20
20
 
@@ -171,6 +171,53 @@ class TestMapIdsToBoundingBoxes:
171
171
  assert rect2.get("id") in result
172
172
  assert rect3.get("id") in result
173
173
 
174
+ def test_get_bboxes_explicit(self) -> None:
175
+ """Returns a dict with an entry for each element plus an envelope entry."""
176
+ xml = new_svg_root(10, 20, 160, 19, id="svg1")
177
+ rect1 = new_sub_element(xml, "rect", x=0, y=0, width=16, height=9)
178
+ rect2 = new_sub_element(xml, "rect", x=0, y=0, width=8, height=32)
179
+ rect3 = new_sub_element(xml, "rect", x=0, y=0, width=12, height=18)
180
+ rect4 = new_sub_element(xml, "rect", x=0, y=0, width=12, height=18)
181
+
182
+ result = get_bounding_boxes(INKSCAPE, xml, rect1, rect2, rect3, rect4)
183
+ assert result[0] == BoundingBox(
184
+ _x=0.0,
185
+ _y=0.0,
186
+ _width=16.0,
187
+ _height=32.0,
188
+ _transformation=(1, 0, 0, 1, 0, 0),
189
+ )
190
+ assert result[1] == BoundingBox(
191
+ _x=0.0, _y=0.0, _width=16.0, _height=9.0, _transformation=(1, 0, 0, 1, 0, 0)
192
+ )
193
+ assert result[2] == BoundingBox(
194
+ _x=0.0, _y=0.0, _width=8.0, _height=32.0, _transformation=(1, 0, 0, 1, 0, 0)
195
+ )
196
+ assert result[3] == BoundingBox(
197
+ _x=0.0,
198
+ _y=0.0,
199
+ _width=12.0,
200
+ _height=18.0,
201
+ _transformation=(1, 0, 0, 1, 0, 0),
202
+ )
203
+ assert result[4] == BoundingBox(
204
+ _x=0.0,
205
+ _y=0.0,
206
+ _width=12.0,
207
+ _height=18.0,
208
+ _transformation=(1, 0, 0, 1, 0, 0),
209
+ )
210
+
211
+ def test_get_bbox_vs_boxes(self) -> None:
212
+ """Multiple calls to get_bounding_box are equivalent to a single call."""
213
+ xml = new_svg_root(10, 20, 160, 19, id="svg1")
214
+ rect1 = new_sub_element(xml, "rect", x=0, y=0, width=16, height=9)
215
+ rect2 = new_sub_element(xml, "rect", x=0, y=0, width=8, height=32)
216
+ rect3 = new_sub_element(xml, "rect", x=0, y=0, width=12, height=18)
217
+ rect4 = new_sub_element(xml, "rect", x=0, y=0, width=12, height=18)
218
+ elems = (xml, rect1, rect2, rect3, rect4)
219
+ result = get_bounding_boxes(INKSCAPE, *elems)
220
+ assert result == tuple(get_bounding_box(INKSCAPE, e) for e in elems)
174
221
 
175
222
  class TestAlterBoundingBox:
176
223
  def test_reverse_width(self) -> None:
@@ -181,15 +228,3 @@ class TestAlterBoundingBox:
181
228
  bbox.height = 200
182
229
  bbox.height = 40
183
230
  assert bbox.transformation == (1, 0, 0, 1, 90, 180)
184
-
185
-
186
- class TestPadText:
187
- def test_tspans(self) -> None:
188
- """Text in tspan are padded the same as text in text."""
189
- no_tspan = new_element("text", font_size=100, text="typography")
190
- in_tspan = new_element("text")
191
- _ = new_sub_element(in_tspan, "tspan", font_size=100, text="typography")
192
- padded_with = pad_text(INKSCAPE, no_tspan)
193
- padded_without = pad_text(INKSCAPE, in_tspan)
194
- assert padded_with.bbox == padded_without.bbox
195
- assert padded_with.padded_bbox == padded_without.padded_bbox
@@ -1,50 +0,0 @@
1
- """Import an svg file as a BoundElement.
2
-
3
- :author: Shay Hill
4
- :created: 2024-05-28
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from typing import TYPE_CHECKING
10
-
11
- from lxml import etree
12
-
13
- from svg_ultralight.bounding_boxes.type_bound_element import BoundElement
14
- from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
15
- from svg_ultralight.constructors import new_element
16
-
17
- if TYPE_CHECKING:
18
- import os
19
-
20
- from lxml.etree import _Element as EtreeElement # type: ignore
21
-
22
-
23
- def _get_bounds_from_viewbox(root: EtreeElement) -> BoundingBox:
24
- """Get the BoundingBox from the viewbox attribute of the root element.
25
-
26
- :param root: The root element of the svg.
27
- :return: The BoundingBox of the svg.
28
- """
29
- viewbox = root.attrib.get("viewBox")
30
- if viewbox is None:
31
- msg = "SVG file has no viewBox attribute. Failed to create BoundingBox."
32
- raise ValueError(msg)
33
- x, y, width, height = map(float, viewbox.split())
34
- return BoundingBox(x, y, width, height)
35
-
36
-
37
- def import_svg(svg: str | os.PathLike[str]) -> BoundElement:
38
- """Import an svg file as a BoundElement.
39
-
40
- :param svg: The path to the svg file.
41
- :return: The BoundElement representation of the svg.
42
-
43
- The viewbox of the svg is used to create the BoundingBox of the BoundElement.
44
- """
45
- tree = etree.parse(svg)
46
- root = tree.getroot()
47
- bbox = _get_bounds_from_viewbox(root)
48
- root_as_elem = new_element("g")
49
- root_as_elem.extend(root)
50
- return BoundElement(root_as_elem, bbox)
@@ -1,21 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="-3 -3 128 128">
2
- <defs>
3
- <clipPath id="clip">
4
- <path d="M0,122L0,103.7L40.666667,73.2L81.333333,79.3L99.556877,49.231152L122,12.2L122,122Z"/>
5
- </clipPath>
6
- <mask id="mask">
7
- <g>
8
- <rect x="0" y="0" width="122" height="122" fill="#FFFFFF"/>
9
- <path d="M0,91.45Q0,91.2 0.2,91.05L37.845867,62.8156Q38.013194,62.690105 38.220037,62.721131L75.903051,68.373583Q76.232274,68.422967 76.40482,68.138266L90.745748,44.475734Q91.004899,44.048135 90.5773,43.788984L78.917555,36.722472Q78.176932,36.273609 78.935969,35.856642L121.240962,12.616967Q122,12.2 121.981585,13.06583L120.955238,61.322866Q120.936823,62.188695 120.1962,61.739833L108.536455,54.67332Q108.108856,54.41417 107.849705,54.841768L86.606939,89.892332Q86.434393,90.177033 86.10517,90.12765L43.526984,83.740922Q43.32014,83.709895 43.152813,83.83539L0.8,115.6Q0,116.2 0,115.2Z" fill="#000000" stroke="#000000" stroke-width="3.1"/>
10
- </g>
11
- </mask>
12
- </defs>
13
- <g clip-path="url(#clip)" mask="url(#mask)">
14
- <rect x="0" y="0" width="23.6" height="122" fill="orange"/>
15
- <rect x="24.6" y="0" width="23.6" height="122" fill="orange"/>
16
- <rect x="49.2" y="0" width="23.6" height="122" fill="orange"/>
17
- <rect x="73.8" y="0" width="23.6" height="122" fill="orange"/>
18
- <rect x="98.4" y="0" width="23.6" height="122" fill="orange"/>
19
- </g>
20
- <path d="M0,91.45Q0,91.2 0.2,91.05L37.845867,62.8156Q38.013194,62.690105 38.220037,62.721131L75.903051,68.373583Q76.232274,68.422967 76.40482,68.138266L90.745748,44.475734Q91.004899,44.048135 90.5773,43.788984L78.917555,36.722472Q78.176932,36.273609 78.935969,35.856642L121.240962,12.616967Q122,12.2 121.981585,13.06583L120.955238,61.322866Q120.936823,62.188695 120.1962,61.739833L108.536455,54.67332Q108.108856,54.41417 107.849705,54.841768L86.606939,89.892332Q86.434393,90.177033 86.10517,90.12765L43.526984,83.740922Q43.32014,83.709895 43.152813,83.83539L0.8,115.6Q0,116.2 0,115.2Z" fill="#00bfff" stroke="none"/>
21
- </svg>
@@ -1,32 +0,0 @@
1
- """Test importing an SVG file.
2
-
3
- :author: Shay Hill
4
- :created: 2024-05-28
5
- """
6
-
7
- from pathlib import Path
8
- from svg_ultralight.import_svg import import_svg
9
- from lxml import etree
10
-
11
- _TEST_RESOURCES = Path(__file__).parent / "resources"
12
- _TEST_FILE = _TEST_RESOURCES / "arrow.svg"
13
-
14
-
15
-
16
- class TestImportSvg:
17
- def test_get_bbox(self):
18
- """Import an svg file as a BoundElement instance."""
19
- blem = import_svg(_TEST_FILE)
20
- assert blem.bbox.x == -3
21
- assert blem.bbox.y == -3
22
- assert blem.bbox.width == 128
23
- assert blem.bbox.height == 128
24
-
25
- def test_get_geometry(self):
26
- tree = etree.parse(_TEST_FILE)
27
- root = tree.getroot()
28
- blem = import_svg(_TEST_FILE)
29
- assert [x.tag for x in blem.elem] == [x.tag for x in root]
30
-
31
-
32
-
File without changes