svg-ultralight 0.47.0__py3-none-any.whl → 0.50.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.

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