NotYAFC 1.0.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.
- font_converter/__init__.py +4 -0
- font_converter/__main__.py +45 -0
- font_converter/assets/ico.png +0 -0
- font_converter/assets/notyafc-1024.png +0 -0
- font_converter/assets/notyafc.ico +0 -0
- font_converter/assets/notyafc.svg +15 -0
- font_converter/cli.py +543 -0
- font_converter/constants.py +27 -0
- font_converter/contours.py +336 -0
- font_converter/css_kit.py +77 -0
- font_converter/finder.py +213 -0
- font_converter/font.py +530 -0
- font_converter/gui/__init__.py +1 -0
- font_converter/gui/conversion_dialog.py +110 -0
- font_converter/gui/file_list.py +411 -0
- font_converter/gui/instance_tree.py +583 -0
- font_converter/gui/logs.py +51 -0
- font_converter/gui/organization_dialog.py +417 -0
- font_converter/gui/profile_options.py +316 -0
- font_converter/gui/profiles.py +17 -0
- font_converter/gui/scanner.py +42 -0
- font_converter/gui/styles.py +658 -0
- font_converter/gui/widgets.py +495 -0
- font_converter/gui/window.py +1768 -0
- font_converter/inspection.py +169 -0
- font_converter/merge_curves.py +499 -0
- font_converter/otf_builder.py +144 -0
- font_converter/qu2cu.py +117 -0
- font_converter/service.py +729 -0
- font_converter/ttf_builder.py +107 -0
- notyafc-1.0.0.dist-info/METADATA +256 -0
- notyafc-1.0.0.dist-info/RECORD +34 -0
- notyafc-1.0.0.dist-info/WHEEL +4 -0
- notyafc-1.0.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from collections.abc import Callable, Mapping
|
|
3
|
+
|
|
4
|
+
import pathops
|
|
5
|
+
from fontTools.cffLib import CFFFontSet
|
|
6
|
+
from fontTools.misc.psCharStrings import T2CharString
|
|
7
|
+
from fontTools.misc.roundTools import noRound, otRound
|
|
8
|
+
from fontTools.pens.t2CharStringPen import T2CharStringPen
|
|
9
|
+
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
|
10
|
+
from fontTools.ttLib import TTFont
|
|
11
|
+
from fontTools.ttLib.tables import _g_l_y_f, _h_m_t_x
|
|
12
|
+
from fontTools.ttLib.ttGlyphSet import _TTGlyph
|
|
13
|
+
|
|
14
|
+
from font_converter.constants import T_CFF, T_GLYF, T_HMTX
|
|
15
|
+
|
|
16
|
+
_TTGlyphMapping = Mapping[str, _TTGlyph]
|
|
17
|
+
|
|
18
|
+
__all__ = ["correct_cff_contours", "correct_glyf_contours", "simplify_path"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CorrectContoursError(Exception):
|
|
22
|
+
"""Raised when an error occurs while correcting the contours of a TrueType font."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _skia_path_from_glyph(glyph_name: str, glyph_set: _TTGlyphMapping) -> pathops.Path:
|
|
26
|
+
path = pathops.Path()
|
|
27
|
+
pen = path.getPen(glyphSet=glyph_set)
|
|
28
|
+
glyph_set[glyph_name].draw(pen)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _skia_path_from_glyph_component(
|
|
33
|
+
component: _g_l_y_f.GlyphComponent, glyph_set: _TTGlyphMapping
|
|
34
|
+
) -> pathops.Path:
|
|
35
|
+
base_glyph_name, transformation = component.getComponentInfo()
|
|
36
|
+
path = _skia_path_from_glyph(glyph_name=base_glyph_name, glyph_set=glyph_set)
|
|
37
|
+
return path.transform(*transformation)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ttf_components_overlap(glyph: _g_l_y_f.Glyph, glyph_set: _TTGlyphMapping) -> bool:
|
|
41
|
+
if not glyph.isComposite():
|
|
42
|
+
raise ValueError("This method only works with TrueType composite glyphs")
|
|
43
|
+
if len(glyph.components) < 2:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
component_paths = {}
|
|
47
|
+
|
|
48
|
+
def _get_nth_component_path(index: int) -> pathops.Path:
|
|
49
|
+
if index not in component_paths:
|
|
50
|
+
component_paths[index] = _skia_path_from_glyph_component(
|
|
51
|
+
glyph.components[index], glyph_set
|
|
52
|
+
)
|
|
53
|
+
return component_paths[index]
|
|
54
|
+
|
|
55
|
+
return any(
|
|
56
|
+
pathops.op(
|
|
57
|
+
_get_nth_component_path(i),
|
|
58
|
+
_get_nth_component_path(j),
|
|
59
|
+
pathops.PathOp.INTERSECTION,
|
|
60
|
+
clockwise=True, # type: ignore
|
|
61
|
+
fix_winding=True, # type: ignore
|
|
62
|
+
)
|
|
63
|
+
for i, j in itertools.combinations(range(len(glyph.components)), 2)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tt_glyph_from_skia_path(path: pathops.Path) -> _g_l_y_f.Glyph:
|
|
68
|
+
tt_pen = TTGlyphPen(glyphSet=None)
|
|
69
|
+
path.draw(tt_pen)
|
|
70
|
+
glyph = tt_pen.glyph()
|
|
71
|
+
glyph.recalcBounds(glyfTable=None)
|
|
72
|
+
return glyph
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _t2_charstring_from_skia_path(
|
|
76
|
+
path: pathops.Path,
|
|
77
|
+
charstring: T2CharString,
|
|
78
|
+
) -> T2CharString:
|
|
79
|
+
# https://github.com/fonttools/fonttools/commit/40b525c1e3cc20b4b64004b8e3224a67adc2adf1
|
|
80
|
+
# The width argument of `T2CharStringPen()` is inserted directly into the CharString
|
|
81
|
+
# program, so it must be relative to Private.nominalWidthX.
|
|
82
|
+
if charstring.width == charstring.private.defaultWidthX:
|
|
83
|
+
width = None
|
|
84
|
+
else:
|
|
85
|
+
width = charstring.width - charstring.private.nominalWidthX
|
|
86
|
+
|
|
87
|
+
t2_pen = T2CharStringPen(width=width, glyphSet=None)
|
|
88
|
+
path.draw(t2_pen)
|
|
89
|
+
charstring = t2_pen.getCharString(charstring.private, charstring.globalSubrs)
|
|
90
|
+
return charstring
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _round_path(path: pathops.Path, rounder: Callable[[float], float] = otRound) -> pathops.Path:
|
|
94
|
+
rounded_path = pathops.Path()
|
|
95
|
+
for verb, points in path:
|
|
96
|
+
rounded_path.add(verb, *((rounder(p[0]), rounder(p[1])) for p in points))
|
|
97
|
+
return rounded_path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _same_path(path_1: pathops.Path, path_2: pathops.Path) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
Checks if two pathops paths are the same.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path_1 (pathops.Path): The first path
|
|
106
|
+
path_2 (pathops.Path): The second path
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
bool: ``True`` if the paths are the same, ``False`` if the paths are different
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
return {tuple(c) for c in path_1.contours} == {tuple(c) for c in path_2.contours}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _remove_tiny_paths(path: pathops.Path, min_area: float) -> pathops.Path:
|
|
116
|
+
"""
|
|
117
|
+
Removes tiny paths from a ``pathops.Path`` object. A path is considered tiny if its area is less
|
|
118
|
+
than the given minimum area (default is 25).
|
|
119
|
+
|
|
120
|
+
:param path: The path to clean.
|
|
121
|
+
:type path: pathops.Path
|
|
122
|
+
:param min_area: The minimum area of a contour to be considered. Defaults to 25.
|
|
123
|
+
:type min_area: int
|
|
124
|
+
:return: The cleaned path.
|
|
125
|
+
:rtype: pathops.Path
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
cleaned_path = pathops.Path()
|
|
129
|
+
for contour in path.contours:
|
|
130
|
+
if contour.area >= min_area:
|
|
131
|
+
cleaned_path.addPath(contour)
|
|
132
|
+
return cleaned_path
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _correct_tt_glyph_contours(
|
|
136
|
+
glyph_name: str,
|
|
137
|
+
glyph_set: _TTGlyphMapping,
|
|
138
|
+
glyf_table: _g_l_y_f.table__g_l_y_f,
|
|
139
|
+
hmtx_table: _h_m_t_x.table__h_m_t_x,
|
|
140
|
+
remove_hinting: bool = True,
|
|
141
|
+
min_area: int = 25,
|
|
142
|
+
) -> bool:
|
|
143
|
+
glyph: _g_l_y_f.Glyph = glyf_table[glyph_name]
|
|
144
|
+
# decompose composite glyphs only if components overlap each other
|
|
145
|
+
if (
|
|
146
|
+
glyph.numberOfContours > 0
|
|
147
|
+
or glyph.isComposite()
|
|
148
|
+
and _ttf_components_overlap(glyph=glyph, glyph_set=glyph_set)
|
|
149
|
+
):
|
|
150
|
+
path = _skia_path_from_glyph(glyph_name, glyph_set)
|
|
151
|
+
path_2 = simplify_path(path, glyph_name, clockwise=True)
|
|
152
|
+
if min_area > 0:
|
|
153
|
+
path_2 = _remove_tiny_paths(path_2, min_area=min_area)
|
|
154
|
+
|
|
155
|
+
if not _same_path(path_1=path, path_2=path_2):
|
|
156
|
+
glyf_table[glyph_name] = glyph = _tt_glyph_from_skia_path(path_2)
|
|
157
|
+
width, lsb = hmtx_table[glyph_name]
|
|
158
|
+
if lsb != glyph.xMin:
|
|
159
|
+
hmtx_table[glyph_name] = (width, glyph.xMin)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
if remove_hinting:
|
|
163
|
+
glyph.removeHinting()
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _correct_charstring_contours(
|
|
168
|
+
glyph_name: str,
|
|
169
|
+
glyph_set: _TTGlyphMapping,
|
|
170
|
+
cff_font_set: CFFFontSet,
|
|
171
|
+
min_area: float = 25,
|
|
172
|
+
) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
path = _skia_path_from_glyph(glyph_name, glyph_set)
|
|
175
|
+
path_2 = simplify_path(path, glyph_name, clockwise=False)
|
|
176
|
+
|
|
177
|
+
if min_area > 0:
|
|
178
|
+
path_2 = _remove_tiny_paths(path_2, min_area=min_area)
|
|
179
|
+
|
|
180
|
+
if not _same_path(path_1=path, path_2=path_2):
|
|
181
|
+
charstrings = cff_font_set[0].CharStrings
|
|
182
|
+
charstrings[glyph_name] = _t2_charstring_from_skia_path(path_2, charstrings[glyph_name])
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
return False
|
|
186
|
+
except Exception as e:
|
|
187
|
+
raise CorrectContoursError(f"Failed to correct contours of glyph {glyph_name!r}") from e
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def simplify_path(path: pathops.Path, glyph_name: str, clockwise: bool) -> pathops.Path:
|
|
191
|
+
"""
|
|
192
|
+
Simplifies a ``pathops.Path`` by removing overlaps and correcting the direction of the contours.
|
|
193
|
+
|
|
194
|
+
:param path: The ``pathops.Path`` to simplify.
|
|
195
|
+
:type path: pathops.Path
|
|
196
|
+
:param glyph_name: The name of the glyph.
|
|
197
|
+
:type glyph_name: str
|
|
198
|
+
:param clockwise: Whether the contours should be clockwise.
|
|
199
|
+
:type clockwise: bool
|
|
200
|
+
:raises CorrectContoursError: If an error occurs while simplifying the path.
|
|
201
|
+
:return: The simplified path.
|
|
202
|
+
:rtype: pathops.Path
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
return pathops.simplify(path, fix_winding=True, clockwise=clockwise) # type: ignore
|
|
207
|
+
except pathops.PathOpsError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
path = _round_path(path, rounder=noRound)
|
|
211
|
+
try:
|
|
212
|
+
path = pathops.simplify(path, fix_winding=True, clockwise=clockwise) # type: ignore
|
|
213
|
+
return path
|
|
214
|
+
except pathops.PathOpsError as e:
|
|
215
|
+
raise CorrectContoursError(f"Failed to remove overlaps from glyph {glyph_name!r}") from e
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def correct_glyf_contours(
|
|
219
|
+
font: TTFont,
|
|
220
|
+
remove_hinting: bool,
|
|
221
|
+
ignore_errors: bool,
|
|
222
|
+
min_area: int = 25,
|
|
223
|
+
) -> set[str]:
|
|
224
|
+
"""
|
|
225
|
+
Corrects the contours of the given TrueType font by removing overlaps, correcting the direction
|
|
226
|
+
of the contours, and removing tiny paths. The function returns the list of modified glyphs.
|
|
227
|
+
|
|
228
|
+
:param font: The ``TTFont`` object.
|
|
229
|
+
:type font: TTFont
|
|
230
|
+
:param remove_hinting: Whether to remove hinting instructions.
|
|
231
|
+
:type remove_hinting: bool
|
|
232
|
+
:param ignore_errors: Whether to ignore errors.
|
|
233
|
+
:type ignore_errors: bool
|
|
234
|
+
:param min_area: The minimum area of a contour to be considered. Defaults to 25.
|
|
235
|
+
:type min_area: int
|
|
236
|
+
:raises CorrectContoursError: If an error occurs while correcting the contours.
|
|
237
|
+
:return: The list of modified glyphs.
|
|
238
|
+
:rtype: set[str]
|
|
239
|
+
"""
|
|
240
|
+
glyph_names = font.getGlyphOrder()
|
|
241
|
+
glyph_set = font.getGlyphSet()
|
|
242
|
+
glyf_table = font[T_GLYF]
|
|
243
|
+
hmtx_table = font[T_HMTX]
|
|
244
|
+
|
|
245
|
+
# The minimum area is given in font units, so we need to convert it to em units
|
|
246
|
+
min_area = min_area / 1000 * font["head"].unitsPerEm
|
|
247
|
+
|
|
248
|
+
# process all simple glyphs first, then composites with increasing component depth,
|
|
249
|
+
# so that by the time we test for component intersections the respective base glyphs
|
|
250
|
+
# have already been simplified
|
|
251
|
+
glyph_names = sorted(
|
|
252
|
+
glyph_names,
|
|
253
|
+
key=lambda name: (
|
|
254
|
+
(
|
|
255
|
+
glyf_table[name].getCompositeMaxpValues(glyf_table).maxComponentDepth
|
|
256
|
+
if glyf_table[name].isComposite()
|
|
257
|
+
else 0
|
|
258
|
+
),
|
|
259
|
+
name,
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
modified_glyphs = set()
|
|
263
|
+
for glyph_name in glyph_names:
|
|
264
|
+
try:
|
|
265
|
+
if _correct_tt_glyph_contours(
|
|
266
|
+
glyph_name=glyph_name,
|
|
267
|
+
glyph_set=glyph_set,
|
|
268
|
+
glyf_table=glyf_table,
|
|
269
|
+
hmtx_table=hmtx_table,
|
|
270
|
+
remove_hinting=remove_hinting,
|
|
271
|
+
min_area=min_area,
|
|
272
|
+
):
|
|
273
|
+
modified_glyphs.add(glyph_name)
|
|
274
|
+
except CorrectContoursError as e:
|
|
275
|
+
if not ignore_errors:
|
|
276
|
+
raise e
|
|
277
|
+
|
|
278
|
+
return modified_glyphs
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def correct_cff_contours(
|
|
282
|
+
font: TTFont,
|
|
283
|
+
remove_hinting: bool,
|
|
284
|
+
ignore_errors: bool,
|
|
285
|
+
remove_unused_subroutines: bool = True,
|
|
286
|
+
min_area: int = 25,
|
|
287
|
+
) -> set[str]:
|
|
288
|
+
"""
|
|
289
|
+
Corrects the contours of the given CFF font by removing overlaps, correcting the direction of
|
|
290
|
+
the contours, and removing tiny paths. The function returns the list of modified glyphs.
|
|
291
|
+
|
|
292
|
+
:param font: The ``TTFont`` object.
|
|
293
|
+
:type font: TTFont
|
|
294
|
+
:param remove_hinting: Whether to remove hinting instructions.
|
|
295
|
+
:type remove_hinting: bool
|
|
296
|
+
:param ignore_errors: Whether to ignore errors.
|
|
297
|
+
:type ignore_errors: bool
|
|
298
|
+
:param remove_unused_subroutines: Whether to remove unused subroutines. Defaults to True.
|
|
299
|
+
:type remove_unused_subroutines: bool
|
|
300
|
+
:param min_area: The minimum area of a contour to be considered. Defaults to 25.
|
|
301
|
+
:type min_area: int
|
|
302
|
+
:raises CorrectContoursError: If an error occurs while correcting the contours.
|
|
303
|
+
:return: The list of modified glyphs.
|
|
304
|
+
:rtype: set[str]
|
|
305
|
+
"""
|
|
306
|
+
glyph_names = font.getGlyphOrder()
|
|
307
|
+
glyph_set = font.getGlyphSet()
|
|
308
|
+
cff_font_set: CFFFontSet = font[T_CFF].cff
|
|
309
|
+
modified_glyphs = set()
|
|
310
|
+
|
|
311
|
+
# The minimum area is given in font units, so we need to convert it to em units
|
|
312
|
+
min_area = min_area / 1000 * font["head"].unitsPerEm
|
|
313
|
+
|
|
314
|
+
for glyph_name in glyph_names:
|
|
315
|
+
try:
|
|
316
|
+
if _correct_charstring_contours(
|
|
317
|
+
glyph_name=glyph_name,
|
|
318
|
+
glyph_set=glyph_set,
|
|
319
|
+
cff_font_set=cff_font_set,
|
|
320
|
+
min_area=min_area,
|
|
321
|
+
):
|
|
322
|
+
modified_glyphs.add(glyph_name)
|
|
323
|
+
except CorrectContoursError as e:
|
|
324
|
+
if not ignore_errors:
|
|
325
|
+
raise e
|
|
326
|
+
|
|
327
|
+
if not modified_glyphs:
|
|
328
|
+
return set()
|
|
329
|
+
|
|
330
|
+
if remove_hinting:
|
|
331
|
+
cff_font_set.remove_hints()
|
|
332
|
+
|
|
333
|
+
if remove_unused_subroutines:
|
|
334
|
+
cff_font_set.remove_unused_subroutines()
|
|
335
|
+
|
|
336
|
+
return modified_glyphs
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Generate the CSS (``@font-face`` rules and feature classes) for converted web fonts."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CSS_FILE_NAME = "fonts.css"
|
|
10
|
+
_FORMAT_ORDER = {"woff2": 0, "woff": 1}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class WebFontEntry:
|
|
15
|
+
file: Path
|
|
16
|
+
format: str
|
|
17
|
+
family: str
|
|
18
|
+
weight: int = 400
|
|
19
|
+
italic: bool = False
|
|
20
|
+
features: tuple[str, ...] = ()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _slug(value: str) -> str:
|
|
24
|
+
return re.sub(r"[^a-z0-9_-]+", "-", value.lower()).strip("-") or "font"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _quote(value: str) -> str:
|
|
28
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_css(entries: Iterable[WebFontEntry]) -> str:
|
|
32
|
+
"""Return the CSS with one ``@font-face`` per font and one class per family and feature."""
|
|
33
|
+
entries = sorted(entries, key=lambda e: (e.family, e.weight, e.italic, e.file.name))
|
|
34
|
+
# The formats of the same font share one rule, with the most efficient format first.
|
|
35
|
+
faces: dict[tuple[str, int, bool, str], list[WebFontEntry]] = {}
|
|
36
|
+
for entry in entries:
|
|
37
|
+
faces.setdefault((entry.family, entry.weight, entry.italic, entry.file.stem), []).append(
|
|
38
|
+
entry
|
|
39
|
+
)
|
|
40
|
+
blocks: list[str] = []
|
|
41
|
+
for (family, weight, italic, _), sources in faces.items():
|
|
42
|
+
sources.sort(key=lambda e: _FORMAT_ORDER.get(e.format, len(_FORMAT_ORDER)))
|
|
43
|
+
src = ",\n ".join(
|
|
44
|
+
f"url({_quote(source.file.name)}) format({_quote(source.format)})" for source in sources
|
|
45
|
+
)
|
|
46
|
+
blocks.append(
|
|
47
|
+
"@font-face {\n"
|
|
48
|
+
f" font-family: {_quote(family)};\n"
|
|
49
|
+
f" src: {src};\n"
|
|
50
|
+
f" font-weight: {weight};\n"
|
|
51
|
+
f" font-style: {'italic' if italic else 'normal'};\n"
|
|
52
|
+
" font-display: swap;\n"
|
|
53
|
+
"}"
|
|
54
|
+
)
|
|
55
|
+
for family in sorted({entry.family for entry in entries}):
|
|
56
|
+
blocks.append(f".family-{_slug(family)} {{ font-family: {_quote(family)}; }}")
|
|
57
|
+
features = sorted({tag for entry in entries for tag in entry.features})
|
|
58
|
+
for tag in features:
|
|
59
|
+
blocks.append(f'.ft-{_slug(tag)} {{ font-feature-settings: "{tag}" 1; }}')
|
|
60
|
+
return "\n\n".join(blocks) + "\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_css_kits(entries: Iterable[WebFontEntry], overwrite: bool = True) -> list[Path]:
|
|
64
|
+
"""Write one CSS file in each folder that contains converted web fonts."""
|
|
65
|
+
by_folder: dict[Path, list[WebFontEntry]] = defaultdict(list)
|
|
66
|
+
for entry in entries:
|
|
67
|
+
by_folder[entry.file.parent].append(entry)
|
|
68
|
+
written: list[Path] = []
|
|
69
|
+
for folder, folder_entries in by_folder.items():
|
|
70
|
+
path = folder / CSS_FILE_NAME
|
|
71
|
+
counter = 1
|
|
72
|
+
while not overwrite and path.exists():
|
|
73
|
+
path = folder / f"{Path(CSS_FILE_NAME).stem}#{counter}.css"
|
|
74
|
+
counter += 1
|
|
75
|
+
path.write_text(build_css(folder_entries), encoding="utf-8")
|
|
76
|
+
written.append(path)
|
|
77
|
+
return written
|
font_converter/finder.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from collections.abc import Callable, Generator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fontTools.ttLib.ttCollection import TTCollection
|
|
6
|
+
from fontTools.ttLib.ttFont import TTLibError
|
|
7
|
+
|
|
8
|
+
from font_converter.font import Font
|
|
9
|
+
|
|
10
|
+
__all__ = ["FinderError", "FinderFilter", "FinderOptions", "FontFinder"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FinderOptions:
|
|
15
|
+
"""A class that specifies the options to pass to the FontFinder class."""
|
|
16
|
+
|
|
17
|
+
recursive: bool = False
|
|
18
|
+
lazy: bool | None = None
|
|
19
|
+
recalc_bboxes: bool = True
|
|
20
|
+
recalc_timestamp: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FinderFilter:
|
|
25
|
+
"""A class that specifies which fonts to filter out when searching for fonts."""
|
|
26
|
+
|
|
27
|
+
filter_out_tt: bool = False
|
|
28
|
+
filter_out_ps: bool = False
|
|
29
|
+
filter_out_woff: bool = False
|
|
30
|
+
filter_out_woff2: bool = False
|
|
31
|
+
filter_out_sfnt: bool = False
|
|
32
|
+
filter_out_static: bool = False
|
|
33
|
+
filter_out_variable: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FinderError(Exception):
|
|
37
|
+
"""An exception raised by the FontFinder class."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FontFinder:
|
|
41
|
+
"""
|
|
42
|
+
A class that finds fonts in a given path. It can search for fonts in a directory and its
|
|
43
|
+
subdirectories, and can also handle a single font file.
|
|
44
|
+
|
|
45
|
+
The class allows for filtering based on various criteria such as outline format (TrueType or
|
|
46
|
+
PostScript), font variations (static or variable), and font flavor ('woff', 'woff2' or
|
|
47
|
+
``None``).
|
|
48
|
+
|
|
49
|
+
The class returns a list or a generator of Font objects that meet the specified criteria.
|
|
50
|
+
|
|
51
|
+
The returned ``Font`` and ``TTCollection`` objects keep their files open until they are closed
|
|
52
|
+
by the caller (``close()`` or a ``with`` block). Prefer the generator methods when processing
|
|
53
|
+
many files, so that only one font at a time is open::
|
|
54
|
+
|
|
55
|
+
for font in finder.generate_fonts():
|
|
56
|
+
with font:
|
|
57
|
+
...
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
input_path: str | Path,
|
|
63
|
+
options: FinderOptions | None = None,
|
|
64
|
+
filter_: FinderFilter | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Initializes the ``FontFinder`` class.
|
|
68
|
+
|
|
69
|
+
:param input_path: The file system path to be processed. This path is resolved to an
|
|
70
|
+
absolute path. If the path is invalid, a FinderError is raised.
|
|
71
|
+
:type input_path: Union[str, Path]
|
|
72
|
+
:param options: Options for customizing the behavior of the finder. If not provided,
|
|
73
|
+
defaults to a FinderOptions instance.
|
|
74
|
+
:type options: Optional[FinderOptions]
|
|
75
|
+
:param filter_: Filter criteria for selecting files or directories. If not provided,
|
|
76
|
+
defaults to a FinderFilter instance.
|
|
77
|
+
:type filter_: Optional[FinderFilter]
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
self.input_path = Path(input_path).resolve(strict=True)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise FinderError(f"Invalid input path: {input_path}") from e
|
|
83
|
+
|
|
84
|
+
self.filter = filter_ or FinderFilter()
|
|
85
|
+
self.options = options or FinderOptions()
|
|
86
|
+
self._filter_conditions = self._generate_filter_conditions(self.filter)
|
|
87
|
+
self._validate_filter_conditions()
|
|
88
|
+
|
|
89
|
+
def find_fonts(self) -> list[Font]:
|
|
90
|
+
"""
|
|
91
|
+
Finds fonts in the given input path.
|
|
92
|
+
|
|
93
|
+
:return: A list of ``Font`` objects generated by the generate_fonts method.
|
|
94
|
+
:rtype: list[Font]
|
|
95
|
+
"""
|
|
96
|
+
return list(self.generate_fonts())
|
|
97
|
+
|
|
98
|
+
def find_collections(self) -> list[TTCollection]:
|
|
99
|
+
"""
|
|
100
|
+
Finds TTCollections in the given input path.
|
|
101
|
+
|
|
102
|
+
:return: A list of ``TTCollection`` objects generated by the generate_collections method.
|
|
103
|
+
:rtype: list[TTCollection]
|
|
104
|
+
"""
|
|
105
|
+
return list(self.generate_collections())
|
|
106
|
+
|
|
107
|
+
def generate_fonts(self) -> Generator[Font, None, None]:
|
|
108
|
+
"""
|
|
109
|
+
Generates a sequence of ``Font`` objects from the files generated by the ``_generate_files``
|
|
110
|
+
method. Filters out fonts based on specified conditions in ``_filter_conditions``.
|
|
111
|
+
|
|
112
|
+
:return: A generator yielding Font objects
|
|
113
|
+
:rtype: Generator[Font, None, None]
|
|
114
|
+
"""
|
|
115
|
+
# Collect the paths first, so that files saved while iterating are not found again.
|
|
116
|
+
files = list(self._generate_files())
|
|
117
|
+
for file in files:
|
|
118
|
+
try:
|
|
119
|
+
font = Font(
|
|
120
|
+
file,
|
|
121
|
+
lazy=self.options.lazy,
|
|
122
|
+
recalc_timestamp=self.options.recalc_timestamp,
|
|
123
|
+
recalc_bboxes=self.options.recalc_bboxes,
|
|
124
|
+
)
|
|
125
|
+
except (TTLibError, PermissionError):
|
|
126
|
+
continue
|
|
127
|
+
if any(condition and func(font) for condition, func in self._filter_conditions):
|
|
128
|
+
font.close()
|
|
129
|
+
else:
|
|
130
|
+
yield font
|
|
131
|
+
|
|
132
|
+
def generate_collections(self) -> Generator[TTCollection, None, None]:
|
|
133
|
+
"""
|
|
134
|
+
Generates a sequence of ``TTCollection`` objects from the files generated by the
|
|
135
|
+
``_generate_files`` method.
|
|
136
|
+
|
|
137
|
+
:return: A generator yielding TTCollection objects
|
|
138
|
+
:rtype: Generator[TTCollection, None, None]
|
|
139
|
+
"""
|
|
140
|
+
files = list(self._generate_files())
|
|
141
|
+
for file in files:
|
|
142
|
+
try:
|
|
143
|
+
ttc = TTCollection(file)
|
|
144
|
+
except (TTLibError, PermissionError):
|
|
145
|
+
continue
|
|
146
|
+
yield ttc
|
|
147
|
+
|
|
148
|
+
def _generate_files(self) -> Generator[Path, None, None]:
|
|
149
|
+
is_file = self.input_path.is_file()
|
|
150
|
+
is_dir = self.input_path.is_dir()
|
|
151
|
+
|
|
152
|
+
if is_file:
|
|
153
|
+
yield self.input_path
|
|
154
|
+
elif is_dir:
|
|
155
|
+
if self.options.recursive:
|
|
156
|
+
yield from (x for x in self.input_path.rglob("*") if x.is_file())
|
|
157
|
+
else:
|
|
158
|
+
yield from (x for x in self.input_path.glob("*") if x.is_file())
|
|
159
|
+
|
|
160
|
+
def _validate_filter_conditions(self) -> None:
|
|
161
|
+
if self.filter.filter_out_tt and self.filter.filter_out_ps:
|
|
162
|
+
raise FinderError("Cannot filter out both TrueType and PostScript fonts.")
|
|
163
|
+
if (
|
|
164
|
+
self.filter.filter_out_woff
|
|
165
|
+
and self.filter.filter_out_woff2
|
|
166
|
+
and self.filter.filter_out_sfnt
|
|
167
|
+
):
|
|
168
|
+
raise FinderError("Cannot filter out WOFF, WOFF2 and SFNT fonts at the same time.")
|
|
169
|
+
if self.filter.filter_out_static and self.filter.filter_out_variable:
|
|
170
|
+
raise FinderError("Cannot filter out both static and variable fonts.")
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _generate_filter_conditions(
|
|
174
|
+
filter_: FinderFilter,
|
|
175
|
+
) -> list[tuple[bool, Callable[[Font], bool]]]:
|
|
176
|
+
conditions: list[tuple[bool, Callable[[Font], bool]]] = [
|
|
177
|
+
(filter_.filter_out_tt, _is_tt),
|
|
178
|
+
(filter_.filter_out_ps, _is_ps),
|
|
179
|
+
(filter_.filter_out_woff, _is_woff),
|
|
180
|
+
(filter_.filter_out_woff2, _is_woff2),
|
|
181
|
+
(filter_.filter_out_sfnt, _is_sfnt),
|
|
182
|
+
(filter_.filter_out_static, _is_static),
|
|
183
|
+
(filter_.filter_out_variable, _is_variable),
|
|
184
|
+
]
|
|
185
|
+
return conditions
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_woff(font: Font) -> bool:
|
|
189
|
+
return font.is_woff
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_woff2(font: Font) -> bool:
|
|
193
|
+
return font.is_woff2
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_sfnt(font: Font) -> bool:
|
|
197
|
+
return font.is_sfnt
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_ps(font: Font) -> bool:
|
|
201
|
+
return font.is_ps
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_tt(font: Font) -> bool:
|
|
205
|
+
return font.is_tt
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_static(font: Font) -> bool:
|
|
209
|
+
return font.is_static
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_variable(font: Font) -> bool:
|
|
213
|
+
return font.is_variable
|