svg-ultralight 0.39.1__py3-none-any.whl → 0.40.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svg-ultralight might be problematic. Click here for more details.
- svg_ultralight/__init__.py +9 -1
- svg_ultralight/animate.py +3 -3
- svg_ultralight/bounding_boxes/padded_text_initializers.py +184 -0
- svg_ultralight/bounding_boxes/type_bounding_box.py +12 -0
- svg_ultralight/bounding_boxes/type_padded_text.py +38 -0
- svg_ultralight/font_tools/__init__.py +5 -0
- svg_ultralight/font_tools/comp_results.py +283 -0
- svg_ultralight/font_tools/font_css.py +82 -0
- svg_ultralight/font_tools/font_info.py +567 -0
- svg_ultralight/font_tools/globs.py +7 -0
- svg_ultralight/image_ops.py +4 -2
- svg_ultralight/inkscape.py +29 -18
- svg_ultralight/main.py +5 -3
- svg_ultralight/query.py +8 -37
- svg_ultralight/string_conversion.py +69 -1
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/METADATA +4 -2
- svg_ultralight-0.40.1.dist-info/RECORD +35 -0
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/WHEEL +1 -1
- svg_ultralight-0.39.1.dist-info/RECORD +0 -29
- {svg_ultralight-0.39.1.dist-info → svg_ultralight-0.40.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Link local fonts as css in an svg file.
|
|
2
|
+
|
|
3
|
+
:author: Shay Hill
|
|
4
|
+
:created: 2025-06-04
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# pyright: reportUnknownMemberType = false
|
|
8
|
+
# pyright: reportAttributeAccessIssue = false
|
|
9
|
+
# pyright: reportUnknownArgumentType = false
|
|
10
|
+
# pyright: reportUnknownVariableType = false
|
|
11
|
+
# pyright: reportUnknownParameterType = false
|
|
12
|
+
# pyright: reportMissingTypeStubs = false
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import cssutils
|
|
20
|
+
|
|
21
|
+
from svg_ultralight.constructors import new_element
|
|
22
|
+
from svg_ultralight.string_conversion import encode_to_css_class_name
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from lxml.etree import (
|
|
28
|
+
_Element as EtreeElement, # pyright: ignore[reportPrivateUsage]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_class_names_from_stylesheet(
|
|
33
|
+
stylesheet: cssutils.css.CSSStyleSheet,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Extract all class names from a given CSS stylesheet.
|
|
36
|
+
|
|
37
|
+
:param stylesheet: A cssutils.css.CSSStyleSheet object.
|
|
38
|
+
:return: A list of class names (without the leading dot).
|
|
39
|
+
"""
|
|
40
|
+
class_names: list[str] = []
|
|
41
|
+
for rule in stylesheet.cssRules:
|
|
42
|
+
if rule.type == rule.STYLE_RULE:
|
|
43
|
+
selectors = (s.strip() for s in rule.selectorText.split(","))
|
|
44
|
+
class_names.extend(s[1:] for s in selectors if s.startswith("."))
|
|
45
|
+
return class_names
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_svg_font_class(root: EtreeElement, font: str | os.PathLike[str]) -> str:
|
|
49
|
+
"""Add a css class for the font to the root element.
|
|
50
|
+
|
|
51
|
+
:param root: The root element of the SVG document.
|
|
52
|
+
:param font: Path to the font file.
|
|
53
|
+
:return: The class name for the font, e.g., "bahnschrift_2e_ttf"
|
|
54
|
+
"""
|
|
55
|
+
assert Path(font).exists()
|
|
56
|
+
family_name = encode_to_css_class_name(Path(font).stem)
|
|
57
|
+
class_name = encode_to_css_class_name(Path(font).name)
|
|
58
|
+
style = root.find("style")
|
|
59
|
+
if style is None:
|
|
60
|
+
style = new_element("style", type="text/css")
|
|
61
|
+
root.insert(0, style)
|
|
62
|
+
css = style.text or ""
|
|
63
|
+
|
|
64
|
+
stylesheet = cssutils.parseString(css)
|
|
65
|
+
existing_class_names = _get_class_names_from_stylesheet(stylesheet)
|
|
66
|
+
if class_name in existing_class_names:
|
|
67
|
+
return class_name
|
|
68
|
+
|
|
69
|
+
font_face_rule = cssutils.css.CSSFontFaceRule()
|
|
70
|
+
font_face_rule.style = cssutils.css.CSSStyleDeclaration()
|
|
71
|
+
font_face_rule.style["font-family"] = f'"{family_name}"'
|
|
72
|
+
font_face_rule.style["src"] = rf"url('{Path(font).as_posix()}')"
|
|
73
|
+
stylesheet.add(font_face_rule)
|
|
74
|
+
|
|
75
|
+
style_rule = cssutils.css.CSSStyleRule(selectorText=f".{class_name}")
|
|
76
|
+
style_rule.style = cssutils.css.CSSStyleDeclaration()
|
|
77
|
+
style_rule.style["font-family"] = f'"{family_name}"'
|
|
78
|
+
stylesheet.add(style_rule)
|
|
79
|
+
|
|
80
|
+
style.text = stylesheet.cssText.decode("utf-8")
|
|
81
|
+
|
|
82
|
+
return class_name
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""Use fontTools to extract some font information and remove the problematic types.
|
|
2
|
+
|
|
3
|
+
Svg_Ultralight uses Inkscape command-line calls to find binding boxes, rasterize
|
|
4
|
+
images, and convert font objects to paths. This has some nice advantages:
|
|
5
|
+
|
|
6
|
+
- it's free
|
|
7
|
+
|
|
8
|
+
- ensures Inkscape compatibility, so you can open the results and edit them in
|
|
9
|
+
Inkscape
|
|
10
|
+
|
|
11
|
+
- is much easier to work with than Adobe Illustrator's scripting
|
|
12
|
+
|
|
13
|
+
... and a couple big disadvantages:
|
|
14
|
+
|
|
15
|
+
- Inkscape will not read local font files without encoding them.
|
|
16
|
+
|
|
17
|
+
- Inkscape uses Pango for text layout.
|
|
18
|
+
|
|
19
|
+
Pango is a Linux / GTK library. You can get it working on Windows with some work, but
|
|
20
|
+
it's definitely not a requirement I want for every project that uses Svg_Ultralight.
|
|
21
|
+
|
|
22
|
+
This means I can only infer Pango's text layout by passing reference text elements to
|
|
23
|
+
Inkscape and examining the results. That's not terribly, but it's slow and does not
|
|
24
|
+
reveal line_gap, line_height, true ascent, or true descent, which I often want for
|
|
25
|
+
text layout.
|
|
26
|
+
|
|
27
|
+
FontTools is a Pango-like library that can get *similar* results. Maybe identical
|
|
28
|
+
results you want to re-implement Pango's text layout. I have 389 ttf and otf fonts
|
|
29
|
+
installed on my system.
|
|
30
|
+
|
|
31
|
+
- for 361 of 389, this module apears to lay out text exactly as Pango.
|
|
32
|
+
|
|
33
|
+
- 17 of 389 raise an error when trying to examine them. Some of these are only issues
|
|
34
|
+
with the test text, which may include characters not in the font.
|
|
35
|
+
|
|
36
|
+
- 7 of 389 have y-bounds differences from Pango, but the line_gap values may still be
|
|
37
|
+
useful.
|
|
38
|
+
|
|
39
|
+
- 4 of 389 have x-bounds differences from Pango. A hybrid function `pad_text_mix`
|
|
40
|
+
uses the x-bounds from Inkscape/Pango and the y-bounds from this module. The 11
|
|
41
|
+
total mismatched font bounds appear to all be from fonts with liguatures, which I
|
|
42
|
+
have not implemented.
|
|
43
|
+
|
|
44
|
+
I have provided the `check_font_tools_alignment` function to check an existing font
|
|
45
|
+
for compatilibilty with Inkscape's text layout. If that returns (NO_ERROR, None),
|
|
46
|
+
then a font object created with
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
new_element("text", text="abc", **get_svg_font_attributes(path_to_font))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
... will lay out the element exactly as Inkscape would *if* Inkscape were able to
|
|
53
|
+
read locally linked font files.
|
|
54
|
+
|
|
55
|
+
Advantages to using fontTools do predict how Inkscape will lay out text:
|
|
56
|
+
|
|
57
|
+
- does not require Inkscape to be installed.
|
|
58
|
+
|
|
59
|
+
- knows the actual ascent and descent of the font, not just inferences based on
|
|
60
|
+
reference characters
|
|
61
|
+
|
|
62
|
+
- provides the line_gap and line_height, which Inkscape cannot
|
|
63
|
+
|
|
64
|
+
- much faster
|
|
65
|
+
|
|
66
|
+
Disadvantages:
|
|
67
|
+
|
|
68
|
+
- will fail for some fonts that do not have the necessary tables
|
|
69
|
+
|
|
70
|
+
- will not reflect any layout nuances that Inkscape might apply to the text
|
|
71
|
+
|
|
72
|
+
- does not adjust for font-weight and other characteristics that Inkscape *might*
|
|
73
|
+
|
|
74
|
+
- matching the specification of a font file to svg's font-family, font-style,
|
|
75
|
+
font-weight, and font-stretch isn't always straightforward. It's worth a visual
|
|
76
|
+
test to see how well your bounding boxes fit if you're using an unfamiliar font.
|
|
77
|
+
|
|
78
|
+
- does not support `font-variant`, `font-kerning`, `text-anchor`, and other
|
|
79
|
+
attributes that `pad_text` would through Inkscape.
|
|
80
|
+
|
|
81
|
+
See the padded_text_initializers module for how to create a PaddedText instance using
|
|
82
|
+
fontTools and this module.
|
|
83
|
+
|
|
84
|
+
:author: Shay Hill
|
|
85
|
+
:created: 2025-05-31
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
# pyright: reportUnknownMemberType = false
|
|
89
|
+
# pyright: reportPrivateUsage = false
|
|
90
|
+
# pyright: reportAttributeAccessIssue = false
|
|
91
|
+
# pyright: reportUnknownArgumentType = false
|
|
92
|
+
# pyright: reportUnknownVariableType = false
|
|
93
|
+
# pyright: reportUnknownParameterType = false
|
|
94
|
+
# pyright: reportMissingTypeStubs = false
|
|
95
|
+
|
|
96
|
+
from __future__ import annotations
|
|
97
|
+
|
|
98
|
+
import functools as ft
|
|
99
|
+
import itertools as it
|
|
100
|
+
import logging
|
|
101
|
+
from contextlib import suppress
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
104
|
+
|
|
105
|
+
from fontTools.pens.boundsPen import BoundsPen
|
|
106
|
+
from fontTools.ttLib import TTFont
|
|
107
|
+
|
|
108
|
+
from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
|
|
109
|
+
from svg_ultralight.font_tools.globs import DEFAULT_FONT_SIZE
|
|
110
|
+
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
import os
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
logging.getLogger("fontTools").setLevel(logging.ERROR)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class FTFontInfo:
|
|
119
|
+
"""Hide all the type kludging necessary to use fontTools."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, font_path: str | os.PathLike[str]) -> None:
|
|
122
|
+
"""Initialize the SUFont with a path to a TTF font file."""
|
|
123
|
+
self._path = Path(font_path)
|
|
124
|
+
if not self.path.exists():
|
|
125
|
+
msg = f"Font file '{self.path}' does not exist."
|
|
126
|
+
raise FileNotFoundError(msg)
|
|
127
|
+
self._font = TTFont(self.path)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def path(self) -> Path:
|
|
131
|
+
"""Return the path to the font file."""
|
|
132
|
+
return self._path
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def font(self) -> TTFont:
|
|
136
|
+
"""Return the fontTools TTFont object."""
|
|
137
|
+
return self._font
|
|
138
|
+
|
|
139
|
+
@ft.cached_property
|
|
140
|
+
def units_per_em(self) -> int:
|
|
141
|
+
"""Get the units per em for the font.
|
|
142
|
+
|
|
143
|
+
:return: The units per em for the font. For a ttf, this will usually
|
|
144
|
+
(always?) be 2048.
|
|
145
|
+
:raises ValueError: If the font does not have a 'head' table or 'unitsPerEm'
|
|
146
|
+
attribute.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
maybe_units_per_em = cast("int | None", self.font["head"].unitsPerEm)
|
|
150
|
+
except (KeyError, AttributeError) as e:
|
|
151
|
+
msg = (
|
|
152
|
+
f"Font '{self.path}' does not have"
|
|
153
|
+
+ " 'head' table or 'unitsPerEm' attribute: {e}"
|
|
154
|
+
)
|
|
155
|
+
raise ValueError(msg) from e
|
|
156
|
+
if maybe_units_per_em is None:
|
|
157
|
+
msg = f"Font '{self.path}' does not have 'unitsPerEm' defined."
|
|
158
|
+
raise ValueError(msg)
|
|
159
|
+
return maybe_units_per_em
|
|
160
|
+
|
|
161
|
+
@ft.cached_property
|
|
162
|
+
def kern_table(self) -> dict[tuple[str, str], int]:
|
|
163
|
+
"""Get the kerning pairs for the font.
|
|
164
|
+
|
|
165
|
+
:return: A dictionary mapping glyph pairs to their kerning values.
|
|
166
|
+
:raises ValueError: If the font does not have a 'kern' table.
|
|
167
|
+
|
|
168
|
+
I haven't run across a font with multiple kern tables, but *if* a font had
|
|
169
|
+
multiple tables and *if* the same pair were defined in multiple tables, this
|
|
170
|
+
method would give precedence to the first occurrence. That behavior is copied
|
|
171
|
+
from examples found online.
|
|
172
|
+
"""
|
|
173
|
+
with suppress(KeyError, AttributeError):
|
|
174
|
+
kern_tables = cast(
|
|
175
|
+
"list[dict[tuple[str, str], int]]",
|
|
176
|
+
[x.kernTable for x in self.font["kern"].kernTables],
|
|
177
|
+
)
|
|
178
|
+
return dict(x for d in reversed(kern_tables) for x in d.items())
|
|
179
|
+
return {}
|
|
180
|
+
|
|
181
|
+
@ft.cached_property
|
|
182
|
+
def hhea(self) -> Any:
|
|
183
|
+
"""Get the horizontal header table for the font.
|
|
184
|
+
|
|
185
|
+
:return: The horizontal header table for the font.
|
|
186
|
+
:raises ValueError: If the font does not have a 'hhea' table.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
return cast("Any", self.font["hhea"])
|
|
190
|
+
except KeyError as e:
|
|
191
|
+
msg = f"Font '{self.path}' does not have a 'hhea' table: {e}"
|
|
192
|
+
raise ValueError(msg) from e
|
|
193
|
+
|
|
194
|
+
def get_glyph_name(self, char: str) -> str:
|
|
195
|
+
"""Get the glyph name for a character in the font.
|
|
196
|
+
|
|
197
|
+
:param char: The character to get the glyph name for.
|
|
198
|
+
:return: The glyph name for the character.
|
|
199
|
+
:raises ValueError: If the character is not found in the font.
|
|
200
|
+
"""
|
|
201
|
+
ord_char = ord(char)
|
|
202
|
+
char_map = cast("dict[int, str]", self.font.getBestCmap())
|
|
203
|
+
if ord_char in char_map:
|
|
204
|
+
return char_map[ord_char]
|
|
205
|
+
msg = f"Character '{char}' not found in font '{self.path}'."
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
|
|
208
|
+
def get_char_bounds(self, char: str) -> tuple[int, int, int, int]:
|
|
209
|
+
"""Return the min and max x and y coordinates of a glyph.
|
|
210
|
+
|
|
211
|
+
There are two ways to get the bounds of a glyph, using an object from
|
|
212
|
+
font["glyf"] or this awkward-looking method. Most of the time, they are the
|
|
213
|
+
same, but when they disagree, this method is more accurate. Additionally,
|
|
214
|
+
some fonts do not have a glyf table, so this method is more robust.
|
|
215
|
+
"""
|
|
216
|
+
glyph_set = self.font.getGlyphSet()
|
|
217
|
+
glyph_name = self.font.getBestCmap().get(ord(char))
|
|
218
|
+
bounds_pen = BoundsPen(glyph_set)
|
|
219
|
+
_ = glyph_set[glyph_name].draw(bounds_pen)
|
|
220
|
+
|
|
221
|
+
pen_bounds = cast("None | tuple[int, int, int, int]", bounds_pen.bounds)
|
|
222
|
+
if pen_bounds is None:
|
|
223
|
+
return 0, 0, 0, 0
|
|
224
|
+
xMin, yMin, xMax, yMax = pen_bounds
|
|
225
|
+
return xMin, yMin, xMax, yMax
|
|
226
|
+
|
|
227
|
+
def get_char_bbox(self, char: str) -> BoundingBox:
|
|
228
|
+
"""Return the BoundingBox of a character svg coordinates.
|
|
229
|
+
|
|
230
|
+
Don't miss: this not only converts min and max x and y to x, y, width,
|
|
231
|
+
height; it also converts from Cartesian coordinates (+y is up) to SVG
|
|
232
|
+
coordinates (+y is down).
|
|
233
|
+
"""
|
|
234
|
+
min_x, min_y, max_x, max_y = self.get_char_bounds(char)
|
|
235
|
+
return BoundingBox(min_x, -max_y, max_x - min_x, max_y - min_y)
|
|
236
|
+
|
|
237
|
+
def get_text_bounds(self, text: str) -> tuple[int, int, int, int]:
|
|
238
|
+
"""Return bounds of a string as xmin, ymin, xmax, ymax.
|
|
239
|
+
|
|
240
|
+
:param font_path: path to a TTF font file
|
|
241
|
+
:param text: a string to get the bounding box for
|
|
242
|
+
|
|
243
|
+
The max x value of a string is the sum of the hmtx advances for each glyph
|
|
244
|
+
with some adjustments:
|
|
245
|
+
|
|
246
|
+
* The rightmost glyph's actual width is used instead of its advance (because
|
|
247
|
+
no space is added after the last glyph).
|
|
248
|
+
* The kerning between each pair of glyphs is added to the total advance.
|
|
249
|
+
|
|
250
|
+
These bounds are in Cartesian coordinates, not translated to SVGs screen
|
|
251
|
+
coordinates, and not x, y, width, height.
|
|
252
|
+
"""
|
|
253
|
+
hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
|
|
254
|
+
|
|
255
|
+
names = [self.get_glyph_name(c) for c in text]
|
|
256
|
+
bounds = [self.get_char_bounds(c) for c in text]
|
|
257
|
+
total_advance = sum(hmtx[n][0] for n in names[:-1])
|
|
258
|
+
total_kern = sum(self.kern_table.get((x, y), 0) for x, y in it.pairwise(names))
|
|
259
|
+
min_xs, min_ys, max_xs, max_ys = zip(*bounds)
|
|
260
|
+
min_x = min_xs[0]
|
|
261
|
+
min_y = min(min_ys)
|
|
262
|
+
|
|
263
|
+
max_x = total_advance + max_xs[-1] + total_kern
|
|
264
|
+
max_y = max(max_ys)
|
|
265
|
+
return min_x, min_y, max_x, max_y
|
|
266
|
+
|
|
267
|
+
def get_text_bbox(self, text: str) -> BoundingBox:
|
|
268
|
+
"""Return the BoundingBox of a string svg coordinates.
|
|
269
|
+
|
|
270
|
+
Don't miss: this not only converts min and max x and y to x, y, width,
|
|
271
|
+
height; it also converts from Cartesian coordinates (+y is up) to SVG
|
|
272
|
+
coordinates (+y is down).
|
|
273
|
+
"""
|
|
274
|
+
min_x, min_y, max_x, max_y = self.get_text_bounds(text)
|
|
275
|
+
return BoundingBox(min_x, -max_y, max_x - min_x, max_y - min_y)
|
|
276
|
+
|
|
277
|
+
def get_lsb(self, char: str) -> float:
|
|
278
|
+
"""Return the left side bearing of a character."""
|
|
279
|
+
hmtx = cast("Any", self.font["hmtx"])
|
|
280
|
+
_, lsb = hmtx.metrics[self.get_glyph_name(char)]
|
|
281
|
+
return lsb
|
|
282
|
+
|
|
283
|
+
def get_rsb(self, char: str) -> float:
|
|
284
|
+
"""Return the right side bearing of a character."""
|
|
285
|
+
glyph_name = self.get_glyph_name(char)
|
|
286
|
+
glyph_width = self.get_char_bbox(char).width
|
|
287
|
+
hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
|
|
288
|
+
advance, lsb = hmtx[glyph_name]
|
|
289
|
+
return advance - (lsb + glyph_width)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class FTTextInfo:
|
|
293
|
+
"""Scale the fontTools font information for a specific text and font size."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
font: str | os.PathLike[str] | FTFontInfo,
|
|
298
|
+
text: str,
|
|
299
|
+
font_size: float,
|
|
300
|
+
ascent: float | None = None,
|
|
301
|
+
descent: float | None = None,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Initialize the SUText with text, a SUFont instance, and font size."""
|
|
304
|
+
if isinstance(font, FTFontInfo):
|
|
305
|
+
self._font = font
|
|
306
|
+
else:
|
|
307
|
+
self._font = FTFontInfo(font)
|
|
308
|
+
self._text = text.rstrip(" ")
|
|
309
|
+
self._font_size = font_size
|
|
310
|
+
self._ascent = ascent
|
|
311
|
+
self._descent = descent
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def font(self) -> FTFontInfo:
|
|
315
|
+
"""Return the font information."""
|
|
316
|
+
return self._font
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def text(self) -> str:
|
|
320
|
+
"""Return the text."""
|
|
321
|
+
return self._text
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def font_size(self) -> float:
|
|
325
|
+
"""Return the font size."""
|
|
326
|
+
return self._font_size
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def scale(self) -> float:
|
|
330
|
+
"""Return the scale factor for the font size.
|
|
331
|
+
|
|
332
|
+
:return: The scale factor for the font size.
|
|
333
|
+
"""
|
|
334
|
+
return self.font_size / self.font.units_per_em
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def bbox(self) -> BoundingBox:
|
|
338
|
+
"""Return the bounding box of the text.
|
|
339
|
+
|
|
340
|
+
:return: A BoundingBox in svg coordinates.
|
|
341
|
+
"""
|
|
342
|
+
bbox = self.font.get_text_bbox(self.text)
|
|
343
|
+
bbox.transform(scale=self.scale)
|
|
344
|
+
return BoundingBox(*bbox.values())
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def ascent(self) -> float:
|
|
348
|
+
"""Return the ascent of the font."""
|
|
349
|
+
if self._ascent is None:
|
|
350
|
+
self._ascent = self.font.hhea.ascent * self.scale
|
|
351
|
+
return self._ascent
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def descent(self) -> float:
|
|
355
|
+
"""Return the descent of the font."""
|
|
356
|
+
if self._descent is None:
|
|
357
|
+
self._descent = self.font.hhea.descent * self.scale
|
|
358
|
+
return self._descent
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def line_gap(self) -> float:
|
|
362
|
+
"""Return the height of the capline for the font."""
|
|
363
|
+
return self.font.hhea.lineGap * self.scale
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def line_spacing(self) -> float:
|
|
367
|
+
"""Return the line spacing for the font."""
|
|
368
|
+
return self.descent + self.ascent + self.line_gap
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def tpad(self) -> float:
|
|
372
|
+
"""Return the top padding for the text."""
|
|
373
|
+
return self.ascent + self.bbox.y
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def rpad(self) -> float:
|
|
377
|
+
"""Return the right padding for the text.
|
|
378
|
+
|
|
379
|
+
This is the right side bearing of the last glyph in the text.
|
|
380
|
+
"""
|
|
381
|
+
return self.font.get_rsb(self.text[-1]) * self.scale
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def bpad(self) -> float:
|
|
385
|
+
"""Return the bottom padding for the text."""
|
|
386
|
+
return self.descent - self.bbox.y2
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def lpad(self) -> float:
|
|
390
|
+
"""Return the left padding for the text.
|
|
391
|
+
|
|
392
|
+
This is the left side bearing of the first glyph in the text.
|
|
393
|
+
"""
|
|
394
|
+
return self.font.get_lsb(self.text[0]) * self.scale
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def padding(self) -> tuple[float, float, float, float]:
|
|
398
|
+
"""Return the padding for the text as a tuple of (top, right, bottom, left)."""
|
|
399
|
+
return self.tpad, self.rpad, self.bpad, self.lpad
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_font_size_given_height(font: str | os.PathLike[str], height: float) -> float:
|
|
403
|
+
"""Return the font size that would give the given line height.
|
|
404
|
+
|
|
405
|
+
:param font: path to a font file.
|
|
406
|
+
:param height: desired line height in pixels.
|
|
407
|
+
|
|
408
|
+
Where line height is the distance from the longest possible descender to the
|
|
409
|
+
longest possible ascender.
|
|
410
|
+
"""
|
|
411
|
+
font_info = FTFontInfo(font)
|
|
412
|
+
units_per_em = font_info.units_per_em
|
|
413
|
+
if units_per_em <= 0:
|
|
414
|
+
msg = f"Font '{font}' has invalid units per em: {units_per_em}"
|
|
415
|
+
raise ValueError(msg)
|
|
416
|
+
line_height = font_info.hhea.ascent - font_info.hhea.descent
|
|
417
|
+
return height / line_height * units_per_em
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def get_padded_text_info(
|
|
421
|
+
font: str | os.PathLike[str],
|
|
422
|
+
text: str,
|
|
423
|
+
font_size: float = DEFAULT_FONT_SIZE,
|
|
424
|
+
ascent: float | None = None,
|
|
425
|
+
descent: float | None = None,
|
|
426
|
+
*,
|
|
427
|
+
y_bounds_reference: str | None = None,
|
|
428
|
+
) -> FTTextInfo:
|
|
429
|
+
"""Return a FTTextInfo object for the given text and font.
|
|
430
|
+
|
|
431
|
+
:param font: path to a font file.
|
|
432
|
+
:param text: the text to get the information for.
|
|
433
|
+
:param font_size: the font size to use.
|
|
434
|
+
:param ascent: the ascent of the font. If not provided, it will be calculated
|
|
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.
|
|
438
|
+
:param y_bounds_reference: optional character or string to use as a reference
|
|
439
|
+
for the ascent and descent. If provided, the ascent and descent will be the y
|
|
440
|
+
extents of the capline reference. This argument is provided to mimic the
|
|
441
|
+
behavior of the query module's `pad_text` function. `pad_text` does no
|
|
442
|
+
inspect font files and relies on Inkscape to measure reference characters.
|
|
443
|
+
:return: A FTTextInfo object with the information necessary to create a
|
|
444
|
+
PaddedText instance: bbox, tpad, rpad, bpad, lpad.
|
|
445
|
+
"""
|
|
446
|
+
font_info = FTFontInfo(font)
|
|
447
|
+
if y_bounds_reference:
|
|
448
|
+
capline_info = FTTextInfo(font_info, y_bounds_reference, font_size)
|
|
449
|
+
ascent = -capline_info.bbox.y
|
|
450
|
+
descent = capline_info.bbox.y2
|
|
451
|
+
|
|
452
|
+
return FTTextInfo(font_info, text, font_size, ascent, descent)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ===================================================================================
|
|
456
|
+
# Infer svg font attributes from a ttf or otf file
|
|
457
|
+
# ===================================================================================
|
|
458
|
+
|
|
459
|
+
# This is the record nameID that most consistently reproduce the desired font
|
|
460
|
+
# characteristics in svg.
|
|
461
|
+
_NAME_ID = 1
|
|
462
|
+
_STYLE_ID = 2
|
|
463
|
+
|
|
464
|
+
# Windows
|
|
465
|
+
_PLATFORM_ID = 3
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _get_font_names(
|
|
469
|
+
path_to_font: str | os.PathLike[str],
|
|
470
|
+
) -> tuple[str | None, str | None]:
|
|
471
|
+
"""Get the family and style of a font from a ttf or otf file path.
|
|
472
|
+
|
|
473
|
+
:param path_to_font: path to a ttf or otf file
|
|
474
|
+
:return: One of many names of the font (e.g., "HelveticaNeue-CondensedBlack") or
|
|
475
|
+
None and a style name (e.g., "Bold") as a tuple or None. This seems to be the
|
|
476
|
+
convention that semi-reliably works with Inkscape.
|
|
477
|
+
|
|
478
|
+
These are loosely the font-family and font-style, but they will not usually work
|
|
479
|
+
in Inkscape without some transation (see translate_font_style).
|
|
480
|
+
"""
|
|
481
|
+
font = TTFont(path_to_font)
|
|
482
|
+
name_table = cast("Any", font["name"])
|
|
483
|
+
font.close()
|
|
484
|
+
family = None
|
|
485
|
+
style = None
|
|
486
|
+
for i, record in enumerate(name_table.names):
|
|
487
|
+
if record.nameID == _NAME_ID and record.platformID == _PLATFORM_ID:
|
|
488
|
+
family = record.toUnicode()
|
|
489
|
+
next_record = (
|
|
490
|
+
name_table.names[i + 1] if i + 1 < len(name_table.names) else None
|
|
491
|
+
)
|
|
492
|
+
if (
|
|
493
|
+
next_record is not None
|
|
494
|
+
and next_record.nameID == _STYLE_ID
|
|
495
|
+
and next_record.platformID == _PLATFORM_ID
|
|
496
|
+
):
|
|
497
|
+
style = next_record.toUnicode()
|
|
498
|
+
break
|
|
499
|
+
return family, style
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
_FONT_STYLE_TERMS = [
|
|
503
|
+
"italic",
|
|
504
|
+
"oblique",
|
|
505
|
+
]
|
|
506
|
+
_FONT_WEIGHT_MAP = {
|
|
507
|
+
"ultralight": "100",
|
|
508
|
+
"demibold": "600",
|
|
509
|
+
"light": "300",
|
|
510
|
+
"bold": "bold",
|
|
511
|
+
"black": "900",
|
|
512
|
+
}
|
|
513
|
+
_FONT_STRETCH_TERMS = [
|
|
514
|
+
"ultra-condensed",
|
|
515
|
+
"extra-condensed",
|
|
516
|
+
"semi-condensed",
|
|
517
|
+
"condensed",
|
|
518
|
+
"normal",
|
|
519
|
+
"semi-expanded",
|
|
520
|
+
"extra-expanded",
|
|
521
|
+
"ultra-expanded",
|
|
522
|
+
"expanded",
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _translate_font_style(style: str | None) -> dict[str, str]:
|
|
527
|
+
"""Translate the myriad font styles retured by ttLib into valid svg styles.
|
|
528
|
+
|
|
529
|
+
:param style: the style string from a ttf or otf file, extracted by
|
|
530
|
+
_get_font_names(path_to_font)[1].
|
|
531
|
+
:return: a dictionary with keys 'font-style', 'font-weight', and 'font-stretch'
|
|
532
|
+
|
|
533
|
+
Attempt to create a set of svg font attributes that will reprduce a desired ttf
|
|
534
|
+
or otf font.
|
|
535
|
+
"""
|
|
536
|
+
result: dict[str, str] = {}
|
|
537
|
+
if style is None:
|
|
538
|
+
return result
|
|
539
|
+
style = style.lower()
|
|
540
|
+
for font_style_term in _FONT_STYLE_TERMS:
|
|
541
|
+
if font_style_term in style:
|
|
542
|
+
result["font-style"] = font_style_term
|
|
543
|
+
break
|
|
544
|
+
for k, v in _FONT_WEIGHT_MAP.items():
|
|
545
|
+
if k in style:
|
|
546
|
+
result["font-weight"] = v
|
|
547
|
+
break
|
|
548
|
+
for font_stretch_term in _FONT_STRETCH_TERMS:
|
|
549
|
+
if font_stretch_term in style:
|
|
550
|
+
result["font-stretch"] = font_stretch_term
|
|
551
|
+
break
|
|
552
|
+
return result
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def get_svg_font_attributes(path_to_font: str | os.PathLike[str]) -> dict[str, str]:
|
|
556
|
+
"""Attempt to get svg font attributes (font-family, font-style, etc).
|
|
557
|
+
|
|
558
|
+
:param path_to_font: path to a ttf or otf file
|
|
559
|
+
:return: {'font-family': 'AgencyFB-Bold'}
|
|
560
|
+
"""
|
|
561
|
+
svg_font_attributes: dict[str, str] = {}
|
|
562
|
+
family, style = _get_font_names(path_to_font)
|
|
563
|
+
if family is None:
|
|
564
|
+
return svg_font_attributes
|
|
565
|
+
svg_font_attributes["font-family"] = family
|
|
566
|
+
svg_font_attributes.update(_translate_font_style(style))
|
|
567
|
+
return svg_font_attributes
|
svg_ultralight/image_ops.py
CHANGED
|
@@ -35,7 +35,7 @@ from svg_ultralight.bounding_boxes.bound_helpers import bbox_dict
|
|
|
35
35
|
from svg_ultralight.constructors import new_element
|
|
36
36
|
|
|
37
37
|
if TYPE_CHECKING:
|
|
38
|
-
|
|
38
|
+
import os
|
|
39
39
|
|
|
40
40
|
from lxml.etree import (
|
|
41
41
|
_Element as EtreeElement, # pyright: ignore [reportPrivateUsage]
|
|
@@ -115,7 +115,9 @@ def _get_svg_embedded_image_str(image: ImageType) -> str:
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def new_image_elem_in_bbox(
|
|
118
|
-
filename:
|
|
118
|
+
filename: str | os.PathLike[str],
|
|
119
|
+
bbox: BoundingBox,
|
|
120
|
+
center: tuple[float, float] | None,
|
|
119
121
|
) -> EtreeElement:
|
|
120
122
|
"""Create a new svg image element inside a bounding box.
|
|
121
123
|
|