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