svg-ultralight 0.39.0__py3-none-any.whl → 0.40.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of svg-ultralight might be problematic. Click here for more details.

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