svg-ultralight 0.64.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. svg_ultralight/__init__.py +112 -0
  2. svg_ultralight/animate.py +40 -0
  3. svg_ultralight/attrib_hints.py +14 -0
  4. svg_ultralight/bounding_boxes/__init__.py +5 -0
  5. svg_ultralight/bounding_boxes/bound_helpers.py +200 -0
  6. svg_ultralight/bounding_boxes/padded_text_initializers.py +442 -0
  7. svg_ultralight/bounding_boxes/supports_bounds.py +167 -0
  8. svg_ultralight/bounding_boxes/type_bound_collection.py +74 -0
  9. svg_ultralight/bounding_boxes/type_bound_element.py +68 -0
  10. svg_ultralight/bounding_boxes/type_bounding_box.py +432 -0
  11. svg_ultralight/bounding_boxes/type_padded_list.py +208 -0
  12. svg_ultralight/bounding_boxes/type_padded_text.py +502 -0
  13. svg_ultralight/constructors/__init__.py +14 -0
  14. svg_ultralight/constructors/new_element.py +117 -0
  15. svg_ultralight/font_tools/__init__.py +5 -0
  16. svg_ultralight/font_tools/comp_results.py +291 -0
  17. svg_ultralight/font_tools/font_info.py +849 -0
  18. svg_ultralight/image_ops.py +156 -0
  19. svg_ultralight/inkscape.py +261 -0
  20. svg_ultralight/layout.py +291 -0
  21. svg_ultralight/main.py +183 -0
  22. svg_ultralight/metadata.py +122 -0
  23. svg_ultralight/nsmap.py +36 -0
  24. svg_ultralight/py.typed +5 -0
  25. svg_ultralight/query.py +254 -0
  26. svg_ultralight/read_svg.py +58 -0
  27. svg_ultralight/root_elements.py +96 -0
  28. svg_ultralight/string_conversion.py +244 -0
  29. svg_ultralight/strings/__init__.py +21 -0
  30. svg_ultralight/strings/svg_strings.py +106 -0
  31. svg_ultralight/transformations.py +152 -0
  32. svg_ultralight/unit_conversion.py +247 -0
  33. svg_ultralight-0.64.0.dist-info/METADATA +208 -0
  34. svg_ultralight-0.64.0.dist-info/RECORD +35 -0
  35. svg_ultralight-0.64.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,849 @@
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.basePen import BasePen
106
+ from fontTools.pens.boundsPen import BoundsPen
107
+ from fontTools.ttLib import TTFont
108
+ from paragraphs import par
109
+ from svg_path_data import format_svgd_shortest, get_cpts_from_svgd, get_svgd_from_cpts
110
+
111
+ from svg_ultralight.bounding_boxes.type_bounding_box import BoundingBox
112
+ from svg_ultralight.constructors.new_element import (
113
+ new_element,
114
+ new_sub_element,
115
+ update_element,
116
+ )
117
+ from svg_ultralight.strings import svg_matrix
118
+
119
+ if TYPE_CHECKING:
120
+ import os
121
+ from collections.abc import Iterator
122
+
123
+ from lxml.etree import _Element as EtreeElement
124
+
125
+ from svg_ultralight.attrib_hints import ElemAttrib
126
+
127
+ logging.getLogger("fontTools").setLevel(logging.ERROR)
128
+
129
+
130
+ _ESCAPE_CHARS = {
131
+ "&": "&",
132
+ "<": "&lt;",
133
+ ">": "&gt;",
134
+ '"': "&quot;",
135
+ "'": "&apos;",
136
+ "{": "&#123", # valid, but stops MS File Explorer from thumbnailing an svg
137
+ "}": "&#125", # valid, but stops MS File Explorer from thumbnailing an svg
138
+ }
139
+
140
+
141
+ def _sanitize_svg_data_text(text: str) -> str:
142
+ """Sanitize a string for use in an SVG data-text attribute.
143
+
144
+ :param text: The input string to sanitize.
145
+ :return: The sanitized string with XML characters escaped.
146
+ """
147
+ for char, escape_seq in _ESCAPE_CHARS.items():
148
+ text = text.replace(char, escape_seq)
149
+ return text
150
+
151
+
152
+ def _get_gpos_kerning(font: TTFont) -> dict[tuple[str, str], int]:
153
+ """Extract kerning pairs from the GPOS table of a font.
154
+
155
+ :param font: A fontTools TTFont object.
156
+ :return: A dictionary mapping glyph pairs to their kerning values.
157
+ :raises ValueError: If the font does not have a GPOS table.
158
+
159
+ This is the more elaborate kerning that is used in OTF fonts and some TTF fonts.
160
+ It has several flavors, I'm only implementing glyph-pair kerning (Format 1),
161
+ because I don't have fonts to test anything else.
162
+ """
163
+ if "GPOS" not in font:
164
+ msg = "Font does not have a GPOS table."
165
+ raise ValueError(msg)
166
+
167
+ gpos = font["GPOS"].table
168
+ kern_table: dict[tuple[str, str], int] = {}
169
+
170
+ type2_lookups = (x for x in gpos.LookupList.Lookup if x.LookupType == 2)
171
+ subtables = list(it.chain(*(x.SubTable for x in type2_lookups)))
172
+ for subtable in (x for x in subtables if x.Format == 1): # glyph-pair kerning
173
+ for pair_set, glyph1 in zip(
174
+ subtable.PairSet, subtable.Coverage.glyphs, strict=True
175
+ ):
176
+ for pair_value in pair_set.PairValueRecord:
177
+ glyph2 = pair_value.SecondGlyph
178
+ value1 = pair_value.Value1
179
+ xadv = getattr(value1, "XAdvance", None)
180
+ xpla = getattr(value1, "XPlacement", None)
181
+ value = xadv or xpla or 0
182
+ if value != 0: # only record non-zero kerning values
183
+ kern_table[(glyph1, glyph2)] = value
184
+
185
+ for subtable in (x for x in subtables if x.Format == 2): # class-based kerning
186
+ defs1 = subtable.ClassDef1.classDefs
187
+ defs2 = subtable.ClassDef2.classDefs
188
+ record1 = subtable.Class1Record
189
+ defs1 = {k: v for k, v in defs1.items() if v < len(record1)}
190
+ for (glyph1, class1), (glyph2, class2) in it.product(
191
+ defs1.items(), defs2.items()
192
+ ):
193
+ class1_record = record1[class1]
194
+ if class2 < len(class1_record.Class2Record):
195
+ value1 = class1_record.Class2Record[class2].Value1
196
+ xadv = getattr(value1, "XAdvance", None)
197
+ xpla = getattr(value1, "XPlacement", None)
198
+ value = xadv or xpla or 0
199
+ if value != 0:
200
+ kern_table[(glyph1, glyph2)] = value
201
+
202
+ return kern_table
203
+
204
+
205
+ _XYTuple = tuple[float, float]
206
+
207
+
208
+ def _split_into_quadratic(*pts: _XYTuple) -> Iterator[tuple[_XYTuple, _XYTuple]]:
209
+ """Connect a series of points with quadratic bezier segments.
210
+
211
+ :param points: a series of at least two (x, y) coordinates.
212
+ :return: an iterator of ((x, y), (x, y)) quadatic bezier control points (the
213
+ second and third points)
214
+
215
+ This is part of connecting a (not provided) current point to the last input
216
+ point. The other input points will be control points of a series of quadratic
217
+ Bezier curves. New Bezier curve endpoints will be created between these points.
218
+
219
+ given (B, C, D, E) (with A as the not-provided current point):
220
+ - [A, B, bc][1:]
221
+ - [bc, C, cd][1:]
222
+ - [cd, D, E][1:]
223
+ """
224
+ if len(pts) < 2:
225
+ msg = "At least two points are required."
226
+ raise ValueError(msg)
227
+ for prev_cp, next_cp in it.pairwise(pts[:-1]):
228
+ xs, ys = zip(prev_cp, next_cp, strict=True)
229
+ midpnt = sum(xs) / 2, sum(ys) / 2
230
+ yield prev_cp, midpnt
231
+ yield pts[-2], pts[-1]
232
+
233
+
234
+ class PathPen(BasePen):
235
+ """A pen to collect svg path data commands from a glyph."""
236
+
237
+ def __init__(self, glyph_set: Any) -> None:
238
+ """Initialize the PathPen with a glyph set.
239
+
240
+ :param glyph_set: TTFont(path).getGlyphSet()
241
+ """
242
+ super().__init__(glyph_set)
243
+ self._cmds: list[str] = []
244
+
245
+ @property
246
+ def svgd(self) -> str:
247
+ """Return an svg path data string for the glyph."""
248
+ if not self._cmds:
249
+ return ""
250
+ svgd = " ".join(self._cmds)
251
+ return "M" + svgd[1:]
252
+
253
+ @property
254
+ def cpts(self) -> list[list[tuple[float, float]]]:
255
+ """Return as a list of lists of Bezier control points."""
256
+ return get_cpts_from_svgd(" ".join(self._cmds))
257
+
258
+ def moveTo(self, pt: tuple[float, float]) -> None:
259
+ """Move the current point to a new location."""
260
+ self._cmds.extend(("M", *map(str, pt)))
261
+
262
+ def lineTo(self, pt: tuple[float, float]) -> None:
263
+ """Add a line segment to the path."""
264
+ self._cmds.extend(("L", *map(str, pt)))
265
+
266
+ def curveTo(self, *pts: tuple[float, float]) -> None:
267
+ """Add a series of cubic bezier segments to the path."""
268
+ if len(pts) > 3:
269
+ msg = par(
270
+ """I'm uncertain how to decompose these points into cubics (if the
271
+ goal is to match font rendering in Inkscape and elsewhere. There is
272
+ function, decomposeSuperBezierSegment, in fontTools, but I cannot
273
+ find a reference for the algorithm. I'm hoping to run into one in a
274
+ font file so I have a test case."""
275
+ )
276
+ raise NotImplementedError(msg)
277
+ self._cmds.extend(("C", *map(str, it.chain(*pts))))
278
+
279
+ def qCurveTo(self, *pts: tuple[float, float]) -> None:
280
+ """Add a series of quadratic bezier segments to the path."""
281
+ for q_pts in _split_into_quadratic(*pts):
282
+ self._cmds.extend(("Q", *map(str, it.chain(*q_pts))))
283
+
284
+ def closePath(self) -> None:
285
+ """Close the current path."""
286
+ self._cmds.append("Z")
287
+
288
+
289
+ class FTFontInfo:
290
+ """Hide all the type kludging necessary to use fontTools."""
291
+
292
+ def __init__(self, font_path: str | os.PathLike[str]) -> None:
293
+ """Initialize the SUFont with a path to a TTF font file."""
294
+ self._path = Path(font_path)
295
+ if not self.path.exists():
296
+ msg = f"Font file '{self.path}' does not exist."
297
+ raise FileNotFoundError(msg)
298
+ self._font = TTFont(self.path)
299
+
300
+ @property
301
+ def path(self) -> Path:
302
+ """Return the path to the font file."""
303
+ return self._path
304
+
305
+ @property
306
+ def font(self) -> TTFont:
307
+ """Return the fontTools TTFont object."""
308
+ return self._font
309
+
310
+ @ft.cached_property
311
+ def units_per_em(self) -> int:
312
+ """Get the units per em for the font.
313
+
314
+ :return: The units per em for the font. For a ttf, this will usually
315
+ (always?) be 2048.
316
+ :raises ValueError: If the font does not have a 'head' table or 'unitsPerEm'
317
+ attribute.
318
+ """
319
+ try:
320
+ maybe_units_per_em = cast("int | None", self.font["head"].unitsPerEm)
321
+ except (KeyError, AttributeError) as e:
322
+ msg = (
323
+ f"Font '{self.path}' does not have"
324
+ + " 'head' table or 'unitsPerEm' attribute: {e}"
325
+ )
326
+ raise ValueError(msg) from e
327
+ if maybe_units_per_em is None:
328
+ msg = f"Font '{self.path}' does not have 'unitsPerEm' defined."
329
+ raise ValueError(msg)
330
+ return maybe_units_per_em
331
+
332
+ @ft.cached_property
333
+ def kern_table(self) -> dict[tuple[str, str], int]:
334
+ """Get the kerning pairs for the font.
335
+
336
+ :return: A dictionary mapping glyph pairs to their kerning values.
337
+ :raises ValueError: If the font does not have a 'kern' table.
338
+
339
+ I haven't run across a font with multiple kern tables, but *if* a font had
340
+ multiple tables and *if* the same pair were defined in multiple tables, this
341
+ method would give precedence to the first occurrence. That behavior is copied
342
+ from examples found online.
343
+ """
344
+ try:
345
+ kern_tables = cast(
346
+ "list[dict[tuple[str, str], int]]",
347
+ [x.kernTable for x in self.font["kern"].kernTables],
348
+ )
349
+ kern = dict(x for d in reversed(kern_tables) for x in d.items())
350
+ except (KeyError, AttributeError):
351
+ kern = {}
352
+ with suppress(Exception):
353
+ kern.update(_get_gpos_kerning(self.font))
354
+
355
+ return kern
356
+
357
+ @ft.cached_property
358
+ def hhea(self) -> Any:
359
+ """Get the horizontal header table for the font.
360
+
361
+ :return: The horizontal header table for the font.
362
+ :raises ValueError: If the font does not have a 'hhea' table.
363
+ """
364
+ try:
365
+ return cast("Any", self.font["hhea"])
366
+ except KeyError as e:
367
+ msg = f"Font '{self.path}' does not have a 'hhea' table: {e}"
368
+ raise ValueError(msg) from e
369
+
370
+ def get_glyph_name(self, char: str) -> str:
371
+ """Get the glyph name for a character in the font.
372
+
373
+ :param char: The character to get the glyph name for.
374
+ :return: The glyph name for the character.
375
+ :raises ValueError: If the character is not found in the font.
376
+ """
377
+ ord_char = ord(char)
378
+ char_map = cast("dict[int, str]", self.font.getBestCmap())
379
+ if ord_char in char_map:
380
+ return char_map[ord_char]
381
+ msg = f"Character '{char}' not found in font '{self.path}'."
382
+ raise ValueError(msg)
383
+
384
+ def get_char_svgd(self, char: str, dx: float = 0) -> str:
385
+ """Return the svg path data for a glyph.
386
+
387
+ :param char: The character to get the svg path data for.
388
+ :param dx: An optional x translation to apply to the glyph.
389
+ :return: The svg path data for the character.
390
+ """
391
+ glyph_name = self.get_glyph_name(char)
392
+ glyph_set = self.font.getGlyphSet()
393
+ path_pen = PathPen(glyph_set)
394
+ _ = glyph_set[glyph_name].draw(path_pen)
395
+ svgd = path_pen.svgd
396
+ if not dx or not svgd:
397
+ return format_svgd_shortest(svgd)
398
+ cpts = get_cpts_from_svgd(svgd)
399
+ for i, curve in enumerate(cpts):
400
+ cpts[i][:] = [(x + dx, y) for x, y in curve]
401
+ svgd = format_svgd_shortest(get_svgd_from_cpts(cpts))
402
+ return "M" + svgd[1:]
403
+
404
+ def get_char_bounds(self, char: str) -> tuple[int, int, int, int]:
405
+ """Return the min and max x and y coordinates of a glyph.
406
+
407
+ There are two ways to get the bounds of a glyph, using an object from
408
+ font["glyf"] or this awkward-looking method. Most of the time, they are the
409
+ same, but when they disagree, this method is more accurate. Additionally,
410
+ some fonts do not have a glyf table, so this method is more robust.
411
+ """
412
+ glyph_name = self.get_glyph_name(char)
413
+ glyph_set = self.font.getGlyphSet()
414
+ bounds_pen = BoundsPen(glyph_set)
415
+ _ = glyph_set[glyph_name].draw(bounds_pen)
416
+ pen_bounds = cast("None | tuple[int, int, int, int]", bounds_pen.bounds)
417
+ if pen_bounds is None:
418
+ return 0, 0, 0, 0
419
+ x_min, y_min, x_max, y_max = pen_bounds
420
+ return x_min, y_min, x_max, y_max
421
+
422
+ def get_char_bbox(self, char: str) -> BoundingBox:
423
+ """Return the BoundingBox of a character svg coordinates.
424
+
425
+ Don't miss: this not only converts min and max x and y to x, y, width,
426
+ height; it also converts from Cartesian coordinates (+y is up) to SVG
427
+ coordinates (+y is down).
428
+ """
429
+ min_x, min_y, max_x, max_y = self.get_char_bounds(char)
430
+ return BoundingBox(min_x, -max_y, max_x - min_x, max_y - min_y)
431
+
432
+ def get_text_bounds(self, text: str) -> tuple[int, int, int, int]:
433
+ """Return bounds of a string as xmin, ymin, xmax, ymax.
434
+
435
+ :param font_path: path to a TTF font file
436
+ :param text: a string to get the bounding box for
437
+
438
+ The max x value of a string is the sum of the hmtx advances for each glyph
439
+ with some adjustments:
440
+
441
+ * The rightmost glyph's actual width is used instead of its advance (because
442
+ no space is added after the last glyph).
443
+ * The kerning between each pair of glyphs is added to the total advance.
444
+
445
+ These bounds are in Cartesian coordinates, not translated to SVGs screen
446
+ coordinates, and not x, y, width, height.
447
+ """
448
+ hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
449
+
450
+ names = [self.get_glyph_name(c) for c in text]
451
+ bounds = [self.get_char_bounds(c) for c in text]
452
+ total_advance = sum(hmtx[n][0] for n in names[:-1])
453
+ total_kern = sum(self.kern_table.get((x, y), 0) for x, y in it.pairwise(names))
454
+ min_xs, min_ys, max_xs, max_ys = zip(*bounds, strict=True)
455
+ min_x = min_xs[0]
456
+ min_y = min(min_ys)
457
+
458
+ max_x = total_advance + max_xs[-1] + total_kern
459
+ max_y = max(max_ys)
460
+ return min_x, min_y, max_x, max_y
461
+
462
+ def get_text_svgd_by_char(self, text: str, dx: float = 0) -> Iterator[str]:
463
+ """Return an iterator of svg path data for each character in a string.
464
+
465
+ :param text: The text to get the svg path data for.
466
+ :param dx: An optional x translation to apply to the entire text.
467
+ :return: An iterator of svg path data for each character in the text.
468
+ """
469
+ hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
470
+ char_dx = dx
471
+ for c_this, c_next in it.pairwise(text):
472
+ this_name = self.get_glyph_name(c_this)
473
+ next_name = self.get_glyph_name(c_next)
474
+ yield self.get_char_svgd(c_this, char_dx)
475
+ char_dx += hmtx[this_name][0]
476
+ char_dx += self.kern_table.get((this_name, next_name), 0)
477
+ yield self.get_char_svgd(text[-1], char_dx)
478
+
479
+ def get_text_svgd(self, text: str, dx: float = 0) -> str:
480
+ """Return the svg path data for a string.
481
+
482
+ :param text: The text to get the svg path data for.
483
+ :param dx: An optional x translation to apply to the entire text.
484
+ :return: The svg path data for the text.
485
+ """
486
+ return "".join(self.get_text_svgd_by_char(text, dx))
487
+
488
+ def get_text_svgds(self, text: str, dx: float = 0) -> list[tuple[str, str]]:
489
+ """Return a list of svg path data for each character in a string.
490
+
491
+ :param text: The text to get the svg path data for.
492
+ :param dx: An optional x translation to apply to the entire text.
493
+ :return: A list of svg path data for each character in the text.
494
+ """
495
+ hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
496
+ svgds: list[tuple[str, str]] = []
497
+ for c_this, c_next in it.pairwise(text):
498
+ this_name = self.get_glyph_name(c_this)
499
+ next_name = self.get_glyph_name(c_next)
500
+ svgd = self.get_char_svgd(c_this)
501
+ tmat = "" if dx == 0 else f"matrix(1 0 0 1 {dx} 0)"
502
+ svgds.append((svgd, tmat))
503
+ dx += hmtx[this_name][0]
504
+ dx += self.kern_table.get((this_name, next_name), 0)
505
+ svgd = self.get_char_svgd(text[-1])
506
+ tmat = "" if dx == 0 else f"matrix(1 0 0 1 {dx} 0)"
507
+ svgds.append((svgd, tmat))
508
+ return svgds
509
+
510
+ def get_text_bbox(self, text: str) -> BoundingBox:
511
+ """Return the BoundingBox of a string svg coordinates.
512
+
513
+ Don't miss: this not only converts min and max x and y to x, y, width,
514
+ height; it also converts from Cartesian coordinates (+y is up) to SVG
515
+ coordinates (+y is down).
516
+ """
517
+ min_x, min_y, max_x, max_y = self.get_text_bounds(text)
518
+ return BoundingBox(min_x, -max_y, max_x - min_x, max_y - min_y)
519
+
520
+ def get_lsb(self, char: str) -> float:
521
+ """Return the left side bearing of a character."""
522
+ hmtx = cast("Any", self.font["hmtx"])
523
+ _, lsb = hmtx.metrics[self.get_glyph_name(char)]
524
+ return lsb
525
+
526
+ def get_rsb(self, char: str) -> float:
527
+ """Return the right side bearing of a character."""
528
+ glyph_name = self.get_glyph_name(char)
529
+ glyph_width = self.get_char_bbox(char).width
530
+ hmtx = cast("dict[str, tuple[int, int]]", self.font["hmtx"])
531
+ advance, lsb = hmtx[glyph_name]
532
+ return advance - (lsb + glyph_width)
533
+
534
+
535
+ class FTTextInfo:
536
+ """Scale the fontTools font information for a specific text and font size."""
537
+
538
+ def __init__(
539
+ self,
540
+ font: str | os.PathLike[str] | FTFontInfo,
541
+ text: str,
542
+ font_size: float | None = None,
543
+ ascent: float | None = None,
544
+ descent: float | None = None,
545
+ ) -> None:
546
+ """Initialize the SUText with text, a SUFont instance, and font size."""
547
+ if isinstance(font, FTFontInfo):
548
+ self._font = font
549
+ else:
550
+ self._font = FTFontInfo(font)
551
+ self._text = text.rstrip(" ")
552
+ self._font_size = font_size or self._font.units_per_em
553
+ self._ascent = ascent
554
+ self._descent = descent
555
+
556
+ @property
557
+ def font(self) -> FTFontInfo:
558
+ """Return the font information."""
559
+ return self._font
560
+
561
+ @property
562
+ def text(self) -> str:
563
+ """Return the text."""
564
+ return self._text
565
+
566
+ @property
567
+ def font_size(self) -> float:
568
+ """Return the font size."""
569
+ return self._font_size
570
+
571
+ @property
572
+ def scale(self) -> float:
573
+ """Return the scale factor for the font size.
574
+
575
+ :return: The scale factor for the font size.
576
+ """
577
+ return self.font_size / self.font.units_per_em
578
+
579
+ def new_element(self, **attributes: ElemAttrib) -> EtreeElement:
580
+ """Return an svg text element with the appropriate font attributes."""
581
+ matrix_vals = (self.scale, 0, 0, -self.scale, 0, 0)
582
+ attributes["transform"] = svg_matrix(matrix_vals)
583
+ stroke_width = attributes.get("stroke-width")
584
+ if stroke_width:
585
+ attributes["stroke-width"] = float(stroke_width) / self.scale
586
+ data_text = _sanitize_svg_data_text(self.text)
587
+ group = new_element("g", data_text=data_text, **attributes)
588
+ for i, (svgd, tmat) in enumerate(self.font.get_text_svgds(self.text)):
589
+ if not svgd:
590
+ continue
591
+ data_text = _sanitize_svg_data_text(self.text[i])
592
+ if not tmat:
593
+ _ = new_sub_element(group, "path", data_text=data_text, d=svgd)
594
+ continue
595
+ _ = new_sub_element(
596
+ group, "path", data_text=data_text, d=svgd, transform=tmat
597
+ )
598
+ return group
599
+
600
+ def new_chars_group_element(self, **attributes: ElemAttrib) -> EtreeElement:
601
+ """Return an svg group element with a path for each character in the text."""
602
+ names = map(self.font.get_glyph_name, self.text)
603
+ svgds = self.font.get_text_svgd_by_char(self.text)
604
+ group = new_element("g", **attributes)
605
+ for name, svgd in zip(names, svgds, strict=True):
606
+ data_text = _sanitize_svg_data_text(name)
607
+ _ = new_sub_element(group, "path", data_text=data_text, d=svgd)
608
+ matrix_vals = (self.scale, 0, 0, -self.scale, 0, 0)
609
+ group.attrib["transform"] = svg_matrix(matrix_vals)
610
+ stroke_width = group.attrib.get("stroke-width")
611
+ if stroke_width:
612
+ _ = update_element(group, stroke_width=float(stroke_width) / self.scale)
613
+ return group
614
+
615
+ @property
616
+ def bbox(self) -> BoundingBox:
617
+ """Return the bounding box of the text.
618
+
619
+ :return: A BoundingBox in svg coordinates.
620
+ """
621
+ bbox = self.font.get_text_bbox(self.text)
622
+ bbox.transform(scale=self.scale)
623
+ return BoundingBox(*bbox.values())
624
+
625
+ @property
626
+ def ascent(self) -> float:
627
+ """Return the ascent of the font."""
628
+ if self._ascent is None:
629
+ self._ascent = self.font.hhea.ascent * self.scale
630
+ return self._ascent
631
+
632
+ @property
633
+ def descent(self) -> float:
634
+ """Return the descent of the font."""
635
+ if self._descent is None:
636
+ self._descent = self.font.hhea.descent * self.scale
637
+ return self._descent
638
+
639
+ @property
640
+ def line_gap(self) -> float:
641
+ """Return the height of the capline for the font."""
642
+ return self.font.hhea.lineGap * self.scale
643
+
644
+ @property
645
+ def line_spacing(self) -> float:
646
+ """Return the line spacing for the font."""
647
+ return self.descent + self.ascent + self.line_gap
648
+
649
+ @property
650
+ def tpad(self) -> float:
651
+ """Return the top padding for the text."""
652
+ return self.ascent + self.bbox.y
653
+
654
+ @property
655
+ def rpad(self) -> float:
656
+ """Return the right padding for the text.
657
+
658
+ This is the right side bearing of the last glyph in the text.
659
+ """
660
+ return self.font.get_rsb(self.text[-1]) * self.scale
661
+
662
+ @property
663
+ def bpad(self) -> float:
664
+ """Return the bottom padding for the text."""
665
+ return -self.descent - self.bbox.y2
666
+
667
+ @property
668
+ def lpad(self) -> float:
669
+ """Return the left padding for the text.
670
+
671
+ This is the left side bearing of the first glyph in the text.
672
+ """
673
+ return self.font.get_lsb(self.text[0]) * self.scale
674
+
675
+ @property
676
+ def padding(self) -> tuple[float, float, float, float]:
677
+ """Return the padding for the text as a tuple of (top, right, bottom, left)."""
678
+ return self.tpad, self.rpad, self.bpad, self.lpad
679
+
680
+
681
+ def get_font_size_given_height(font: str | os.PathLike[str], height: float) -> float:
682
+ """Return the font size that would give the given line height.
683
+
684
+ :param font: path to a font file.
685
+ :param height: desired line height in pixels.
686
+
687
+ Where line height is the distance from the longest possible descender to the
688
+ longest possible ascender.
689
+ """
690
+ font_info = FTFontInfo(font)
691
+ units_per_em = font_info.units_per_em
692
+ if units_per_em <= 0:
693
+ msg = f"Font '{font}' has invalid units per em: {units_per_em}"
694
+ raise ValueError(msg)
695
+ line_height = font_info.hhea.ascent - font_info.hhea.descent
696
+ return height / line_height * units_per_em
697
+
698
+
699
+ def get_padded_text_info(
700
+ font: str | os.PathLike[str] | FTFontInfo,
701
+ text: str,
702
+ font_size: float | None = None,
703
+ ascent: float | None = None,
704
+ descent: float | None = None,
705
+ *,
706
+ y_bounds_reference: str | None = None,
707
+ ) -> FTTextInfo:
708
+ """Return a FTTextInfo object for the given text and font.
709
+
710
+ :param font: path to a font file.
711
+ :param text: the text to get the information for.
712
+ :param font_size: the font size to use.
713
+ :param ascent: the ascent of the font. If not provided, it will be calculated
714
+ from the font file.
715
+ :param descent: the descent of the font, usually a negative number. If not
716
+ provided, it will be calculated from the font file.
717
+ :param y_bounds_reference: optional character or string to use as a reference
718
+ for the ascent and descent. If provided, the ascent and descent will be the y
719
+ extents of the capline reference. This argument is provided to mimic the
720
+ behavior of the query module's `pad_text` function. `pad_text` does no
721
+ inspect font files and relies on Inkscape to measure reference characters.
722
+ :return: A FTTextInfo object with the information necessary to create a
723
+ PaddedText instance: bbox, tpad, rpad, bpad, lpad.
724
+ """
725
+ if isinstance(font, FTFontInfo):
726
+ font_info = font
727
+ else:
728
+ font_info = FTFontInfo(font)
729
+ if y_bounds_reference:
730
+ capline_info = FTTextInfo(font_info, y_bounds_reference, font_size)
731
+ ascent = -capline_info.bbox.y
732
+ descent = -capline_info.bbox.y2
733
+
734
+ return FTTextInfo(font_info, text, font_size, ascent, descent)
735
+
736
+
737
+ # ===================================================================================
738
+ # Infer svg font attributes from a ttf or otf file
739
+ # ===================================================================================
740
+
741
+ # This is the record nameID that most consistently reproduce the desired font
742
+ # characteristics in svg.
743
+ _NAME_ID = 1
744
+ _STYLE_ID = 2
745
+
746
+ # Windows
747
+ _PLATFORM_ID = 3
748
+
749
+
750
+ def _get_font_names(
751
+ path_to_font: str | os.PathLike[str],
752
+ ) -> tuple[str | None, str | None]:
753
+ """Get the family and style of a font from a ttf or otf file path.
754
+
755
+ :param path_to_font: path to a ttf or otf file
756
+ :return: One of many names of the font (e.g., "HelveticaNeue-CondensedBlack") or
757
+ None and a style name (e.g., "Bold") as a tuple or None. This seems to be the
758
+ convention that semi-reliably works with Inkscape.
759
+
760
+ These are loosely the font-family and font-style, but they will not usually work
761
+ in Inkscape without some transation (see translate_font_style).
762
+ """
763
+ font = TTFont(path_to_font)
764
+ name_table = cast("Any", font["name"])
765
+ font.close()
766
+ family = None
767
+ style = None
768
+ for i, record in enumerate(name_table.names):
769
+ if record.nameID == _NAME_ID and record.platformID == _PLATFORM_ID:
770
+ family = record.toUnicode()
771
+ next_record = (
772
+ name_table.names[i + 1] if i + 1 < len(name_table.names) else None
773
+ )
774
+ if (
775
+ next_record is not None
776
+ and next_record.nameID == _STYLE_ID
777
+ and next_record.platformID == _PLATFORM_ID
778
+ ):
779
+ style = next_record.toUnicode()
780
+ break
781
+ return family, style
782
+
783
+
784
+ _FONT_STYLE_TERMS = [
785
+ "italic",
786
+ "oblique",
787
+ ]
788
+ _FONT_WEIGHT_MAP = {
789
+ "ultralight": "100",
790
+ "demibold": "600",
791
+ "light": "300",
792
+ "bold": "bold",
793
+ "black": "900",
794
+ }
795
+ _FONT_STRETCH_TERMS = [
796
+ "ultra-condensed",
797
+ "extra-condensed",
798
+ "semi-condensed",
799
+ "condensed",
800
+ "normal",
801
+ "semi-expanded",
802
+ "extra-expanded",
803
+ "ultra-expanded",
804
+ "expanded",
805
+ ]
806
+
807
+
808
+ def _translate_font_style(style: str | None) -> dict[str, str]:
809
+ """Translate the myriad font styles retured by ttLib into valid svg styles.
810
+
811
+ :param style: the style string from a ttf or otf file, extracted by
812
+ _get_font_names(path_to_font)[1].
813
+ :return: a dictionary with keys 'font-style', 'font-weight', and 'font-stretch'
814
+
815
+ Attempt to create a set of svg font attributes that will reprduce a desired ttf
816
+ or otf font.
817
+ """
818
+ result: dict[str, str] = {}
819
+ if style is None:
820
+ return result
821
+ style = style.lower()
822
+ for font_style_term in _FONT_STYLE_TERMS:
823
+ if font_style_term in style:
824
+ result["font-style"] = font_style_term
825
+ break
826
+ for k, v in _FONT_WEIGHT_MAP.items():
827
+ if k in style:
828
+ result["font-weight"] = v
829
+ break
830
+ for font_stretch_term in _FONT_STRETCH_TERMS:
831
+ if font_stretch_term in style:
832
+ result["font-stretch"] = font_stretch_term
833
+ break
834
+ return result
835
+
836
+
837
+ def get_svg_font_attributes(path_to_font: str | os.PathLike[str]) -> dict[str, str]:
838
+ """Attempt to get svg font attributes (font-family, font-style, etc).
839
+
840
+ :param path_to_font: path to a ttf or otf file
841
+ :return: {'font-family': 'AgencyFB-Bold'}
842
+ """
843
+ svg_font_attributes: dict[str, str] = {}
844
+ family, style = _get_font_names(path_to_font)
845
+ if family is None:
846
+ return svg_font_attributes
847
+ svg_font_attributes["font-family"] = family
848
+ svg_font_attributes.update(_translate_font_style(style))
849
+ return svg_font_attributes