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,729 @@
|
|
|
1
|
+
"""Interface-independent request and result types for font conversions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar, Literal, TypeAlias, TypeVar
|
|
10
|
+
|
|
11
|
+
from fontTools.misc.cliTools import makeOutputFileName
|
|
12
|
+
from fontTools.ttLib import TTFont, TTLibError
|
|
13
|
+
from fontTools.ttLib.ttCollection import TTCollection
|
|
14
|
+
from pathvalidate import sanitize_filename
|
|
15
|
+
|
|
16
|
+
from font_converter import Font
|
|
17
|
+
from font_converter import constants as const
|
|
18
|
+
from font_converter.constants import WOFF2_FLAVOR, WOFF_FLAVOR
|
|
19
|
+
from font_converter.css_kit import WebFontEntry, write_css_kits
|
|
20
|
+
|
|
21
|
+
ConversionKind: TypeAlias = Literal[
|
|
22
|
+
"ttf2otf", "otf2ttf", "ft2wf", "woff2ft", "woff22ft", "ttc2sfnt", "var2static"
|
|
23
|
+
]
|
|
24
|
+
WebFormat: TypeAlias = Literal["woff", "woff2"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ConversionOptions:
|
|
29
|
+
recursive: bool = False
|
|
30
|
+
output_dir: Path | None = None
|
|
31
|
+
overwrite: bool = False
|
|
32
|
+
recalc_bboxes: bool = True
|
|
33
|
+
recalc_timestamp: bool = False
|
|
34
|
+
reorder_tables: bool = False
|
|
35
|
+
organize_output: bool = False
|
|
36
|
+
organize_by_family: bool = False
|
|
37
|
+
organization_levels: tuple[tuple[str, ...], ...] = ()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class TtfToOtfOptions(ConversionOptions):
|
|
42
|
+
tolerance: float = 1.0
|
|
43
|
+
target_upm: int | None = None
|
|
44
|
+
subroutinize: bool = True
|
|
45
|
+
optimize_contours: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class OtfToTtfOptions(ConversionOptions):
|
|
50
|
+
tolerance: float = 1.0
|
|
51
|
+
target_upm: int | None = None
|
|
52
|
+
autohint: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class SfntToWebOptions(ConversionOptions):
|
|
57
|
+
format: WebFormat | None = None
|
|
58
|
+
obfuscate_names: bool = False
|
|
59
|
+
remove_unused_glyphs: bool = False
|
|
60
|
+
remove_non_windows_names: bool = False
|
|
61
|
+
css_kit: bool = False
|
|
62
|
+
remove_dsig: bool = False # WOFF only: WOFF2 does not support the DSIG table
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class WebToSfntOptions(ConversionOptions):
|
|
67
|
+
format: ClassVar[WebFormat | None] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class WoffToSfntOptions(WebToSfntOptions):
|
|
72
|
+
format: ClassVar[WebFormat | None] = "woff"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Woff2ToSfntOptions(WebToSfntOptions):
|
|
77
|
+
format: ClassVar[WebFormat | None] = "woff2"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TtcToSfntOptions(ConversionOptions):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class InstanceSpec:
|
|
87
|
+
"""A location in the design space; ``name`` is set for instances defined in the fvar table."""
|
|
88
|
+
|
|
89
|
+
coordinates: tuple[tuple[str, float], ...]
|
|
90
|
+
name: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class VarToStaticOptions(ConversionOptions):
|
|
95
|
+
# Instances to export for each input font; a font without an entry exports its default.
|
|
96
|
+
instances: tuple[tuple[Path, tuple[InstanceSpec, ...]], ...] = ()
|
|
97
|
+
target_upm: int | None = None
|
|
98
|
+
autohint: bool = True # TrueType outlines only
|
|
99
|
+
subroutinize: bool = True # PostScript outlines only
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ConversionSettings: TypeAlias = (
|
|
103
|
+
TtfToOtfOptions
|
|
104
|
+
| OtfToTtfOptions
|
|
105
|
+
| SfntToWebOptions
|
|
106
|
+
| WebToSfntOptions
|
|
107
|
+
| TtcToSfntOptions
|
|
108
|
+
| VarToStaticOptions
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _format_organization_number(value: float) -> str:
|
|
113
|
+
return f"{value:g}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def organization_level_name(criteria: tuple[str, ...], values: dict[str, str]) -> str:
|
|
117
|
+
"""Build a safe directory name from the selected organization criteria."""
|
|
118
|
+
parts = []
|
|
119
|
+
for criterion in criteria:
|
|
120
|
+
if criterion not in values:
|
|
121
|
+
raise ValueError(f"Organization criterion is not available: {criterion}")
|
|
122
|
+
value = values[criterion]
|
|
123
|
+
if criterion in ("upm", "tolerance", "simplify"):
|
|
124
|
+
value = f"{criterion}_{value}"
|
|
125
|
+
parts.append(value)
|
|
126
|
+
if not parts:
|
|
127
|
+
raise ValueError("Organization levels must contain at least one criterion.")
|
|
128
|
+
return sanitize_filename("_".join(parts)).strip() or "Unknown"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_CONVERSION_BY_SETTINGS: dict[type[ConversionOptions], ConversionKind] = {
|
|
132
|
+
VarToStaticOptions: "var2static",
|
|
133
|
+
TtfToOtfOptions: "ttf2otf",
|
|
134
|
+
OtfToTtfOptions: "otf2ttf",
|
|
135
|
+
SfntToWebOptions: "ft2wf",
|
|
136
|
+
WoffToSfntOptions: "woff2ft",
|
|
137
|
+
Woff2ToSfntOptions: "woff22ft",
|
|
138
|
+
TtcToSfntOptions: "ttc2sfnt",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class ConversionRequest:
|
|
144
|
+
input_paths: tuple[Path, ...]
|
|
145
|
+
settings: ConversionSettings
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def conversion(self) -> ConversionKind:
|
|
149
|
+
return _CONVERSION_BY_SETTINGS[type(self.settings)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class FontOutcomeKind(Enum):
|
|
153
|
+
CONVERTED = "converted"
|
|
154
|
+
SKIPPED = "skipped"
|
|
155
|
+
FAILED = "failed"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class FontOutcome:
|
|
160
|
+
"""Outcome for one font; a collection can produce multiple outcomes per input path."""
|
|
161
|
+
|
|
162
|
+
input_path: Path
|
|
163
|
+
kind: FontOutcomeKind
|
|
164
|
+
output_paths: tuple[Path, ...] = ()
|
|
165
|
+
error: str | None = None
|
|
166
|
+
elapsed: float = 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class ConversionProgress:
|
|
171
|
+
"""Batch progress measured in fonts, including each member of a collection."""
|
|
172
|
+
|
|
173
|
+
done: int
|
|
174
|
+
total: int
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
ProgressCallback: TypeAlias = Callable[[ConversionProgress], None]
|
|
178
|
+
OutcomeCallback: TypeAlias = Callable[[FontOutcome], None]
|
|
179
|
+
FontStartCallback: TypeAlias = Callable[[Path], None]
|
|
180
|
+
CancellationCheck: TypeAlias = Callable[[], bool]
|
|
181
|
+
LogCallback: TypeAlias = Callable[[str], None]
|
|
182
|
+
PauseWait: TypeAlias = Callable[[], None]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class ConversionResult:
|
|
187
|
+
"""Structured batch result, independent of CLI output and GUI presentation."""
|
|
188
|
+
|
|
189
|
+
outcomes: tuple[FontOutcome, ...] = ()
|
|
190
|
+
error: str | None = None
|
|
191
|
+
cancelled: bool = False
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def elapsed(self) -> float:
|
|
195
|
+
"""Total conversion time in seconds: the sum of per-font times, so pauses are excluded."""
|
|
196
|
+
return sum(outcome.elapsed for outcome in self.outcomes)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def converted_count(self) -> int:
|
|
200
|
+
return sum(outcome.kind is FontOutcomeKind.CONVERTED for outcome in self.outcomes)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def skipped_count(self) -> int:
|
|
204
|
+
return sum(outcome.kind is FontOutcomeKind.SKIPPED for outcome in self.outcomes)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def failed_count(self) -> int:
|
|
208
|
+
return sum(outcome.kind is FontOutcomeKind.FAILED for outcome in self.outcomes)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def saved_files(self) -> list[Path]:
|
|
212
|
+
return [
|
|
213
|
+
path
|
|
214
|
+
for outcome in self.outcomes
|
|
215
|
+
if outcome.kind is FontOutcomeKind.CONVERTED
|
|
216
|
+
for path in outcome.output_paths
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def ok(self) -> bool:
|
|
221
|
+
return self.error is None and self.failed_count == 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def build_output_file_name(font: Font, output_dir: Path | None, overwrite: bool = True) -> Path:
|
|
225
|
+
"""Build the output name for a TrueType-to-PostScript conversion."""
|
|
226
|
+
flavor = font.ttfont.flavor
|
|
227
|
+
extension = font.get_file_ext() if flavor is not None else ".otf"
|
|
228
|
+
return font.get_file_path(output_dir=output_dir, overwrite=overwrite, extension=extension)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_S = TypeVar("_S")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def instance_label(coordinates: tuple[tuple[str, float], ...]) -> str:
|
|
235
|
+
"""Describe an instance location from its axis values, e.g. ``opsz11 wght450``."""
|
|
236
|
+
return " ".join(f"{tag}{value:g}" for tag, value in coordinates)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def instance_stem(input_path: Path, name: str | None, label: str) -> str:
|
|
240
|
+
"""File name (without extension) of an instance: named ones use the name, others the label."""
|
|
241
|
+
suffix = re.sub(r"\s+", "", name) if name else label.replace(" ", "-")
|
|
242
|
+
base_stem = re.sub(r"\[.*?\]", "", input_path.stem)
|
|
243
|
+
return f"{base_stem}-{sanitize_filename(suffix)}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _expect_settings(settings: object, kind: type[_S]) -> _S:
|
|
247
|
+
"""Return settings as the expected type, or raise if the request is inconsistent."""
|
|
248
|
+
if not isinstance(settings, kind):
|
|
249
|
+
raise TypeError(f"Expected {kind.__name__}, got {type(settings).__name__}")
|
|
250
|
+
return settings
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ConverterService:
|
|
254
|
+
"""Run font conversions without depending on Qt, Click, or CLI output."""
|
|
255
|
+
|
|
256
|
+
def convert(
|
|
257
|
+
self,
|
|
258
|
+
request: ConversionRequest,
|
|
259
|
+
on_progress: ProgressCallback | None = None,
|
|
260
|
+
on_outcome: OutcomeCallback | None = None,
|
|
261
|
+
on_log: LogCallback | None = None,
|
|
262
|
+
should_cancel: CancellationCheck | None = None,
|
|
263
|
+
on_font_start: FontStartCallback | None = None,
|
|
264
|
+
wait_if_paused: PauseWait | None = None,
|
|
265
|
+
) -> ConversionResult:
|
|
266
|
+
def stop_requested() -> bool:
|
|
267
|
+
if wait_if_paused is not None:
|
|
268
|
+
wait_if_paused()
|
|
269
|
+
return should_cancel is not None and should_cancel()
|
|
270
|
+
|
|
271
|
+
if not request.input_paths:
|
|
272
|
+
return ConversionResult(error="Select at least one input file or folder.")
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
if request.settings.output_dir is not None:
|
|
276
|
+
request.settings.output_dir.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
candidates = self._find_candidates(request)
|
|
278
|
+
except (OSError, ValueError, TTLibError) as error:
|
|
279
|
+
return ConversionResult(error=f"{type(error).__name__}: {error}")
|
|
280
|
+
if not candidates:
|
|
281
|
+
return ConversionResult(error="No compatible fonts found in the selected inputs.")
|
|
282
|
+
|
|
283
|
+
outcomes: list[FontOutcome] = []
|
|
284
|
+
web_entries: list[WebFontEntry] = []
|
|
285
|
+
has_compatible_fonts = any(compatible for _, _, compatible in candidates)
|
|
286
|
+
progress = ConversionProgress(0, sum(weight for _, weight, _ in candidates))
|
|
287
|
+
if on_progress is not None:
|
|
288
|
+
on_progress(progress)
|
|
289
|
+
|
|
290
|
+
for input_path, weight, compatible in candidates:
|
|
291
|
+
if stop_requested():
|
|
292
|
+
self._write_css_kit(web_entries, request.settings, on_log)
|
|
293
|
+
return ConversionResult(tuple(outcomes), cancelled=True)
|
|
294
|
+
if not compatible:
|
|
295
|
+
outcome = FontOutcome(input_path, FontOutcomeKind.SKIPPED)
|
|
296
|
+
outcomes.append(outcome)
|
|
297
|
+
self._report(outcome, on_outcome)
|
|
298
|
+
progress = ConversionProgress(progress.done + weight, progress.total)
|
|
299
|
+
if on_progress is not None:
|
|
300
|
+
on_progress(progress)
|
|
301
|
+
continue
|
|
302
|
+
if request.conversion == "ttc2sfnt":
|
|
303
|
+
try:
|
|
304
|
+
collection = TTCollection(input_path)
|
|
305
|
+
except (OSError, TTLibError) as error:
|
|
306
|
+
return ConversionResult(
|
|
307
|
+
tuple(outcomes), error=f"{type(error).__name__}: {error}"
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
for member in collection.fonts:
|
|
311
|
+
if stop_requested():
|
|
312
|
+
return ConversionResult(tuple(outcomes), cancelled=True)
|
|
313
|
+
if on_font_start is not None:
|
|
314
|
+
on_font_start(input_path)
|
|
315
|
+
outcome = self._extract_collection_member(
|
|
316
|
+
input_path, member, request.settings
|
|
317
|
+
)
|
|
318
|
+
outcomes.append(outcome)
|
|
319
|
+
self._report(outcome, on_outcome)
|
|
320
|
+
progress = ConversionProgress(progress.done + 1, progress.total)
|
|
321
|
+
if on_progress is not None:
|
|
322
|
+
on_progress(progress)
|
|
323
|
+
finally:
|
|
324
|
+
collection.close()
|
|
325
|
+
continue
|
|
326
|
+
if request.conversion == "var2static":
|
|
327
|
+
var_settings = _expect_settings(request.settings, VarToStaticOptions)
|
|
328
|
+
used_stems: set[str] = set()
|
|
329
|
+
for spec in self._instances_for(request.settings, input_path):
|
|
330
|
+
if stop_requested():
|
|
331
|
+
return ConversionResult(tuple(outcomes), cancelled=True)
|
|
332
|
+
if on_font_start is not None:
|
|
333
|
+
on_font_start(input_path)
|
|
334
|
+
outcome = self._convert_instance(
|
|
335
|
+
input_path, spec, var_settings, used_stems, on_log
|
|
336
|
+
)
|
|
337
|
+
outcomes.append(outcome)
|
|
338
|
+
self._report(outcome, on_outcome)
|
|
339
|
+
progress = ConversionProgress(progress.done + 1, progress.total)
|
|
340
|
+
if on_progress is not None:
|
|
341
|
+
on_progress(progress)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
for _ in range(weight):
|
|
345
|
+
if stop_requested():
|
|
346
|
+
self._write_css_kit(web_entries, request.settings, on_log)
|
|
347
|
+
return ConversionResult(tuple(outcomes), cancelled=True)
|
|
348
|
+
if on_font_start is not None:
|
|
349
|
+
on_font_start(input_path)
|
|
350
|
+
started = time.perf_counter()
|
|
351
|
+
try:
|
|
352
|
+
font = Font(
|
|
353
|
+
input_path,
|
|
354
|
+
recalc_bboxes=request.settings.recalc_bboxes,
|
|
355
|
+
recalc_timestamp=request.settings.recalc_timestamp,
|
|
356
|
+
)
|
|
357
|
+
with font:
|
|
358
|
+
output_paths = self._convert_font(
|
|
359
|
+
font, request.settings, on_log, web_entries
|
|
360
|
+
)
|
|
361
|
+
outcome = FontOutcome(
|
|
362
|
+
input_path,
|
|
363
|
+
FontOutcomeKind.CONVERTED,
|
|
364
|
+
tuple(output_paths),
|
|
365
|
+
elapsed=time.perf_counter() - started,
|
|
366
|
+
)
|
|
367
|
+
except Exception as error:
|
|
368
|
+
outcome = FontOutcome(
|
|
369
|
+
input_path,
|
|
370
|
+
FontOutcomeKind.FAILED,
|
|
371
|
+
error=f"{type(error).__name__}: {error}",
|
|
372
|
+
elapsed=time.perf_counter() - started,
|
|
373
|
+
)
|
|
374
|
+
outcomes.append(outcome)
|
|
375
|
+
self._report(outcome, on_outcome)
|
|
376
|
+
progress = ConversionProgress(progress.done + 1, progress.total)
|
|
377
|
+
if on_progress is not None:
|
|
378
|
+
on_progress(progress)
|
|
379
|
+
|
|
380
|
+
self._write_css_kit(web_entries, request.settings, on_log)
|
|
381
|
+
no_compatible_error = (
|
|
382
|
+
None if has_compatible_fonts else "No compatible fonts found in the selected inputs."
|
|
383
|
+
)
|
|
384
|
+
return ConversionResult(tuple(outcomes), error=no_compatible_error)
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _write_css_kit(
|
|
388
|
+
entries: list[WebFontEntry], settings: ConversionSettings, on_log: LogCallback | None
|
|
389
|
+
) -> None:
|
|
390
|
+
if not entries or not isinstance(settings, SfntToWebOptions):
|
|
391
|
+
return
|
|
392
|
+
try:
|
|
393
|
+
for path in write_css_kits(entries, overwrite=settings.overwrite):
|
|
394
|
+
ConverterService._log(on_log, f"CSS saved to {path}")
|
|
395
|
+
except OSError as error:
|
|
396
|
+
ConverterService._log(on_log, f"Could not write the CSS: {error}")
|
|
397
|
+
|
|
398
|
+
def _find_candidates(self, request: ConversionRequest) -> list[tuple[Path, int, bool]]:
|
|
399
|
+
files: dict[Path, None] = {}
|
|
400
|
+
for source in request.input_paths:
|
|
401
|
+
if not source.exists():
|
|
402
|
+
raise FileNotFoundError(f"Input path does not exist: {source}")
|
|
403
|
+
if source.is_file():
|
|
404
|
+
files[source.resolve()] = None
|
|
405
|
+
elif source.is_dir():
|
|
406
|
+
paths = source.rglob("*") if request.settings.recursive else source.glob("*")
|
|
407
|
+
files.update((path.resolve(), None) for path in paths if path.is_file())
|
|
408
|
+
|
|
409
|
+
candidates: list[tuple[Path, int, bool]] = []
|
|
410
|
+
for path in files:
|
|
411
|
+
if request.conversion == "ttc2sfnt":
|
|
412
|
+
try:
|
|
413
|
+
collection = TTCollection(path)
|
|
414
|
+
except (OSError, TTLibError, AssertionError):
|
|
415
|
+
continue
|
|
416
|
+
count = len(collection.fonts)
|
|
417
|
+
collection.close()
|
|
418
|
+
if count:
|
|
419
|
+
candidates.append((path, count, True))
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
font = Font(
|
|
424
|
+
path,
|
|
425
|
+
recalc_bboxes=request.settings.recalc_bboxes,
|
|
426
|
+
recalc_timestamp=request.settings.recalc_timestamp,
|
|
427
|
+
)
|
|
428
|
+
except (OSError, TTLibError, PermissionError):
|
|
429
|
+
continue
|
|
430
|
+
with font:
|
|
431
|
+
compatible = self._is_compatible(font, request)
|
|
432
|
+
weight = 1
|
|
433
|
+
if compatible and request.conversion == "var2static":
|
|
434
|
+
weight = len(self._instances_for(request.settings, path))
|
|
435
|
+
candidates.append((path, weight, compatible))
|
|
436
|
+
return candidates
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def _is_compatible(font: Font, request: ConversionRequest) -> bool:
|
|
440
|
+
conversion = request.conversion
|
|
441
|
+
if conversion == "ttf2otf":
|
|
442
|
+
return font.is_tt and font.is_static
|
|
443
|
+
if conversion == "otf2ttf":
|
|
444
|
+
return font.is_ps and font.is_static
|
|
445
|
+
if conversion == "var2static":
|
|
446
|
+
return font.is_variable
|
|
447
|
+
if conversion == "ft2wf":
|
|
448
|
+
return font.is_sfnt
|
|
449
|
+
if conversion in ("woff2ft", "woff22ft"):
|
|
450
|
+
settings = _expect_settings(request.settings, WebToSfntOptions)
|
|
451
|
+
return (font.is_woff or font.is_woff2) and font.ttfont.flavor == settings.format
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _output_dir(
|
|
456
|
+
font: Font,
|
|
457
|
+
settings: ConversionOptions,
|
|
458
|
+
flavor: str | None,
|
|
459
|
+
is_tt: bool,
|
|
460
|
+
) -> Path | None:
|
|
461
|
+
if (
|
|
462
|
+
not settings.organization_levels
|
|
463
|
+
and not settings.organize_output
|
|
464
|
+
and not settings.organize_by_family
|
|
465
|
+
):
|
|
466
|
+
return settings.output_dir
|
|
467
|
+
base_dir = settings.output_dir or (font.file.parent if font.file is not None else None)
|
|
468
|
+
if base_dir is None:
|
|
469
|
+
raise ValueError("Cannot organize output without an output directory or input file.")
|
|
470
|
+
output_dir = base_dir
|
|
471
|
+
if settings.organization_levels:
|
|
472
|
+
family = str(font.ttfont[const.T_NAME].getBestFamilyName() or "")
|
|
473
|
+
format_name = {None: "opentype", "woff": "woff", "woff2": "woff2"}.get(flavor)
|
|
474
|
+
if format_name is None:
|
|
475
|
+
raise ValueError(f"Unsupported output font format: {flavor}")
|
|
476
|
+
values = {
|
|
477
|
+
"family": family or "Unknown family",
|
|
478
|
+
"format": format_name,
|
|
479
|
+
"outline": "tt" if is_tt else "ps",
|
|
480
|
+
"upm": str(getattr(settings, "target_upm", None) or font.units_per_em),
|
|
481
|
+
}
|
|
482
|
+
if hasattr(settings, "tolerance"):
|
|
483
|
+
values["tolerance"] = _format_organization_number(settings.tolerance)
|
|
484
|
+
if hasattr(settings, "optimize_contours"):
|
|
485
|
+
values["simplify"] = str(settings.optimize_contours)
|
|
486
|
+
for level in settings.organization_levels:
|
|
487
|
+
output_dir /= organization_level_name(level, values)
|
|
488
|
+
else:
|
|
489
|
+
if settings.organize_by_family:
|
|
490
|
+
family = str(font.ttfont[const.T_NAME].getBestFamilyName() or "")
|
|
491
|
+
output_dir = output_dir / (sanitize_filename(family).strip() or "Unknown family")
|
|
492
|
+
if settings.organize_output:
|
|
493
|
+
format_name = {None: "opentype", "woff": "woff", "woff2": "woff2"}.get(flavor)
|
|
494
|
+
if format_name is None:
|
|
495
|
+
raise ValueError(f"Unsupported output font format: {flavor}")
|
|
496
|
+
output_dir = output_dir / f"{format_name}-{'tt' if is_tt else 'ps'}"
|
|
497
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
return output_dir
|
|
499
|
+
|
|
500
|
+
def _convert_font(
|
|
501
|
+
self,
|
|
502
|
+
font: Font,
|
|
503
|
+
settings: ConversionSettings,
|
|
504
|
+
on_log: LogCallback | None,
|
|
505
|
+
web_entries: list[WebFontEntry] | None = None,
|
|
506
|
+
) -> list[Path]:
|
|
507
|
+
conversion = _CONVERSION_BY_SETTINGS[type(settings)]
|
|
508
|
+
if conversion == "ttf2otf":
|
|
509
|
+
settings = _expect_settings(settings, TtfToOtfOptions)
|
|
510
|
+
flavor = font.ttfont.flavor
|
|
511
|
+
output_dir = self._output_dir(font, settings, flavor, is_tt=False)
|
|
512
|
+
output = build_output_file_name(
|
|
513
|
+
font,
|
|
514
|
+
output_dir=output_dir,
|
|
515
|
+
overwrite=settings.overwrite,
|
|
516
|
+
)
|
|
517
|
+
font.ttfont.flavor = None
|
|
518
|
+
if settings.target_upm:
|
|
519
|
+
self._log(on_log, f"Scaling UPM to {settings.target_upm}...")
|
|
520
|
+
font.scale_upm(settings.target_upm)
|
|
521
|
+
tolerance = settings.tolerance / 1000 * font.units_per_em
|
|
522
|
+
self._log(on_log, "Converting to OTF...")
|
|
523
|
+
font.to_otf(tolerance=tolerance, optimize_contours=settings.optimize_contours)
|
|
524
|
+
if settings.subroutinize:
|
|
525
|
+
self._log(on_log, "Subroutinizing...")
|
|
526
|
+
font.subroutinize()
|
|
527
|
+
font.ttfont.flavor = flavor
|
|
528
|
+
font.save(output, reorder_tables=settings.reorder_tables)
|
|
529
|
+
return [Path(output)]
|
|
530
|
+
|
|
531
|
+
if conversion == "otf2ttf":
|
|
532
|
+
settings = _expect_settings(settings, OtfToTtfOptions)
|
|
533
|
+
flavor = font.ttfont.flavor
|
|
534
|
+
output_dir = self._output_dir(font, settings, flavor, is_tt=True)
|
|
535
|
+
extension = font.get_file_ext() if flavor is not None else ".ttf"
|
|
536
|
+
output = font.get_file_path(
|
|
537
|
+
output_dir=output_dir,
|
|
538
|
+
overwrite=settings.overwrite,
|
|
539
|
+
extension=extension,
|
|
540
|
+
)
|
|
541
|
+
tolerance = settings.tolerance / 1000 * font.units_per_em
|
|
542
|
+
self._log(on_log, "Converting to TTF...")
|
|
543
|
+
font.to_ttf(max_err=tolerance, reverse_direction=True)
|
|
544
|
+
if settings.target_upm:
|
|
545
|
+
self._log(on_log, f"Scaling UPM to {settings.target_upm}...")
|
|
546
|
+
font.scale_upm(settings.target_upm)
|
|
547
|
+
if settings.autohint:
|
|
548
|
+
self._log(on_log, "Autohinting...")
|
|
549
|
+
font.ttf_autohint()
|
|
550
|
+
font.save(output)
|
|
551
|
+
return [Path(output)]
|
|
552
|
+
|
|
553
|
+
if conversion in ("woff2ft", "woff22ft"):
|
|
554
|
+
settings = _expect_settings(settings, WebToSfntOptions)
|
|
555
|
+
output_dir = self._output_dir(font, settings, None, font.is_tt)
|
|
556
|
+
output = font.get_file_path(
|
|
557
|
+
output_dir=output_dir,
|
|
558
|
+
overwrite=settings.overwrite,
|
|
559
|
+
extension=".ttf" if font.is_tt else ".otf",
|
|
560
|
+
)
|
|
561
|
+
font.to_sfnt()
|
|
562
|
+
font.save(output, reorder_tables=settings.reorder_tables)
|
|
563
|
+
return [Path(output)]
|
|
564
|
+
|
|
565
|
+
if conversion == "ft2wf":
|
|
566
|
+
settings = _expect_settings(settings, SfntToWebOptions)
|
|
567
|
+
formats = (settings.format,) if settings.format else (WOFF_FLAVOR, WOFF2_FLAVOR)
|
|
568
|
+
if settings.remove_unused_glyphs:
|
|
569
|
+
self._log(on_log, "Removing unused glyphs...")
|
|
570
|
+
removed = font.remove_unused_glyphs()
|
|
571
|
+
self._log(on_log, f"Removed {removed} unused glyphs")
|
|
572
|
+
source_file = font.file
|
|
573
|
+
family = ""
|
|
574
|
+
if settings.obfuscate_names:
|
|
575
|
+
self._log(on_log, "Obfuscating names...")
|
|
576
|
+
family, ps_name = font.obfuscate_names()
|
|
577
|
+
if source_file is not None:
|
|
578
|
+
source_file = source_file.with_name(ps_name)
|
|
579
|
+
entry_data: tuple[str, int, bool, tuple[str, ...]] | None = None
|
|
580
|
+
if settings.css_kit:
|
|
581
|
+
family = family or str(font.ttfont[const.T_NAME].getBestFamilyName() or "")
|
|
582
|
+
weight = int(getattr(font.ttfont.get(const.T_OS_2), "usWeightClass", 400))
|
|
583
|
+
italic = bool(font.ttfont[const.T_HEAD].macStyle & 0b10)
|
|
584
|
+
entry_data = (family, weight, italic, font.get_features())
|
|
585
|
+
if settings.remove_non_windows_names:
|
|
586
|
+
removed_names = font.remove_non_windows_names()
|
|
587
|
+
self._log(on_log, f"Removed {removed_names} non-Windows name records")
|
|
588
|
+
outputs = []
|
|
589
|
+
for format_ in formats:
|
|
590
|
+
self._log(on_log, f"Converting to {format_.upper()}")
|
|
591
|
+
if format_ == WOFF_FLAVOR:
|
|
592
|
+
if settings.remove_dsig and font.remove_dsig():
|
|
593
|
+
self._log(on_log, "Removed the DSIG table")
|
|
594
|
+
font.to_woff()
|
|
595
|
+
else:
|
|
596
|
+
font.to_woff2()
|
|
597
|
+
output_dir = self._output_dir(font, settings, format_, font.is_tt)
|
|
598
|
+
output = font.get_file_path(
|
|
599
|
+
file=source_file, output_dir=output_dir, overwrite=settings.overwrite
|
|
600
|
+
)
|
|
601
|
+
font.save(output, reorder_tables=settings.reorder_tables)
|
|
602
|
+
outputs.append(Path(output))
|
|
603
|
+
if entry_data is not None and web_entries is not None:
|
|
604
|
+
web_entries.append(WebFontEntry(Path(output), format_, *entry_data))
|
|
605
|
+
return outputs
|
|
606
|
+
raise ValueError(f"Unsupported conversion: {conversion}")
|
|
607
|
+
|
|
608
|
+
@staticmethod
|
|
609
|
+
def _instances_for(settings: ConversionSettings, path: Path) -> tuple[InstanceSpec, ...]:
|
|
610
|
+
"""Return the instances to export from a font; the default instance if none is given."""
|
|
611
|
+
settings = _expect_settings(settings, VarToStaticOptions)
|
|
612
|
+
for instance_path, specs in settings.instances:
|
|
613
|
+
if instance_path.resolve() == path and specs:
|
|
614
|
+
return specs
|
|
615
|
+
return (InstanceSpec(()),)
|
|
616
|
+
|
|
617
|
+
def _convert_instance(
|
|
618
|
+
self,
|
|
619
|
+
input_path: Path,
|
|
620
|
+
spec: InstanceSpec,
|
|
621
|
+
settings: VarToStaticOptions,
|
|
622
|
+
used_stems: set[str],
|
|
623
|
+
on_log: LogCallback | None,
|
|
624
|
+
) -> FontOutcome:
|
|
625
|
+
"""Export one instance of a variable font."""
|
|
626
|
+
started = time.perf_counter()
|
|
627
|
+
try:
|
|
628
|
+
with Font(
|
|
629
|
+
input_path,
|
|
630
|
+
recalc_bboxes=settings.recalc_bboxes,
|
|
631
|
+
recalc_timestamp=settings.recalc_timestamp,
|
|
632
|
+
) as font:
|
|
633
|
+
axis_tags = {axis.axisTag for axis in font.axes}
|
|
634
|
+
coordinates = {tag: value for tag, value in spec.coordinates if tag in axis_tags}
|
|
635
|
+
if spec.coordinates and not coordinates:
|
|
636
|
+
raise ValueError("The instance does not match the axes of this font.")
|
|
637
|
+
flavor = font.ttfont.flavor
|
|
638
|
+
label = font.to_static(coordinates, name=spec.name)
|
|
639
|
+
self._log(on_log, f"Instancing {spec.name or label}...")
|
|
640
|
+
if settings.target_upm:
|
|
641
|
+
self._log(on_log, f"Scaling UPM to {settings.target_upm}...")
|
|
642
|
+
font.scale_upm(settings.target_upm)
|
|
643
|
+
self._log(on_log, "Correcting contours...")
|
|
644
|
+
font.correct_contours()
|
|
645
|
+
if font.is_tt and settings.autohint:
|
|
646
|
+
self._log(on_log, "Autohinting...")
|
|
647
|
+
font.ttf_autohint()
|
|
648
|
+
elif font.is_ps and settings.subroutinize:
|
|
649
|
+
self._log(on_log, "Subroutinizing...")
|
|
650
|
+
font.subroutinize()
|
|
651
|
+
stem = instance_stem(input_path, spec.name, label)
|
|
652
|
+
if stem in used_stems:
|
|
653
|
+
stem = f"{stem}-{len(used_stems)}"
|
|
654
|
+
used_stems.add(stem)
|
|
655
|
+
output_dir = self._output_dir(font, settings, flavor, font.is_tt)
|
|
656
|
+
extension = font.get_file_ext()
|
|
657
|
+
output = font.get_file_path(
|
|
658
|
+
file=input_path.with_name(stem + extension),
|
|
659
|
+
output_dir=output_dir,
|
|
660
|
+
overwrite=settings.overwrite,
|
|
661
|
+
extension=extension,
|
|
662
|
+
)
|
|
663
|
+
font.save(output, reorder_tables=settings.reorder_tables)
|
|
664
|
+
return FontOutcome(
|
|
665
|
+
input_path,
|
|
666
|
+
FontOutcomeKind.CONVERTED,
|
|
667
|
+
(Path(output),),
|
|
668
|
+
elapsed=time.perf_counter() - started,
|
|
669
|
+
)
|
|
670
|
+
except Exception as error:
|
|
671
|
+
return FontOutcome(
|
|
672
|
+
input_path,
|
|
673
|
+
FontOutcomeKind.FAILED,
|
|
674
|
+
error=f"{type(error).__name__}: {error}",
|
|
675
|
+
elapsed=time.perf_counter() - started,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def _extract_collection_member(
|
|
679
|
+
self,
|
|
680
|
+
input_path: Path,
|
|
681
|
+
member: TTFont,
|
|
682
|
+
settings: ConversionOptions,
|
|
683
|
+
) -> FontOutcome:
|
|
684
|
+
started = time.perf_counter()
|
|
685
|
+
try:
|
|
686
|
+
member.recalcBBoxes = settings.recalc_bboxes
|
|
687
|
+
member.recalcTimestamp = settings.recalc_timestamp
|
|
688
|
+
font = Font(member)
|
|
689
|
+
font.ensure_postscript_name()
|
|
690
|
+
name = sanitize_filename(font.get_postscript_name())
|
|
691
|
+
extension = font.get_file_ext()
|
|
692
|
+
member_settings = replace(
|
|
693
|
+
settings,
|
|
694
|
+
output_dir=settings.output_dir or input_path.parent,
|
|
695
|
+
)
|
|
696
|
+
output_dir = self._output_dir(font, member_settings, None, font.is_tt)
|
|
697
|
+
output = makeOutputFileName(
|
|
698
|
+
name,
|
|
699
|
+
outputDir=output_dir or input_path.parent,
|
|
700
|
+
overWrite=member_settings.overwrite,
|
|
701
|
+
extension=extension,
|
|
702
|
+
)
|
|
703
|
+
font.save(output, reorder_tables=settings.reorder_tables)
|
|
704
|
+
return FontOutcome(
|
|
705
|
+
input_path,
|
|
706
|
+
FontOutcomeKind.CONVERTED,
|
|
707
|
+
(Path(output),),
|
|
708
|
+
elapsed=time.perf_counter() - started,
|
|
709
|
+
)
|
|
710
|
+
except Exception as error:
|
|
711
|
+
return FontOutcome(
|
|
712
|
+
input_path,
|
|
713
|
+
FontOutcomeKind.FAILED,
|
|
714
|
+
error=f"{type(error).__name__}: {error}",
|
|
715
|
+
elapsed=time.perf_counter() - started,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def _report(
|
|
720
|
+
outcome: FontOutcome,
|
|
721
|
+
callback: OutcomeCallback | None,
|
|
722
|
+
) -> None:
|
|
723
|
+
if callback is not None:
|
|
724
|
+
callback(outcome)
|
|
725
|
+
|
|
726
|
+
@staticmethod
|
|
727
|
+
def _log(callback: LogCallback | None, message: str) -> None:
|
|
728
|
+
if callback is not None:
|
|
729
|
+
callback(message)
|