svg-ultralight 0.42.0__tar.gz → 0.43.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 (60) hide show
  1. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/.pre-commit-config.yaml +5 -3
  2. {svg_ultralight-0.42.0/src/svg_ultralight.egg-info → svg_ultralight-0.43.0}/PKG-INFO +2 -1
  3. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/dev-requirements.txt +20 -12
  4. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/pyproject.toml +3 -2
  5. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/__init__.py +0 -2
  6. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/padded_text_initializers.py +7 -2
  7. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/font_tools/font_info.py +131 -1
  8. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/string_conversion.py +14 -143
  9. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0/src/svg_ultralight.egg-info}/PKG-INFO +2 -1
  10. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight.egg-info/requires.txt +1 -0
  11. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_string_conversion.py +18 -86
  12. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/.gitignore +0 -0
  13. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/README.md +0 -0
  14. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/experiments/encode_fonts3.py +0 -0
  15. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/experiments/font_css.py +0 -0
  16. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/setup.cfg +0 -0
  17. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/animate.py +0 -0
  18. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/__init__.py +0 -0
  19. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/bound_helpers.py +0 -0
  20. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/supports_bounds.py +0 -0
  21. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/type_bound_collection.py +0 -0
  22. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/type_bound_element.py +0 -0
  23. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/type_bounding_box.py +0 -0
  24. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/bounding_boxes/type_padded_text.py +0 -0
  25. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/constructors/__init__.py +0 -0
  26. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/constructors/new_element.py +0 -0
  27. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/font_tools/__init__.py +0 -0
  28. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/font_tools/comp_results.py +0 -0
  29. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/font_tools/globs.py +0 -0
  30. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/image_ops.py +0 -0
  31. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/inkscape.py +0 -0
  32. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/layout.py +0 -0
  33. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/main.py +0 -0
  34. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/metadata.py +0 -0
  35. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/nsmap.py +0 -0
  36. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/py.typed +0 -0
  37. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/query.py +0 -0
  38. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/root_elements.py +0 -0
  39. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/strings/__init__.py +0 -0
  40. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/strings/svg_strings.py +0 -0
  41. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/transformations.py +0 -0
  42. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight/unit_conversion.py +0 -0
  43. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight.egg-info/SOURCES.txt +0 -0
  44. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight.egg-info/dependency_links.txt +0 -0
  45. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/src/svg_ultralight.egg-info/top_level.txt +0 -0
  46. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/__init__.py +0 -0
  47. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/conftest.py +0 -0
  48. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/resources/arrow.svg +0 -0
  49. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_bounding.py +0 -0
  50. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_inkscape.py +0 -0
  51. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_layout.py +0 -0
  52. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_matrices.py +0 -0
  53. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_metadata.py +0 -0
  54. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_new_element.py +0 -0
  55. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_padded_text_initializers.py +0 -0
  56. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_padding.py +0 -0
  57. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_queries.py +0 -0
  58. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_root_elements.py +0 -0
  59. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tests/test_svg_ultralight.py +0 -0
  60. {svg_ultralight-0.42.0 → svg_ultralight-0.43.0}/tox.ini +0 -0
@@ -1,7 +1,7 @@
1
1
  ci:
2
2
  skip: [pyright]
3
3
 
4
- exclude: "tests"
4
+ exclude: "experiments"
5
5
 
6
6
  repos:
7
7
 
@@ -84,6 +84,7 @@ repos:
84
84
  # PGH003 Use specific rule codes when ignoring type issues
85
85
  # PLR0913 Too many arguments to function call
86
86
  # PTH108 os.unlink should be replaced by .unlink()
87
+ # SIM108 insists on ternary operator
87
88
  #
88
89
  # S101 Use of `assert` detected
89
90
  # S603 `subprocess` call: check for execution of untrusted input
@@ -94,15 +95,16 @@ repos:
94
95
  # BLE001 Use of `except Exception:` detected
95
96
  # ANN401 Any type disallowed
96
97
  # FLY002 Consider f-string instead of string join
98
+ # S311 Standard pseudo-random generator used
97
99
  rev: 'v0.11.11'
98
100
  hooks:
99
101
  - id: ruff
100
102
  name: "ruff-lint"
101
- exclude: "tests"
103
+ # exclude: "tests"
102
104
  args:
103
105
  - --target-version=py39
104
106
  - --select=ALL
105
- - --ignore=ANN201,ANN202,B905,COM812,D203,D213,I001,ISC003,N802,N806,PGH003,PLR0913,PTH108,S101,S603,PLR2004,S320,S301,B028,BLE001,ANN401,FLY002
107
+ - --ignore=ANN201,ANN202,B905,COM812,D203,D213,I001,ISC003,N802,N806,PGH003,PLR0913,PTH108,S101,S603,PLR2004,S320,S301,B028,BLE001,ANN401,FLY002,SIM108,S311
106
108
  - --fix
107
109
  - --fixable=RUF022
108
110
  - id: ruff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svg-ultralight
3
- Version: 0.42.0
3
+ Version: 0.43.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
@@ -11,6 +11,7 @@ Requires-Dist: fonttools
11
11
  Requires-Dist: lxml
12
12
  Requires-Dist: paragraphs
13
13
  Requires-Dist: pillow
14
+ Requires-Dist: svg-path-data
14
15
  Requires-Dist: types-lxml
15
16
  Requires-Dist: typing-extensions
16
17
  Provides-Extra: dev
@@ -8,7 +8,7 @@ argcomplete==3.6.2
8
8
  # via commitizen
9
9
  beautifulsoup4==4.13.4
10
10
  # via types-lxml
11
- cachetools==6.0.0
11
+ cachetools==6.1.0
12
12
  # via tox
13
13
  cfgv==3.4.0
14
14
  # via pre-commit
@@ -21,7 +21,7 @@ colorama==0.4.6
21
21
  # commitizen
22
22
  # pytest
23
23
  # tox
24
- commitizen==4.8.2
24
+ commitizen==4.8.3
25
25
  # via svg-ultralight (pyproject.toml)
26
26
  cssselect==1.3.0
27
27
  # via types-lxml
@@ -35,15 +35,17 @@ filelock==3.18.0
35
35
  # via
36
36
  # tox
37
37
  # virtualenv
38
- fonttools==4.58.1
38
+ fonttools==4.58.5
39
39
  # via svg-ultralight (pyproject.toml)
40
40
  identify==2.6.12
41
41
  # via pre-commit
42
+ importlib-metadata==8.7.0
43
+ # via commitizen
42
44
  iniconfig==2.1.0
43
45
  # via pytest
44
46
  jinja2==3.1.6
45
47
  # via commitizen
46
- lxml==5.4.0
48
+ lxml==6.0.0
47
49
  # via svg-ultralight (pyproject.toml)
48
50
  markupsafe==3.0.2
49
51
  # via jinja2
@@ -58,8 +60,10 @@ packaging==25.0
58
60
  # pytest
59
61
  # tox
60
62
  paragraphs==1.0.1
61
- # via svg-ultralight (pyproject.toml)
62
- pillow==11.2.1
63
+ # via
64
+ # svg-path-data
65
+ # svg-ultralight (pyproject.toml)
66
+ pillow==11.3.0
63
67
  # via svg-ultralight (pyproject.toml)
64
68
  platformdirs==4.3.8
65
69
  # via
@@ -73,11 +77,11 @@ pre-commit==4.2.0
73
77
  # via svg-ultralight (pyproject.toml)
74
78
  prompt-toolkit==3.0.51
75
79
  # via questionary
76
- pygments==2.19.1
80
+ pygments==2.19.2
77
81
  # via pytest
78
82
  pyproject-api==1.9.1
79
83
  # via tox
80
- pytest==8.4.0
84
+ pytest==8.4.1
81
85
  # via svg-ultralight (pyproject.toml)
82
86
  pyyaml==6.0.2
83
87
  # via
@@ -87,17 +91,19 @@ questionary==2.1.0
87
91
  # via commitizen
88
92
  soupsieve==2.7
89
93
  # via beautifulsoup4
90
- termcolor==2.5.0
94
+ svg-path-data==0.3.0
95
+ # via svg-ultralight (pyproject.toml)
96
+ termcolor==3.1.0
91
97
  # via commitizen
92
- tomlkit==0.13.2
98
+ tomlkit==0.13.3
93
99
  # via commitizen
94
- tox==4.26.0
100
+ tox==4.27.0
95
101
  # via svg-ultralight (pyproject.toml)
96
102
  types-html5lib==1.1.11.20250516
97
103
  # via types-lxml
98
104
  types-lxml==2025.3.30
99
105
  # via svg-ultralight (pyproject.toml)
100
- typing-extensions==4.14.0
106
+ typing-extensions==4.14.1
101
107
  # via
102
108
  # beautifulsoup4
103
109
  # svg-ultralight (pyproject.toml)
@@ -108,3 +114,5 @@ virtualenv==20.31.2
108
114
  # tox
109
115
  wcwidth==0.2.13
110
116
  # via prompt-toolkit
117
+ zipp==3.23.0
118
+ # via importlib-metadata
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "svg-ultralight"
3
- version = "0.42.0"
3
+ version = "0.43.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" }
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "lxml",
13
13
  "paragraphs",
14
14
  "pillow",
15
+ "svg-path-data",
15
16
  "types-lxml",
16
17
  "typing-extensions",
17
18
  ]
@@ -49,7 +50,7 @@ convention = "pep257"
49
50
 
50
51
  [tool.commitizen]
51
52
  name = "cz_conventional_commits"
52
- version = "0.42.0"
53
+ version = "0.43.0"
53
54
  tag_format = "$version"
54
55
  version_files = ["pyproject.toml:^version"]
55
56
  annotated_tag = true
@@ -51,7 +51,6 @@ from svg_ultralight.string_conversion import (
51
51
  format_attr_dict,
52
52
  format_number,
53
53
  format_numbers,
54
- format_numbers_in_string,
55
54
  )
56
55
  from svg_ultralight.transformations import (
57
56
  mat_apply,
@@ -75,7 +74,6 @@ __all__ = [
75
74
  "format_attr_dict",
76
75
  "format_number",
77
76
  "format_numbers",
78
- "format_numbers_in_string",
79
77
  "get_bounding_box",
80
78
  "get_bounding_boxes",
81
79
  "mat_apply",
@@ -122,12 +122,17 @@ def pad_text_ft(
122
122
  """
123
123
  attributes_ = format_attr_dict(**attributes)
124
124
  attributes_.update(get_svg_font_attributes(font))
125
- attributes_["font-size"] = attributes_.get("font-size", format_number(font_size))
126
125
 
127
- elem = new_element("text", text=text, **attributes_)
126
+ _ = attributes_.pop("font-size", None)
127
+ _ = attributes_.pop("font-family", None)
128
+ _ = attributes_.pop("font-style", None)
129
+ _ = attributes_.pop("font-weight", None)
130
+ _ = attributes_.pop("font-stretch", None)
131
+
128
132
  info = get_padded_text_info(
129
133
  font, text, font_size, ascent, descent, y_bounds_reference=y_bounds_reference
130
134
  )
135
+ elem = info.new_element(**attributes_)
131
136
  return PaddedText(elem, info.bbox, *info.padding, info.line_gap)
132
137
 
133
138
 
@@ -102,19 +102,101 @@ from contextlib import suppress
102
102
  from pathlib import Path
103
103
  from typing import TYPE_CHECKING, Any, cast
104
104
 
105
+ from fontTools.pens.basePen import BasePen
105
106
  from fontTools.pens.boundsPen import BoundsPen
106
107
  from fontTools.ttLib import TTFont
108
+ from svg_path_data import format_svgd_shortest, get_cpts_from_svgd, get_svgd_from_cpts
107
109
 
108
110
  from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
111
+ from svg_ultralight.constructors.new_element import new_element
109
112
  from svg_ultralight.font_tools.globs import DEFAULT_FONT_SIZE
113
+ from svg_ultralight.string_conversion import format_numbers
110
114
 
111
115
  if TYPE_CHECKING:
112
116
  import os
117
+ from collections.abc import Iterator
113
118
 
119
+ from lxml.etree import _Element as EtreeElement
114
120
 
115
121
  logging.getLogger("fontTools").setLevel(logging.ERROR)
116
122
 
117
123
 
124
+ def _split_into_quadratic(
125
+ *pts: tuple[float, float],
126
+ ) -> Iterator[tuple[tuple[float, float], tuple[float, float]]]:
127
+ """Connect a series of points with quadratic bezier segments.
128
+
129
+ :param points: a series of at least two (x, y) coordinates.
130
+ :return: an iterator of ((x, y), (x, y)) quadatic bezier control points (the
131
+ second and third points)
132
+
133
+ This is part of connecting a (not provided) current point to the last input
134
+ point. The other input points will be control points of a series of quadratic
135
+ Bezier curves. New Bezier curve endpoints will be created between these points.
136
+
137
+ given (B, C, D, E) (with A as the not-provided current point):
138
+ - [A, B, bc][1:]
139
+ - [bc, C, cd][1:]
140
+ - [cd, D, E][1:]
141
+ """
142
+ if len(pts) < 2:
143
+ msg = "At least two points are required."
144
+ raise ValueError(msg)
145
+ for prev_cp, next_cp in it.pairwise(pts[:-1]):
146
+ xs, ys = zip(prev_cp, next_cp)
147
+ midpnt = sum(xs) / 2, sum(ys) / 2
148
+ yield prev_cp, midpnt
149
+ yield pts[-2], pts[-1]
150
+
151
+
152
+ class PathPen(BasePen):
153
+ """A pen to collect svg path data commands from a glyph."""
154
+
155
+ def __init__(self, glyph_set: Any) -> None:
156
+ """Initialize the PathPen with a glyph set.
157
+
158
+ :param glyph_set: TTFont(path).getGlyphSet()
159
+ """
160
+ super().__init__(glyph_set)
161
+ self._cmds: list[str] = []
162
+
163
+ @property
164
+ def svgd(self) -> str:
165
+ """Return an svg path data string for the glyph."""
166
+ if not self._cmds:
167
+ return ""
168
+ svgd = format_svgd_shortest(" ".join(self._cmds))
169
+ return "M" + svgd[1:]
170
+
171
+ @property
172
+ def cpts(self) -> list[list[tuple[float, float]]]:
173
+ """Return as a list of lists of Bezier control points."""
174
+ return get_cpts_from_svgd(" ".join(self._cmds))
175
+
176
+ def moveTo(self, pt: tuple[float, float]) -> None:
177
+ """Move the current point to a new location."""
178
+ self._cmds.extend(("M", *map(str, pt)))
179
+
180
+ def lineTo(self, pt: tuple[float, float]) -> None:
181
+ """Add a line segment to the path."""
182
+ self._cmds.extend(("L", *map(str, pt)))
183
+
184
+ def curveTo(self, *pts: tuple[float, float]) -> None:
185
+ """Add a series of cubic bezier segments to the path."""
186
+ msg = "Cubic Bezier curves not implemented for getting svg path data."
187
+ raise NotImplementedError(msg)
188
+ self._cmds.extend(("Q", *map(str, it.chain(*pts))))
189
+
190
+ def qCurveTo(self, *pts: tuple[float, float]) -> None:
191
+ """Add a series of quadratic bezier segments to the path."""
192
+ for q_pts in _split_into_quadratic(*pts):
193
+ self._cmds.extend(("Q", *map(str, it.chain(*q_pts))))
194
+
195
+ def closePath(self):
196
+ """Close the current path."""
197
+ self._cmds.append("Z")
198
+
199
+
118
200
  class FTFontInfo:
119
201
  """Hide all the type kludging necessary to use fontTools."""
120
202
 
@@ -205,6 +287,26 @@ class FTFontInfo:
205
287
  msg = f"Character '{char}' not found in font '{self.path}'."
206
288
  raise ValueError(msg)
207
289
 
290
+ def get_char_svgd(self, char: str, dx: float = 0) -> str:
291
+ """Return the svg path data for a glyph.
292
+
293
+ :param char: The character to get the svg path data for.
294
+ :param dx: An optional x translation to apply to the glyph.
295
+ :return: The svg path data for the character.
296
+ """
297
+ glyph_set = self.font.getGlyphSet()
298
+ glyph_name = self.font.getBestCmap().get(ord(char))
299
+ path_pen = PathPen(glyph_set)
300
+ _ = glyph_set[glyph_name].draw(path_pen)
301
+ svgd = path_pen.svgd
302
+ if not dx or not svgd:
303
+ return svgd
304
+ cpts = path_pen.cpts
305
+ for i, curve in enumerate(cpts):
306
+ cpts[i][:] = [(x + dx, y) for x, y in curve]
307
+ svgd = format_svgd_shortest(get_svgd_from_cpts(cpts))
308
+ return "M" + svgd[1:]
309
+
208
310
  def get_char_bounds(self, char: str) -> tuple[int, int, int, int]:
209
311
  """Return the min and max x and y coordinates of a glyph.
210
312
 
@@ -217,7 +319,6 @@ class FTFontInfo:
217
319
  glyph_name = self.font.getBestCmap().get(ord(char))
218
320
  bounds_pen = BoundsPen(glyph_set)
219
321
  _ = glyph_set[glyph_name].draw(bounds_pen)
220
-
221
322
  pen_bounds = cast("None | tuple[int, int, int, int]", bounds_pen.bounds)
222
323
  if pen_bounds is None:
223
324
  return 0, 0, 0, 0
@@ -264,6 +365,25 @@ class FTFontInfo:
264
365
  max_y = max(max_ys)
265
366
  return min_x, min_y, max_x, max_y
266
367
 
368
+ def get_text_svgd(self, text: str, dx: float = 0) -> str:
369
+ """Return the svg path data for a string.
370
+
371
+ :param text: The text to get the svg path data for.
372
+ :param dx: An optional x translation to apply to the entire text.
373
+ :return: The svg path data for the text.
374
+ """
375
+ hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
376
+ svgd = ""
377
+ char_dx = dx
378
+ for c_this, c_next in it.pairwise(text):
379
+ this_name = self.get_glyph_name(c_this)
380
+ next_name = self.get_glyph_name(c_next)
381
+ svgd += self.get_char_svgd(c_this, char_dx)
382
+ char_dx += hmtx[this_name][0]
383
+ char_dx += self.kern_table.get((this_name, next_name), 0)
384
+ svgd += self.get_char_svgd(text[-1], char_dx)
385
+ return svgd
386
+
267
387
  def get_text_bbox(self, text: str) -> BoundingBox:
268
388
  """Return the BoundingBox of a string svg coordinates.
269
389
 
@@ -333,6 +453,16 @@ class FTTextInfo:
333
453
  """
334
454
  return self.font_size / self.font.units_per_em
335
455
 
456
+ def new_element(self, **attributes: str | float) -> EtreeElement:
457
+ """Return an svg text element with the appropriate font attributes."""
458
+ matrix_vals = (self.scale, 0, 0, -self.scale, 0, 0)
459
+ matrix = f"matrix({' '.join(format_numbers(matrix_vals))})"
460
+ attributes["transform"] = matrix
461
+ stroke_width = attributes.get("stroke-width")
462
+ if stroke_width:
463
+ attributes["stroke-width"] = float(stroke_width) / self.scale
464
+ return new_element("path", d=self.font.get_text_svgd(self.text), **attributes)
465
+
336
466
  @property
337
467
  def bbox(self) -> BoundingBox:
338
468
  """Return the bounding box of the text.
@@ -11,10 +11,10 @@ from __future__ import annotations
11
11
 
12
12
  import binascii
13
13
  import re
14
- from contextlib import suppress
15
14
  from enum import Enum
16
15
  from typing import TYPE_CHECKING, cast
17
16
 
17
+ import svg_path_data
18
18
  from lxml import etree
19
19
 
20
20
  from svg_ultralight.nsmap import NSMAP
@@ -27,109 +27,16 @@ if TYPE_CHECKING:
27
27
  )
28
28
 
29
29
 
30
- _MAYBE_NEG = r"(?:(?P<negative>-?))"
31
- _MAYBE_INT = r"(?:(?P<integer>\d+?))"
32
- _MAYBE_FRACTION = r"(?:\.(?P<fraction>\d+))?"
33
- _MAYBE_EXP = r"(?:[eE](?P<exponent>[+-]?\d+))?"
30
+ def format_number(num: float | str, resolution: int | None = 6) -> str:
31
+ """Format a number into an svg-readable float string with resolution = 6.
34
32
 
35
- # Split a float (fp or exponential) into its components. All components are optional.
36
- FLOAT_PATTERN = re.compile(rf"{_MAYBE_NEG}{_MAYBE_INT}{_MAYBE_FRACTION}{_MAYBE_EXP}")
37
-
38
-
39
- def _split_float_str(num: str | float) -> tuple[str, str, str, int]:
40
- """Split a float string into its sign, integer part, fractional part, and exponent.
41
-
42
- :param num_str: A string representing the number (e.g., '1.23e+03').
43
- :return: A tuple containing the integer part, fractional part, and exponent.
44
- """
45
- if float(num) == 0:
46
- return "", "", "", 0
47
- num_str = str(num)
48
- groups = FLOAT_PATTERN.fullmatch(num_str)
49
- if not groups:
50
- msg = "Invalid number string: {num_str}."
51
- raise ValueError(msg.format(num_str=num_str))
52
-
53
- sign = groups["negative"] or ""
54
- integer = (groups["integer"] or "").lstrip("0")
55
- fraction = (groups["fraction"] or "").rstrip("0")
56
- exponent = int(groups["exponent"] or 0)
57
- return sign, integer, fraction, exponent
58
-
59
-
60
- def _format_as_fixed_point(num: str | float) -> str:
61
- """Format a number in fixed-point notation.
62
-
63
- :param exp_str: A string representing the number in exponential notation
64
- (e.g., '1.23e+03') or just a number.
65
- :return: A string representing the number in fixed-point notation.
66
- """
67
- sign, integer, fraction, exponent = _split_float_str(num)
68
- if exponent > 0:
69
- fraction = fraction.ljust(exponent, "0")
70
- integer += fraction[:exponent]
71
- fraction = fraction[exponent:]
72
- elif exponent < 0:
73
- integer = integer.rjust(-exponent, "0")
74
- fraction = integer[exponent:] + fraction
75
- integer = integer[:exponent]
76
-
77
- fraction = "." + fraction if fraction else ""
78
- return f"{sign}{integer}{fraction}" or "0"
79
-
80
-
81
- def _format_as_exponential(num: str | float) -> str:
82
- """Convert a number in fixed-point notation (as a string) to exponential notation.
83
-
84
- :param num_str: A string representing the number in fixed-point notation
85
- (e.g., '123000') or just a number.
86
- :return: A string representing the number in exponential notation.
33
+ :param num: number to format (string or float)
34
+ :param resolution: number of digits after the decimal point, defaults to 6. None
35
+ to match behavior of `str(num)`.
36
+ :return: string representation of the number with six digits after the decimal
37
+ (if in fixed-point notation). Will return exponential notation when shorter.
87
38
  """
88
- sign, integer, fraction, exponent = _split_float_str(num)
89
- if len(integer) > 1:
90
- exponent += len(integer) - 1
91
- fraction = (integer[1:] + fraction).rstrip("0")
92
- integer = integer[0]
93
- elif not integer and fraction:
94
- leading_zeroes = len(fraction) - len(fraction.lstrip("0"))
95
- exponent -= leading_zeroes + 1
96
- integer = fraction[leading_zeroes]
97
- fraction = fraction[leading_zeroes + 1 :]
98
-
99
- fraction = "." + fraction if fraction else ""
100
- exp_str = f"e{exponent}" if exponent else ""
101
- return f"{sign}{integer}{fraction}{exp_str}" or "0"
102
-
103
-
104
- def format_number(num: float | str, precision: float | None = 6) -> str:
105
- """Format strings at limited precision.
106
-
107
- :param num: anything that can print as a float.
108
- :param precision: number of digits after the decimal point, default 6. You can
109
- also pass None for no precision limit. This may produce some long strings,
110
- but will retain as much information as possible when converting between
111
- floats and strings.
112
- :return: str
113
-
114
- I've read articles that recommend no more than four digits before and two digits
115
- after the decimal point to ensure good svg rendering. I'm being generous and
116
- giving six. Mostly to eliminate exponential notation, but I'm "rstripping" the
117
- strings to reduce filesize and increase readability
118
-
119
- * reduce fp precision to (default) 6 digits
120
- * remove trailing zeros
121
- * remove trailing decimal point
122
- * remove leading 0 in "0.123"
123
- * convert "-0" to "0"
124
- * use shorter of exponential or fixed-point notation
125
- """
126
- if precision is not None:
127
- num = f"{float(num):.{precision}f}"
128
- exponential_str = _format_as_exponential(num)
129
- fixed_point_str = _format_as_fixed_point(num)
130
- if len(exponential_str) < len(fixed_point_str):
131
- return exponential_str
132
- return fixed_point_str
39
+ return svg_path_data.format_number(num, resolution=resolution)
133
40
 
134
41
 
135
42
  def format_numbers(
@@ -143,44 +50,6 @@ def format_numbers(
143
50
  return [format_number(num) for num in nums]
144
51
 
145
52
 
146
- def _is_float_or_float_str(data: float | str) -> bool:
147
- """Check if a string is a float.
148
-
149
- :param data: string to check
150
- :return: bool
151
- """
152
- try:
153
- _ = float(data)
154
- except ValueError:
155
- return False
156
- else:
157
- return True
158
-
159
-
160
- def format_numbers_in_string(data: float | str) -> str:
161
- """Find and format floats in a string.
162
-
163
- :param data: string with floats or a float value
164
- :return: string with floats formatted to limited precision
165
-
166
- Works as a more robust version of format_number. Will correctly handle input
167
- floats in exponential notation. This should work for any parameter value in an
168
- svg except 'text', 'id' and for any other value except hex color codes. The
169
- function will fail with input strings like 'ice3.14bucket', because 'e3.14' will
170
- be identified as a float. SVG param values will not have such strings, but the
171
- 'text' attribute could. This function will not handle that case. Do not attempt
172
- to reformat 'text' attribute values.
173
- """
174
- with suppress(ValueError):
175
- # try as a regular number to strip spaces from simple float strings
176
- return format_number(data)
177
- if str(data).startswith("#"):
178
- return str(data)
179
- words = re.split(r"([^\d.eE-]+)", str(data))
180
- words = [format_number(w) if _is_float_or_float_str(w) else w for w in words]
181
- return "".join(words)
182
-
183
-
184
53
  def _fix_key_and_format_val(key: str, val: str | float) -> tuple[str, str]:
185
54
  """Format one key, value pair for an svg element.
186
55
 
@@ -213,10 +82,12 @@ def _fix_key_and_format_val(key: str, val: str | float) -> tuple[str, str]:
213
82
  else:
214
83
  key_ = key.rstrip("_").replace("_", "-")
215
84
 
216
- if key_ in {"id", "text"}:
217
- return key_, str(val)
85
+ if isinstance(val, (int, float)):
86
+ val_ = format_number(val)
87
+ else:
88
+ val_ = val
218
89
 
219
- return key_, format_numbers_in_string(val)
90
+ return key_, val_
220
91
 
221
92
 
222
93
  def format_attr_dict(**attributes: str | float) -> dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svg-ultralight
3
- Version: 0.42.0
3
+ Version: 0.43.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
@@ -11,6 +11,7 @@ Requires-Dist: fonttools
11
11
  Requires-Dist: lxml
12
12
  Requires-Dist: paragraphs
13
13
  Requires-Dist: pillow
14
+ Requires-Dist: svg-path-data
14
15
  Requires-Dist: types-lxml
15
16
  Requires-Dist: typing-extensions
16
17
  Provides-Extra: dev
@@ -3,6 +3,7 @@ fonttools
3
3
  lxml
4
4
  paragraphs
5
5
  pillow
6
+ svg-path-data
6
7
  types-lxml
7
8
  typing-extensions
8
9
 
@@ -5,19 +5,18 @@
5
5
  """
6
6
 
7
7
  # pyright: reportPrivateUsage=false
8
- import random
9
8
  import itertools as it
9
+ import random
10
10
  import string
11
11
  from collections.abc import Iterator
12
- from decimal import Decimal
13
12
 
14
13
  import pytest
15
14
 
16
15
  import svg_ultralight.string_conversion as mod
17
16
 
18
-
19
17
  _FLOAT_ITERATIONS = 100
20
18
 
19
+
21
20
  def random_floats() -> Iterator[float]:
22
21
  """Yield random float values within(-ish) precision limits.
23
22
 
@@ -26,6 +25,7 @@ def random_floats() -> Iterator[float]:
26
25
  for _ in range(_FLOAT_ITERATIONS):
27
26
  yield random.uniform(1e-20, 1e20)
28
27
 
28
+
29
29
  def low_numbers() -> Iterator[float]:
30
30
  """Yield random float values below precision limits.
31
31
 
@@ -34,13 +34,14 @@ def low_numbers() -> Iterator[float]:
34
34
  for _ in range(_FLOAT_ITERATIONS):
35
35
  yield random.uniform(1e-25, 1e-24)
36
36
 
37
+
37
38
  def high_numbers() -> Iterator[float]:
38
39
  """Yield random float values above precision limits.
39
40
 
40
41
  Value may exceed the precision limits of the system.
41
42
  """
42
43
  for _ in range(_FLOAT_ITERATIONS):
43
- yield random.uniform(1e+24, 1e+25)
44
+ yield random.uniform(1e24, 1e25)
44
45
 
45
46
 
46
47
  def random_ints() -> Iterator[int]:
@@ -49,12 +50,15 @@ def random_ints() -> Iterator[int]:
49
50
  for _ in range(_FLOAT_ITERATIONS):
50
51
  yield random.randint(-big_int, big_int)
51
52
 
53
+
52
54
  def random_numbers() -> Iterator[float]:
53
55
  """Yield random numbers values."""
54
56
  return it.chain(random_floats(), low_numbers(), high_numbers(), random_ints())
55
57
 
56
58
 
57
59
  class TestFormatNuber:
60
+ """Test format_number function."""
61
+
58
62
  def test_negative_zero(self):
59
63
  """Remove "-" from "-0"."""
60
64
  assert mod.format_number(-0.0000000001) == "0"
@@ -64,9 +68,9 @@ class TestFormatNuber:
64
68
  assert mod.format_number(1.0000000001) == "1"
65
69
 
66
70
 
67
-
68
-
69
71
  class TestFormatNumbers:
72
+ """Test format_numbers function."""
73
+
70
74
  def test_empty(self):
71
75
  """Return empty list."""
72
76
  assert mod.format_numbers([]) == []
@@ -75,93 +79,18 @@ class TestFormatNumbers:
75
79
  """Return list of formatted strings."""
76
80
  assert mod.format_numbers([1, 2, 3]) == ["1", "2", "3"]
77
81
 
78
- @pytest.mark.parametrize("num", random_numbers())
79
- def test_exp_vs_fp_notation(self, num: float):
80
- """Exponential and fp notation have the same value.
81
-
82
- The first assertion is a sanity check.
83
- """
84
- expect = float(str(num))
85
- assert expect == float(Decimal(num))
86
- assert expect == float(mod._format_as_fixed_point(str(num)))
87
- assert expect == float(mod._format_as_exponential(str(num)))
88
-
89
- @pytest.mark.parametrize("num", random_numbers())
90
- def test_exponent_integer_part_is_len_1_or_stripped(self, num: float):
91
- """Integer part is one digit."""
92
- exponential = mod._format_as_exponential(num)
93
- # Result is exactly one digit
94
- if "." not in exponential:
95
- assert exponential.lstrip("-").isdigit()
96
- # Result is nothing before decimal or one non-zero digit before decimal.
97
- integer = exponential.split(".")[0].lstrip("-")
98
- assert not integer or integer in "123456789"
99
-
100
- class TestFormatNumbersInString:
101
- def test_empty(self):
102
- """Return empty string.."""
103
- assert mod.format_numbers_in_string("") == ""
104
-
105
- def test_no_numbers(self):
106
- """Return string with no changes."""
107
- assert mod.format_numbers_in_string("hello") == "hello"
108
-
109
- def test_numbers(self):
110
- """Return string with numbers formatted."""
111
- assert mod.format_numbers_in_string("1.0000000001") == "1"
112
-
113
- def test_skip_text(self):
114
- """Skip text."""
115
- key = "text"
116
- val = "1.000000000000000000"
117
- assert mod._fix_key_and_format_val(key, val) == (key, val)
118
-
119
- def test_skip_id(self):
120
- """Skip text."""
121
- key = "id"
122
- val = "1.000000000000000000"
123
- assert mod._fix_key_and_format_val(key, val) == (key, val)
124
-
125
- def test_skip_hex_colors(self):
126
- """Skip hex colors."""
127
- assert mod.format_numbers_in_string("#000000") == "#000000"
128
-
129
82
 
130
83
  class TestFormatAttrDict:
84
+ """Test format_attr_dict function."""
85
+
131
86
  def test_float(self):
132
87
  """Return string of float."""
133
88
  assert mod.format_attr_dict(x=1.0) == {"x": "1"}
134
89
 
135
- def test_float_string(self):
136
- """Return string of float."""
137
- assert mod.format_attr_dict(x="1.0") == {"x": "1"}
138
-
139
90
  def test_exponential_float(self):
140
- """Return string of float."""
141
- assert mod.format_attr_dict(x="1.0e-10") == {"x": "0"}
142
-
143
- def test_exponential_float_string(self):
144
91
  """Return string of float."""
145
92
  assert mod.format_attr_dict(x=1.0e-10) == {"x": "0"}
146
93
 
147
- def test_datastring(self):
148
- """Find and format floats in a datastring."""
149
- assert mod.format_attr_dict(d="M1.0,0 Q -0,.33333333333 1,2z") == {
150
- "d": "M1,0 Q 0,.333333 1,2z"
151
- }
152
-
153
- def test_datastring_with_exponential_number(self):
154
- """Find and format floats in a datastring."""
155
- assert mod.format_attr_dict(d="M1.0,1.0e-10 Q -0,.33333333 1,2z") == {
156
- "d": "M1,0 Q 0,.333333 1,2z"
157
- }
158
-
159
- def test_format_string(self):
160
- """Format floats in a format SVG attribute string."""
161
- assert mod.format_attr_dict(transform="translate(1.0, -0) scale(1.0, 1.0)") == {
162
- "transform": "translate(1, 0) scale(1, 1)"
163
- }
164
-
165
94
  def test_trailing_underscore(self):
166
95
  """Remove trailing underscore from key."""
167
96
  assert mod.format_attr_dict(x_=1) == {"x": "1"}
@@ -181,16 +110,19 @@ def _generate_random_utf8_string():
181
110
  + string.whitespace
182
111
  + additional_chars
183
112
  )
184
- random_string = "".join(random.choice(characters) for _ in range(length))
185
- return random_string
113
+ return "".join(random.choice(characters) for _ in range(length))
114
+
186
115
 
187
116
  @pytest.fixture(params=range(100))
188
117
  def random_utf8_string(request: pytest.FixtureRequest) -> str:
118
+ """Generate a random UTF-8 string for testing."""
189
119
  _ = request.param
190
120
  return _generate_random_utf8_string()
191
121
 
192
122
 
193
123
  class TestEncodeCssClassName:
124
+ """Test encode_to_css_class_name and decode_from_css_class_name functions."""
125
+
194
126
  def test_encode_decode(self, random_utf8_string: str):
195
127
  """Encode - decode will return the original string."""
196
128
  encoded = mod.encode_to_css_class_name(random_utf8_string)
@@ -202,4 +134,4 @@ class TestEncodeCssClassName:
202
134
  def test_encode_valid(self, random_utf8_string: str):
203
135
  """All encoded strings will be ascii, _, and -."""
204
136
  encoded = mod.encode_to_css_class_name(random_utf8_string)
205
- assert all(c.isascii() or c in {'_', '-'} for c in encoded)
137
+ assert all(c.isascii() or c in {"_", "-"} for c in encoded)
File without changes