svg-ultralight 0.40.1__tar.gz → 0.42.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.40.1/src/svg_ultralight.egg-info → svg_ultralight-0.42.0}/PKG-INFO +1 -1
  2. svg_ultralight-0.42.0/experiments/encode_fonts3.py +248 -0
  3. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/pyproject.toml +2 -2
  4. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/font_tools/font_info.py +4 -4
  5. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/string_conversion.py +89 -6
  6. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0/src/svg_ultralight.egg-info}/PKG-INFO +1 -1
  7. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight.egg-info/SOURCES.txt +2 -1
  8. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_layout.py +3 -3
  9. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_new_element.py +1 -1
  10. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_string_conversion.py +69 -4
  11. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/.gitignore +0 -0
  12. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/.pre-commit-config.yaml +0 -0
  13. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/README.md +0 -0
  14. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/dev-requirements.txt +0 -0
  15. {svg_ultralight-0.40.1/src/svg_ultralight/font_tools → svg_ultralight-0.42.0/experiments}/font_css.py +0 -0
  16. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/setup.cfg +0 -0
  17. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/__init__.py +0 -0
  18. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/animate.py +0 -0
  19. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/__init__.py +0 -0
  20. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/bound_helpers.py +0 -0
  21. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/padded_text_initializers.py +0 -0
  22. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/supports_bounds.py +0 -0
  23. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/type_bound_collection.py +0 -0
  24. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/type_bound_element.py +0 -0
  25. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/type_bounding_box.py +0 -0
  26. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/bounding_boxes/type_padded_text.py +0 -0
  27. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/constructors/__init__.py +0 -0
  28. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/constructors/new_element.py +0 -0
  29. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/font_tools/__init__.py +0 -0
  30. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/font_tools/comp_results.py +0 -0
  31. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/font_tools/globs.py +0 -0
  32. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/image_ops.py +0 -0
  33. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/inkscape.py +0 -0
  34. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/layout.py +0 -0
  35. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/main.py +0 -0
  36. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/metadata.py +0 -0
  37. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/nsmap.py +0 -0
  38. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/py.typed +0 -0
  39. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/query.py +0 -0
  40. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/root_elements.py +0 -0
  41. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/strings/__init__.py +0 -0
  42. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/strings/svg_strings.py +0 -0
  43. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/transformations.py +0 -0
  44. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight/unit_conversion.py +0 -0
  45. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight.egg-info/dependency_links.txt +0 -0
  46. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight.egg-info/requires.txt +0 -0
  47. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/src/svg_ultralight.egg-info/top_level.txt +0 -0
  48. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/__init__.py +0 -0
  49. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/conftest.py +0 -0
  50. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/resources/arrow.svg +0 -0
  51. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_bounding.py +0 -0
  52. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_inkscape.py +0 -0
  53. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_matrices.py +0 -0
  54. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_metadata.py +0 -0
  55. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_padded_text_initializers.py +0 -0
  56. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_padding.py +0 -0
  57. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_queries.py +0 -0
  58. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_root_elements.py +0 -0
  59. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tests/test_svg_ultralight.py +0 -0
  60. {svg_ultralight-0.40.1 → svg_ultralight-0.42.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svg-ultralight
3
- Version: 0.40.1
3
+ Version: 0.42.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
@@ -0,0 +1,248 @@
1
+ """Deterministically encode a font into a base64 string for CSS.
2
+
3
+ This works fine for a browser, but there is little point. There is no way to
4
+ rasterize a css file with an embedded font like this. Learned some things doing it,
5
+ but Inkscape, Gimp, and everything else I've tried *except* a browser will ignore an
6
+ embedded or locally linked file in a `<style>` element.
7
+
8
+ :author: Shay Hill
9
+ :created: 2025-06-15
10
+ """
11
+
12
+ # pyright: reportMissingTypeStubs = false
13
+ # pyright: reportUnknownMemberType = false
14
+ # pyright: reportUnknownVariableType = false
15
+
16
+ from __future__ import annotations
17
+
18
+ import cssutils
19
+ import base64
20
+ import os
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from svg_ultralight.string_conversion import (
25
+ encode_to_css_class_name,
26
+ decode_from_css_class_name,
27
+ )
28
+
29
+ from fontTools.subset import Subsetter
30
+ from fontTools.ttLib import TTFont
31
+ from fontTools.ttLib.woff2 import compress
32
+ import string
33
+ import warnings
34
+ from typing import Iterable, TYPE_CHECKING
35
+
36
+ if TYPE_CHECKING:
37
+ from lxml.etree import (
38
+ _Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
39
+ )
40
+
41
+
42
+ _WESTERN_UTF8_CHAR_SETS = [
43
+ string.ascii_lowercase,
44
+ string.ascii_uppercase,
45
+ string.digits,
46
+ string.punctuation,
47
+ "áÁéÉíÍóÓúÚñÑäÄëËïÏöÖüÜçÇàÀèÈìÌòÒùÙâÂêÊîÎôÔûÛãÃõÕåÅæÆøØœŒßÿŸ",
48
+ ]
49
+
50
+
51
+ def get_robust_char_subset(
52
+ text: str, char_sets: Iterable[Iterable[str]] = _WESTERN_UTF8_CHAR_SETS
53
+ ) -> str | None:
54
+ """Infer a subset of characters likely to be used from a sample text.
55
+
56
+ :param text: Sample text to infer the subset from.
57
+ :param char_sets: List of character sets to use for inference. Each set is a
58
+ string of characters. The default is a list of common western UTF-8
59
+ character sets. Include all characters from any set that is used in the text.
60
+ :return: A string of characters that are likely to be used in the text. Default
61
+ subsets to include are lowercase, uppercase, digits, punctuation, and a set
62
+ of common western diacritics. Space is always included.
63
+
64
+ This function is for selecting which characters will be encoded inside a css
65
+ class--which is itself inside an svg file.
66
+
67
+ Fonts can be large. The smallest solution is to only encode the characters used
68
+ in the text, but that can be inflexible. A robust subset allows characters that
69
+ are similar to the text, so there is a little room to correct spelling or alter
70
+ the text.
71
+ """
72
+ char_sets_ = [set(x) for x in char_sets]
73
+ chars_known = {" "}.union(*char_sets)
74
+ chars_used = set(text)
75
+ if chars_used - chars_known:
76
+ warnings.warn("Cannot create subset char sets. Returning None.")
77
+ return None
78
+
79
+ subset = {" "}
80
+ for char_set in char_sets_:
81
+ if chars_used & char_set:
82
+ subset |= char_set
83
+ return "".join(sorted(subset))
84
+
85
+
86
+ def font_to_woff2(
87
+ font_path: str | os.PathLike[str],
88
+ woff2_path: str | os.PathLike[str],
89
+ subset: str | None = None,
90
+ ) -> None:
91
+ """Convert a (subset of a) font to woff2.
92
+
93
+ :param font_path: Path to the original font file. Ttf and otf both work, but otf
94
+ may show warnings (implemented as logging messages in fontTools. I have not
95
+ silenced these. It may be a better idea to avoid subsetting otf fonts.
96
+ :param woff2_path: Path to output.
97
+ :param subset: String of characters to include in the subset. If None, the entire
98
+ font is used.
99
+ """
100
+ font = TTFont(font_path, recalcTimestamp=False)
101
+ font.flavor = "woff2"
102
+
103
+ if subset is not None:
104
+ subsetter = Subsetter()
105
+ unicodes = set(map(ord, subset))
106
+ subsetter.populate(unicodes=unicodes)
107
+ subsetter.subset(font)
108
+
109
+ font.save(woff2_path)
110
+
111
+
112
+ def encode_font_to_woff2_base64(
113
+ font_path: str | os.PathLike[str], subset: str | None = None
114
+ ) -> str:
115
+ """Encode a WOFF2 font file to a base64 string useable in CSS.
116
+
117
+ :param font_path: Path to the original font file. Ttf and otf both work, but otf
118
+ may show warnings (implemented as logging messages in fontTools. I have not
119
+ silenced these. It may be a better idea to avoid subsetting otf fonts.
120
+ :param subset: String of characters to include in the subset. If None, the entire
121
+ font is used.
122
+ """
123
+ with tempfile.NamedTemporaryFile(suffix=".woff2", delete=False) as f:
124
+ woff2_path = f.name
125
+ with tempfile.NamedTemporaryFile(suffix=".woff2", delete=False) as f:
126
+ woff2_compressed = f.name
127
+
128
+ try:
129
+ font_to_woff2(font_path, woff2_path, subset)
130
+ compress(woff2_path, woff2_compressed)
131
+ with open(woff2_compressed, "rb") as woff2_file:
132
+ woff2_data = woff2_file.read()
133
+ return base64.b64encode(woff2_data).decode("utf-8")
134
+ finally:
135
+ os.unlink(woff2_path)
136
+ os.unlink(woff2_compressed)
137
+
138
+
139
+ def _find_font_file(name: str, *font_dirs: str | os.PathLike[str]) -> Path | None:
140
+ """Find a font file in the given directories.
141
+
142
+ :param name: The name of the font file to find.
143
+ :param font_dirs: Directories to search for the font file.
144
+ :return: The path to the font file if found, otherwise None.
145
+ """
146
+ if not font_dirs:
147
+ return None
148
+ for file in Path(font_dirs[0]).glob("*"):
149
+ if file.is_file() and file.name == name:
150
+ return Path(file)
151
+ return _find_font_file(name, *font_dirs[1:])
152
+
153
+
154
+ def encode_local_fonts(root: EtreeElement, *font_dirs: str | os.PathLike[str]) -> None:
155
+ """Encode all local fonts in the given SVG root element.
156
+
157
+ :param root: The root element of the SVG document.
158
+ :param font_dirs: directories to search for local fonts.
159
+ """
160
+ font2text: dict[str, set[str]] = {}
161
+ for elem in root.iterdescendants("text"):
162
+ elem_class = elem.attrib.get("class", "")
163
+ if elem_class[-7:].lower() not in {"_2e_ttf", "_2e_otf"}:
164
+ continue
165
+ _ = font2text.setdefault(elem_class, set())
166
+ font2text[elem_class] |= set(elem.text or "")
167
+
168
+ if not font2text:
169
+ return
170
+
171
+ encoded: list[tuple[str, str]] = []
172
+ for font_class, chars in font2text.items():
173
+ font_name = decode_from_css_class_name(font_class)
174
+ font_file = _find_font_file(font_name, *font_dirs)
175
+ if font_file is None:
176
+ msg = f"Font file '{font_name}' not found in specified directories."
177
+ raise FileNotFoundError(msg)
178
+
179
+ base64 = encode_font_to_woff2_base64(font_file, subset="".join(chars))
180
+ encoded.append((font_class, base64))
181
+
182
+ style = root.find("style")
183
+ if style is None:
184
+ style = new_element("style", type="text/css")
185
+ root.insert(0, style)
186
+ css = style.text or ""
187
+ stylesheet = cssutils.parseString(css)
188
+
189
+ for font_class, base64 in encoded:
190
+ font_face_rule = cssutils.css.CSSFontFaceRule()
191
+ font_face_rule.style = cssutils.css.CSSStyleDeclaration()
192
+ font_face_rule.style["font-family"] = f'{font_class}'
193
+ font_face_rule.style["src"] = f"url('data:font/woff2;base64,{base64}') format('woff2')"
194
+ stylesheet.add(font_face_rule)
195
+
196
+ style_rule = cssutils.css.CSSStyleRule(selectorText=f".{font_class}")
197
+ style_rule.style = cssutils.css.CSSStyleDeclaration()
198
+ style_rule.style["font-family"] = f'"{font_class}"'
199
+ stylesheet.add(style_rule)
200
+
201
+ style.text = stylesheet.cssText.decode("utf-8")
202
+
203
+
204
+ from svg_ultralight.main import new_svg_root
205
+ from svg_ultralight.constructors import new_element, new_sub_element
206
+
207
+ root = new_svg_root(x_=0, y_=-10, width_=100, height_=100)
208
+
209
+ class_ = encode_to_css_class_name("AGENCYB.TTF")
210
+ for i in range(6):
211
+ _ = new_sub_element(root, "text", text=f"Hello World {i}", class_=class_)
212
+
213
+
214
+ FONTS_DIRS = [
215
+ Path(r"C:\Windows\Fonts"),
216
+ Path(r"C:\Users\shaya\AppData\Local\Microsoft\Windows\Fonts"),
217
+ ]
218
+
219
+ encode_local_fonts(root, *FONTS_DIRS)
220
+
221
+ from svg_ultralight.main import write_svg
222
+
223
+ # _ = write_svg("temp.svg", root)
224
+
225
+ import time
226
+
227
+ # Example usage:
228
+ from pathlib import Path
229
+ import random
230
+
231
+ # input_font = str(Path("C:/Windows/Fonts/AGENCYB.TTF"))
232
+ # aaa = None
233
+ # bbb = None
234
+ # for i in range(10):
235
+ # if i % 3 == 0:
236
+ # chars = "".join(list(set(input_font)))
237
+ # elif i % 3 == 1:
238
+ # chars = get_robust_char_subset(input_font)
239
+ # else:
240
+ # chars = None
241
+ # print(chars)
242
+ # result = encode_woff2_to_base64(input_font, chars)
243
+ # bbb = result
244
+ # aaa = aaa or bbb
245
+ # print(aaa == bbb)
246
+ # print(len(bbb))
247
+ # aaa = bbb
248
+ # time.sleep(1) # Sleep for 1 second to avoid rapid file creation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "svg-ultralight"
3
- version = "0.40.1"
3
+ version = "0.42.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" }
@@ -49,7 +49,7 @@ convention = "pep257"
49
49
 
50
50
  [tool.commitizen]
51
51
  name = "cz_conventional_commits"
52
- version = "0.40.1"
52
+ version = "0.42.0"
53
53
  tag_format = "$version"
54
54
  version_files = ["pyproject.toml:^version"]
55
55
  annotated_tag = true
@@ -383,7 +383,7 @@ class FTTextInfo:
383
383
  @property
384
384
  def bpad(self) -> float:
385
385
  """Return the bottom padding for the text."""
386
- return self.descent - self.bbox.y2
386
+ return -self.descent - self.bbox.y2
387
387
 
388
388
  @property
389
389
  def lpad(self) -> float:
@@ -433,8 +433,8 @@ def get_padded_text_info(
433
433
  :param font_size: the font size to use.
434
434
  :param ascent: the ascent of the font. If not provided, it will be calculated
435
435
  from the font file.
436
- :param descent: the descent of the font. If not provided, it will be calculated
437
- from the font file.
436
+ :param descent: the descent of the font, usually a negative number. If not
437
+ provided, it will be calculated from the font file.
438
438
  :param y_bounds_reference: optional character or string to use as a reference
439
439
  for the ascent and descent. If provided, the ascent and descent will be the y
440
440
  extents of the capline reference. This argument is provided to mimic the
@@ -447,7 +447,7 @@ def get_padded_text_info(
447
447
  if y_bounds_reference:
448
448
  capline_info = FTTextInfo(font_info, y_bounds_reference, font_size)
449
449
  ascent = -capline_info.bbox.y
450
- descent = capline_info.bbox.y2
450
+ descent = -capline_info.bbox.y2
451
451
 
452
452
  return FTTextInfo(font_info, text, font_size, ascent, descent)
453
453
 
@@ -27,10 +27,88 @@ if TYPE_CHECKING:
27
27
  )
28
28
 
29
29
 
30
- def format_number(num: float | str) -> str:
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+))?"
34
+
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.
87
+ """
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:
31
105
  """Format strings at limited precision.
32
106
 
33
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.
34
112
  :return: str
35
113
 
36
114
  I've read articles that recommend no more than four digits before and two digits
@@ -38,15 +116,20 @@ def format_number(num: float | str) -> str:
38
116
  giving six. Mostly to eliminate exponential notation, but I'm "rstripping" the
39
117
  strings to reduce filesize and increase readability
40
118
 
41
- * reduce fp precision to 6 digits
119
+ * reduce fp precision to (default) 6 digits
42
120
  * remove trailing zeros
43
121
  * remove trailing decimal point
122
+ * remove leading 0 in "0.123"
44
123
  * convert "-0" to "0"
124
+ * use shorter of exponential or fixed-point notation
45
125
  """
46
- as_str = f"{float(num):0.6f}".rstrip("0").rstrip(".")
47
- if as_str == "-0":
48
- as_str = "0"
49
- return as_str
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
50
133
 
51
134
 
52
135
  def format_numbers(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: svg-ultralight
3
- Version: 0.40.1
3
+ Version: 0.42.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
@@ -4,6 +4,8 @@ README.md
4
4
  dev-requirements.txt
5
5
  pyproject.toml
6
6
  tox.ini
7
+ experiments/encode_fonts3.py
8
+ experiments/font_css.py
7
9
  src/svg_ultralight/__init__.py
8
10
  src/svg_ultralight/animate.py
9
11
  src/svg_ultralight/image_ops.py
@@ -35,7 +37,6 @@ src/svg_ultralight/constructors/__init__.py
35
37
  src/svg_ultralight/constructors/new_element.py
36
38
  src/svg_ultralight/font_tools/__init__.py
37
39
  src/svg_ultralight/font_tools/comp_results.py
38
- src/svg_ultralight/font_tools/font_css.py
39
40
  src/svg_ultralight/font_tools/font_info.py
40
41
  src/svg_ultralight/font_tools/globs.py
41
42
  src/svg_ultralight/strings/__init__.py
@@ -175,14 +175,14 @@ class TestLayout:
175
175
  viewbox = (0, 0, 96, 0)
176
176
  padded, width_attribs = layout.pad_and_scale(viewbox, "0.25in", "2in")
177
177
  assert padded == (-12, -12, 120, 24)
178
- assert width_attribs == {"width": "2.5in", "height": "0.5in"}
178
+ assert width_attribs == {"width": "2.5in", "height": ".5in"}
179
179
 
180
180
  def test_0_height(self):
181
181
  """Test that print width is used to calculate pad"""
182
182
  viewbox = (0, 0, 0, 96)
183
183
  padded, width_attribs = layout.pad_and_scale(viewbox, "0.25in", None, "2in")
184
184
  assert padded == (-12, -12, 24, 120)
185
- assert width_attribs == {"width": "0.5in", "height": "2.5in"}
185
+ assert width_attribs == {"width": ".5in", "height": "2.5in"}
186
186
 
187
187
  def test_string_padding(self):
188
188
  """Test that string padding is converted to float"""
@@ -229,7 +229,7 @@ class TestLayout:
229
229
  assert width_attribs == {"width": "12in", "height": "22in"}
230
230
 
231
231
  padded, width_attribs = layout.pad_and_scale(viewbox, "1in", "100in")
232
- assert [format_number(x) for x in padded] == ['-0.1', '-0.1', '10.2', '20.2']
232
+ assert [format_number(x) for x in padded] == ['-.1', '-.1', '10.2', '20.2']
233
233
  assert width_attribs == {"width": "102in", "height": "202in"}
234
234
 
235
235
  def test_dpu_(self):
@@ -41,7 +41,7 @@ class TestNewElement:
41
41
  def test_float(self) -> None:
42
42
  """Floats at 0.6f precision."""
43
43
  elem = constructors.new_element("text", x=1 / 3)
44
- assert etree.tostring(elem) == b'<text x="0.333333"/>'
44
+ assert etree.tostring(elem) == b'<text x=".333333"/>'
45
45
 
46
46
 
47
47
  class TestNewSubElement:
@@ -6,12 +6,54 @@
6
6
 
7
7
  # pyright: reportPrivateUsage=false
8
8
  import random
9
+ import itertools as it
9
10
  import string
11
+ from collections.abc import Iterator
12
+ from decimal import Decimal
13
+
10
14
  import pytest
11
15
 
12
16
  import svg_ultralight.string_conversion as mod
13
17
 
14
18
 
19
+ _FLOAT_ITERATIONS = 100
20
+
21
+ def random_floats() -> Iterator[float]:
22
+ """Yield random float values within(-ish) precision limits.
23
+
24
+ Value may exceed the precision limits of the system.
25
+ """
26
+ for _ in range(_FLOAT_ITERATIONS):
27
+ yield random.uniform(1e-20, 1e20)
28
+
29
+ def low_numbers() -> Iterator[float]:
30
+ """Yield random float values below precision limits.
31
+
32
+ Value may exceed the precision limits of the system.
33
+ """
34
+ for _ in range(_FLOAT_ITERATIONS):
35
+ yield random.uniform(1e-25, 1e-24)
36
+
37
+ def high_numbers() -> Iterator[float]:
38
+ """Yield random float values above precision limits.
39
+
40
+ Value may exceed the precision limits of the system.
41
+ """
42
+ for _ in range(_FLOAT_ITERATIONS):
43
+ yield random.uniform(1e+24, 1e+25)
44
+
45
+
46
+ def random_ints() -> Iterator[int]:
47
+ """Yield random integer values."""
48
+ big_int = 2**63 - 1
49
+ for _ in range(_FLOAT_ITERATIONS):
50
+ yield random.randint(-big_int, big_int)
51
+
52
+ def random_numbers() -> Iterator[float]:
53
+ """Yield random numbers values."""
54
+ return it.chain(random_floats(), low_numbers(), high_numbers(), random_ints())
55
+
56
+
15
57
  class TestFormatNuber:
16
58
  def test_negative_zero(self):
17
59
  """Remove "-" from "-0"."""
@@ -22,6 +64,8 @@ class TestFormatNuber:
22
64
  assert mod.format_number(1.0000000001) == "1"
23
65
 
24
66
 
67
+
68
+
25
69
  class TestFormatNumbers:
26
70
  def test_empty(self):
27
71
  """Return empty list."""
@@ -31,10 +75,31 @@ class TestFormatNumbers:
31
75
  """Return list of formatted strings."""
32
76
  assert mod.format_numbers([1, 2, 3]) == ["1", "2", "3"]
33
77
 
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"
34
99
 
35
100
  class TestFormatNumbersInString:
36
101
  def test_empty(self):
37
- """Return empty string."""
102
+ """Return empty string.."""
38
103
  assert mod.format_numbers_in_string("") == ""
39
104
 
40
105
  def test_no_numbers(self):
@@ -81,14 +146,14 @@ class TestFormatAttrDict:
81
146
 
82
147
  def test_datastring(self):
83
148
  """Find and format floats in a datastring."""
84
- assert mod.format_attr_dict(d="M1.0,0 Q -0,.33333333 1,2z") == {
85
- "d": "M1,0 Q 0,0.333333 1,2z"
149
+ assert mod.format_attr_dict(d="M1.0,0 Q -0,.33333333333 1,2z") == {
150
+ "d": "M1,0 Q 0,.333333 1,2z"
86
151
  }
87
152
 
88
153
  def test_datastring_with_exponential_number(self):
89
154
  """Find and format floats in a datastring."""
90
155
  assert mod.format_attr_dict(d="M1.0,1.0e-10 Q -0,.33333333 1,2z") == {
91
- "d": "M1,0 Q 0,0.333333 1,2z"
156
+ "d": "M1,0 Q 0,.333333 1,2z"
92
157
  }
93
158
 
94
159
  def test_format_string(self):
File without changes