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.
- svg_ultralight/__init__.py +108 -105
- svg_ultralight/animate.py +40 -40
- svg_ultralight/attrib_hints.py +13 -14
- svg_ultralight/bounding_boxes/__init__.py +5 -5
- svg_ultralight/bounding_boxes/bound_helpers.py +189 -201
- svg_ultralight/bounding_boxes/padded_text_initializers.py +207 -206
- svg_ultralight/bounding_boxes/supports_bounds.py +166 -166
- svg_ultralight/bounding_boxes/type_bound_collection.py +71 -71
- svg_ultralight/bounding_boxes/type_bound_element.py +65 -65
- svg_ultralight/bounding_boxes/type_bounding_box.py +396 -396
- svg_ultralight/bounding_boxes/type_padded_text.py +411 -411
- svg_ultralight/constructors/__init__.py +14 -14
- svg_ultralight/constructors/new_element.py +115 -115
- svg_ultralight/font_tools/__init__.py +5 -5
- svg_ultralight/font_tools/comp_results.py +295 -293
- svg_ultralight/font_tools/font_info.py +793 -784
- svg_ultralight/image_ops.py +156 -156
- svg_ultralight/inkscape.py +261 -261
- svg_ultralight/layout.py +290 -291
- svg_ultralight/main.py +183 -198
- svg_ultralight/metadata.py +122 -122
- svg_ultralight/nsmap.py +36 -36
- svg_ultralight/py.typed +5 -0
- svg_ultralight/query.py +254 -249
- svg_ultralight/read_svg.py +58 -0
- svg_ultralight/root_elements.py +87 -87
- svg_ultralight/string_conversion.py +244 -244
- svg_ultralight/strings/__init__.py +21 -13
- svg_ultralight/strings/svg_strings.py +106 -67
- svg_ultralight/transformations.py +140 -141
- svg_ultralight/unit_conversion.py +247 -248
- {svg_ultralight-0.47.0.dist-info → svg_ultralight-0.50.1.dist-info}/METADATA +208 -214
- svg_ultralight-0.50.1.dist-info/RECORD +34 -0
- svg_ultralight-0.50.1.dist-info/WHEEL +4 -0
- svg_ultralight-0.47.0.dist-info/RECORD +0 -34
- svg_ultralight-0.47.0.dist-info/WHEEL +0 -5
- 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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
"""
|
|
131
|
-
|
|
132
|
-
:
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
@
|
|
299
|
-
def
|
|
300
|
-
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
def
|
|
421
|
-
"""Return
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
return
|
|
478
|
-
|
|
479
|
-
def
|
|
480
|
-
"""Return the
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
self
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
@property
|
|
526
|
-
def
|
|
527
|
-
"""Return the font
|
|
528
|
-
return self.
|
|
529
|
-
|
|
530
|
-
@property
|
|
531
|
-
def
|
|
532
|
-
"""Return the
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
:
|
|
649
|
-
:
|
|
650
|
-
:
|
|
651
|
-
:
|
|
652
|
-
|
|
653
|
-
:
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
return FTTextInfo
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
#
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
"""
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
font.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
"
|
|
734
|
-
"
|
|
735
|
-
"
|
|
736
|
-
"
|
|
737
|
-
"
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
""
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
+
"&": "&",
|
|
128
|
+
"<": "<",
|
|
129
|
+
">": ">",
|
|
130
|
+
'"': """,
|
|
131
|
+
"'": "'",
|
|
132
|
+
"{": "{", # valid, but stops MS File Explorer from thumbnailing an svg
|
|
133
|
+
"}": "}", # 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
|