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.
@@ -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)