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
font_converter/font.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import cffsubr
|
|
13
|
+
import ttfautohint as ttfautohint_module
|
|
14
|
+
from cffsubr import subroutinize
|
|
15
|
+
from fontTools import subset
|
|
16
|
+
from fontTools.misc.cliTools import makeOutputFileName
|
|
17
|
+
from fontTools.pens.recordingPen import DecomposingRecordingPen
|
|
18
|
+
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
|
19
|
+
from fontTools.ttLib import TTFont
|
|
20
|
+
from fontTools.ttLib.scaleUpem import scale_upem
|
|
21
|
+
from fontTools.varLib import instancer
|
|
22
|
+
from ttfautohint import ttfautohint
|
|
23
|
+
|
|
24
|
+
from font_converter import constants as const
|
|
25
|
+
from font_converter.contours import correct_cff_contours, correct_glyf_contours
|
|
26
|
+
from font_converter.otf_builder import build_otf
|
|
27
|
+
from font_converter.qu2cu import quadratics_to_cubics
|
|
28
|
+
from font_converter.ttf_builder import build_ttf
|
|
29
|
+
|
|
30
|
+
__all__ = ["Font", "FontConversionError", "FontError"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_CREATE_NO_WINDOW = 0x08000000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hide_console_windows() -> None:
|
|
37
|
+
"""Keep the bundled command-line tools from opening a console window on Windows.
|
|
38
|
+
|
|
39
|
+
cffsubr runs the AFDKO ``tx`` program and ttfautohint runs ``ttfautohint`` as a
|
|
40
|
+
separate process for each font. A GUI app on Windows has no console, so every
|
|
41
|
+
call opens a console window that closes immediately but takes focus, making
|
|
42
|
+
the UI hard to use during a batch conversion.
|
|
43
|
+
|
|
44
|
+
The fix wraps the function each library uses to start the process and adds
|
|
45
|
+
``CREATE_NO_WINDOW`` to the subprocess flags:
|
|
46
|
+
|
|
47
|
+
- ``cffsubr._run_embedded_tx`` (private: re-check it when upgrading cffsubr;
|
|
48
|
+
tested with 0.4.0);
|
|
49
|
+
- ``ttfautohint.run`` (public; tested with ttfautohint-py 0.6.1).
|
|
50
|
+
|
|
51
|
+
If a name is renamed, the attribute lookup fails at import time instead of
|
|
52
|
+
silently doing nothing.
|
|
53
|
+
|
|
54
|
+
Only Windows needs this: POSIX systems do not create a console window for
|
|
55
|
+
child processes, so the function does nothing elsewhere.
|
|
56
|
+
|
|
57
|
+
The alternative to cffsubr, compreffor, subroutinizes in-process and needs no
|
|
58
|
+
patch, but it measured 3-5x slower with ~1-2% smaller output, so cffsubr was
|
|
59
|
+
kept.
|
|
60
|
+
"""
|
|
61
|
+
if sys.platform != "win32":
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
def without_console(original: Callable[..., Any]) -> Callable[..., Any]:
|
|
65
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
kwargs.setdefault("creationflags", _CREATE_NO_WINDOW)
|
|
67
|
+
return original(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
|
|
71
|
+
cffsubr._run_embedded_tx = without_console(cffsubr._run_embedded_tx)
|
|
72
|
+
ttfautohint_module.run = without_console(ttfautohint_module.run)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_hide_console_windows()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def restore_flavor(font: TTFont) -> Iterator[None]:
|
|
80
|
+
"""Temporarily set the flavor to None (needed to subroutinize WOFF/WOFF2 fonts)."""
|
|
81
|
+
original_flavor = font.flavor
|
|
82
|
+
font.flavor = None
|
|
83
|
+
try:
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
font.flavor = original_flavor
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FontError(Exception):
|
|
90
|
+
"""Raised for font-related errors."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FontConversionError(Exception):
|
|
94
|
+
"""Raised when a font cannot be converted."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Font:
|
|
98
|
+
"""A thin wrapper around ``TTFont`` providing the operations needed by the converter."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
font_source: str | Path | BytesIO | TTFont,
|
|
103
|
+
lazy: bool | None = None,
|
|
104
|
+
recalc_bboxes: bool = True,
|
|
105
|
+
recalc_timestamp: bool = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._file: Path | None = None
|
|
108
|
+
self._bytesio: BytesIO | None = None
|
|
109
|
+
if isinstance(font_source, (str, Path)):
|
|
110
|
+
self._file = Path(font_source).resolve()
|
|
111
|
+
self._ttfont = TTFont(
|
|
112
|
+
font_source,
|
|
113
|
+
lazy=lazy,
|
|
114
|
+
recalcBBoxes=recalc_bboxes,
|
|
115
|
+
recalcTimestamp=recalc_timestamp,
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
if isinstance(font_source, TTFont):
|
|
119
|
+
buffer = BytesIO()
|
|
120
|
+
font_source.save(buffer, reorderTables=False)
|
|
121
|
+
buffer.seek(0)
|
|
122
|
+
font_source = buffer
|
|
123
|
+
if isinstance(font_source, BytesIO):
|
|
124
|
+
self._bytesio = font_source
|
|
125
|
+
self._ttfont = TTFont(
|
|
126
|
+
font_source,
|
|
127
|
+
lazy=lazy,
|
|
128
|
+
recalcBBoxes=recalc_bboxes,
|
|
129
|
+
recalcTimestamp=recalc_timestamp,
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
raise FontError(
|
|
133
|
+
f"Invalid source type {type(font_source)}. Expected str, Path, BytesIO, or TTFont."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> Font:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(
|
|
140
|
+
self,
|
|
141
|
+
exc_type: type | None,
|
|
142
|
+
exc_value: BaseException | None,
|
|
143
|
+
traceback: TracebackType | None,
|
|
144
|
+
) -> None:
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
def __repr__(self) -> str:
|
|
148
|
+
return f"<Font: ttfont={self.ttfont}, file={self.file}, bytesio={self.bytesio}>"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def file(self) -> Path | None:
|
|
152
|
+
return self._file
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def bytesio(self) -> BytesIO | None:
|
|
156
|
+
return self._bytesio
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def ttfont(self) -> TTFont:
|
|
160
|
+
return self._ttfont
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def units_per_em(self) -> int:
|
|
164
|
+
return int(self.ttfont[const.T_HEAD].unitsPerEm)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def is_ps(self) -> bool:
|
|
168
|
+
return self.ttfont.sfntVersion == const.PS_SFNT_VERSION
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def is_tt(self) -> bool:
|
|
172
|
+
return self.ttfont.sfntVersion == const.TT_SFNT_VERSION
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_woff(self) -> bool:
|
|
176
|
+
return self.ttfont.flavor == const.WOFF_FLAVOR
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def is_woff2(self) -> bool:
|
|
180
|
+
return self.ttfont.flavor == const.WOFF2_FLAVOR
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def is_sfnt(self) -> bool:
|
|
184
|
+
return self.ttfont.flavor is None
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def is_static(self) -> bool:
|
|
188
|
+
return self.ttfont.get(const.T_FVAR) is None
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_variable(self) -> bool:
|
|
192
|
+
return self.ttfont.get(const.T_FVAR) is not None
|
|
193
|
+
|
|
194
|
+
def save(self, file: str | Path | BytesIO, reorder_tables: bool | None = True) -> None:
|
|
195
|
+
"""Save the font. ``reorder_tables=None`` reorders by table dependency (fastest)."""
|
|
196
|
+
self.ttfont.save(file, reorderTables=reorder_tables)
|
|
197
|
+
|
|
198
|
+
def close(self) -> None:
|
|
199
|
+
self.ttfont.close()
|
|
200
|
+
if self.bytesio:
|
|
201
|
+
self.bytesio.close()
|
|
202
|
+
|
|
203
|
+
def get_file_ext(self) -> str:
|
|
204
|
+
"""Return the real extension of the font (WOFF and WOFF2 are checked first)."""
|
|
205
|
+
if self.is_woff:
|
|
206
|
+
return const.WOFF_EXTENSION
|
|
207
|
+
if self.is_woff2:
|
|
208
|
+
return const.WOFF2_EXTENSION
|
|
209
|
+
if self.is_ps:
|
|
210
|
+
return const.OTF_EXTENSION
|
|
211
|
+
if self.is_tt:
|
|
212
|
+
return const.TTF_EXTENSION
|
|
213
|
+
raise ValueError("Unknown font type.")
|
|
214
|
+
|
|
215
|
+
def get_file_path(
|
|
216
|
+
self,
|
|
217
|
+
file: Path | None = None,
|
|
218
|
+
output_dir: Path | None = None,
|
|
219
|
+
overwrite: bool = True,
|
|
220
|
+
extension: str | None = None,
|
|
221
|
+
) -> Path:
|
|
222
|
+
"""
|
|
223
|
+
Build the output path. If the file exists and ``overwrite`` is ``False``, a number
|
|
224
|
+
preceded by '#' is added to the name until it is unique.
|
|
225
|
+
"""
|
|
226
|
+
file = file or self.file
|
|
227
|
+
if file is None:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
"Cannot get output file for a BytesIO object without providing a file name."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
out_dir = output_dir or file.parent
|
|
233
|
+
extension = extension or self.get_file_ext()
|
|
234
|
+
# The extension is appended so that makeOutputFileName only strips it, keeping any dots
|
|
235
|
+
# that belong to the file name.
|
|
236
|
+
return Path(
|
|
237
|
+
makeOutputFileName(
|
|
238
|
+
input=file.stem + extension,
|
|
239
|
+
outputDir=out_dir,
|
|
240
|
+
extension=extension,
|
|
241
|
+
overWrite=overwrite,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def ensure_postscript_name(self) -> None:
|
|
246
|
+
"""Build the PostScript name (name ID 6) from family and subfamily if it is missing."""
|
|
247
|
+
name_table = self.ttfont[const.T_NAME]
|
|
248
|
+
if name_table.getDebugName(6):
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
family = str(name_table.getBestFamilyName())
|
|
252
|
+
subfamily = str(name_table.getBestSubFamilyName())
|
|
253
|
+
ps_name = f"{family}-{subfamily}".replace(" ", "").replace(".", "_")
|
|
254
|
+
name_table.removeNames(nameID=6)
|
|
255
|
+
name_table.addMultilingualName(
|
|
256
|
+
{"en": ps_name}, ttFont=self.ttfont, nameID=6, windows=True, mac=True
|
|
257
|
+
)
|
|
258
|
+
except Exception as error:
|
|
259
|
+
raise FontConversionError(
|
|
260
|
+
"An error occurred while building the PostScript name from the family and "
|
|
261
|
+
"subfamily names."
|
|
262
|
+
) from error
|
|
263
|
+
|
|
264
|
+
def get_postscript_name(self) -> str:
|
|
265
|
+
return str(self.ttfont[const.T_NAME].getDebugName(6))
|
|
266
|
+
|
|
267
|
+
def to_woff(self) -> None:
|
|
268
|
+
if self.is_woff:
|
|
269
|
+
raise FontConversionError("Font is already a WOFF font.")
|
|
270
|
+
self.ttfont.flavor = const.WOFF_FLAVOR
|
|
271
|
+
|
|
272
|
+
def to_woff2(self) -> None:
|
|
273
|
+
if self.is_woff2:
|
|
274
|
+
raise FontConversionError("Font is already a WOFF2 font.")
|
|
275
|
+
self.ttfont.flavor = const.WOFF2_FLAVOR
|
|
276
|
+
|
|
277
|
+
def to_sfnt(self) -> None:
|
|
278
|
+
if self.is_sfnt:
|
|
279
|
+
raise FontConversionError("Font is already a SFNT font.")
|
|
280
|
+
self.ttfont.flavor = None
|
|
281
|
+
|
|
282
|
+
def remove_unused_glyphs(self) -> int:
|
|
283
|
+
"""
|
|
284
|
+
Drop the glyphs that are not reachable from the cmap or from layout features.
|
|
285
|
+
|
|
286
|
+
All the mapped characters and the OpenType features are kept. Return the number of
|
|
287
|
+
glyphs removed.
|
|
288
|
+
"""
|
|
289
|
+
before = len(self.ttfont.getGlyphOrder())
|
|
290
|
+
options = subset.Options()
|
|
291
|
+
options.layout_features = ["*"]
|
|
292
|
+
options.name_IDs = ["*"]
|
|
293
|
+
options.name_languages = ["*"]
|
|
294
|
+
options.notdef_outline = True
|
|
295
|
+
options.glyph_names = True
|
|
296
|
+
options.legacy_kern = True
|
|
297
|
+
options.hinting = True
|
|
298
|
+
options.layout_closure = True
|
|
299
|
+
subsetter = subset.Subsetter(options=options)
|
|
300
|
+
subsetter.populate(unicodes=list(self.ttfont.getBestCmap() or {}))
|
|
301
|
+
subsetter.subset(self.ttfont)
|
|
302
|
+
return before - len(self.ttfont.getGlyphOrder())
|
|
303
|
+
|
|
304
|
+
def obfuscate_names(self) -> tuple[str, str]:
|
|
305
|
+
"""
|
|
306
|
+
Replace the family, full, unique and PostScript names with hashes.
|
|
307
|
+
|
|
308
|
+
The style name (name ID 2) is kept and the other records (copyright, license...) are not
|
|
309
|
+
touched. Return the obfuscated family name and PostScript name.
|
|
310
|
+
"""
|
|
311
|
+
name_table = self.ttfont[const.T_NAME]
|
|
312
|
+
family = str(name_table.getBestFamilyName() or "")
|
|
313
|
+
original_ps = str(name_table.getDebugName(6) or family)
|
|
314
|
+
subfamily = str(name_table.getDebugName(2) or "Regular")
|
|
315
|
+
|
|
316
|
+
def digest(value: str, length: int) -> str:
|
|
317
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
|
|
318
|
+
|
|
319
|
+
new_family = f"f{digest(family, 10)}"
|
|
320
|
+
new_ps = f"{new_family}-{digest(original_ps, 8)}"
|
|
321
|
+
for name_id in (1, 2, 3, 4, 6, 16, 17, 21, 22, 25):
|
|
322
|
+
name_table.removeNames(nameID=name_id)
|
|
323
|
+
entries = {1: new_family, 2: subfamily, 3: new_ps, 4: new_ps, 6: new_ps}
|
|
324
|
+
for name_id, value in entries.items():
|
|
325
|
+
name_table.setName(value, name_id, 3, 1, 0x409)
|
|
326
|
+
name_table.setName(value, name_id, 1, 0, 0)
|
|
327
|
+
if "CFF " in self.ttfont:
|
|
328
|
+
cff = self.ttfont["CFF "].cff
|
|
329
|
+
cff.fontNames = [new_ps]
|
|
330
|
+
top_dict = cff.topDictIndex[0]
|
|
331
|
+
top_dict.FullName = new_ps
|
|
332
|
+
top_dict.FamilyName = new_family
|
|
333
|
+
return new_family, new_ps
|
|
334
|
+
|
|
335
|
+
def remove_non_windows_names(self) -> int:
|
|
336
|
+
"""Remove the name records whose platform ID is not 3 (Windows); return how many."""
|
|
337
|
+
name_table = self.ttfont[const.T_NAME]
|
|
338
|
+
before = len(name_table.names)
|
|
339
|
+
name_table.names = [record for record in name_table.names if record.platformID == 3]
|
|
340
|
+
return before - len(name_table.names)
|
|
341
|
+
|
|
342
|
+
def remove_dsig(self) -> bool:
|
|
343
|
+
"""Remove the DSIG table; return whether it was present."""
|
|
344
|
+
if const.T_DSIG not in self.ttfont:
|
|
345
|
+
return False
|
|
346
|
+
del self.ttfont[const.T_DSIG]
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
def get_features(self) -> tuple[str, ...]:
|
|
350
|
+
"""Return the sorted OpenType feature tags defined in GSUB and GPOS."""
|
|
351
|
+
tags: set[str] = set()
|
|
352
|
+
for table_tag in ("GSUB", "GPOS"):
|
|
353
|
+
if table_tag not in self.ttfont:
|
|
354
|
+
continue
|
|
355
|
+
feature_list = self.ttfont[table_tag].table.FeatureList
|
|
356
|
+
if feature_list is not None:
|
|
357
|
+
tags.update(record.FeatureTag for record in feature_list.FeatureRecord)
|
|
358
|
+
return tuple(sorted(tags))
|
|
359
|
+
|
|
360
|
+
def to_ttf(self, max_err: float = 1.0, reverse_direction: bool = True) -> None:
|
|
361
|
+
"""Convert a PostScript font to TrueType, removing overlaps and fixing contour direction."""
|
|
362
|
+
if self.is_tt:
|
|
363
|
+
raise FontConversionError("Font is already a TrueType font.")
|
|
364
|
+
if self.is_variable:
|
|
365
|
+
raise FontConversionError("Conversion to TrueType is not supported for variable fonts.")
|
|
366
|
+
|
|
367
|
+
build_ttf(font=self.ttfont, max_err=max_err, reverse_direction=reverse_direction)
|
|
368
|
+
correct_glyf_contours(self.ttfont, remove_hinting=True, ignore_errors=False)
|
|
369
|
+
|
|
370
|
+
def to_otf(self, tolerance: float = 1.0, optimize_contours: int = 0) -> None:
|
|
371
|
+
"""
|
|
372
|
+
Convert a TrueType font to PostScript. If the font has no PostScript name (name ID 6),
|
|
373
|
+
it is built from the family and subfamily names.
|
|
374
|
+
"""
|
|
375
|
+
if self.is_ps:
|
|
376
|
+
raise FontConversionError("Font is already a PostScript font.")
|
|
377
|
+
if self.is_variable:
|
|
378
|
+
raise FontConversionError(
|
|
379
|
+
"Conversion to PostScript is not supported for variable fonts."
|
|
380
|
+
)
|
|
381
|
+
self.ensure_postscript_name()
|
|
382
|
+
self._decompose_composite_glyphs()
|
|
383
|
+
|
|
384
|
+
charstrings = quadratics_to_cubics(
|
|
385
|
+
font=self.ttfont, tolerance=tolerance, optimize_contours=optimize_contours
|
|
386
|
+
)
|
|
387
|
+
build_otf(font=self.ttfont, charstrings_dict=charstrings)
|
|
388
|
+
|
|
389
|
+
self.ttfont[const.T_OS_2].recalcAvgCharWidth(ttFont=self.ttfont)
|
|
390
|
+
|
|
391
|
+
def _decompose_composite_glyphs(self) -> None:
|
|
392
|
+
glyf = self.ttfont[const.T_GLYF]
|
|
393
|
+
glyph_set = self.ttfont.getGlyphSet()
|
|
394
|
+
for glyph_name in self.ttfont.getGlyphOrder():
|
|
395
|
+
if not glyf[glyph_name].isComposite():
|
|
396
|
+
continue
|
|
397
|
+
dc_pen = DecomposingRecordingPen(glyph_set)
|
|
398
|
+
glyph_set[glyph_name].draw(dc_pen)
|
|
399
|
+
tt_pen = TTGlyphPen(None)
|
|
400
|
+
dc_pen.replay(tt_pen)
|
|
401
|
+
glyf[glyph_name] = tt_pen.glyph()
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def axes(self) -> list[Any]:
|
|
405
|
+
return list(self.ttfont[const.T_FVAR].axes) if self.is_variable else []
|
|
406
|
+
|
|
407
|
+
def to_static(
|
|
408
|
+
self,
|
|
409
|
+
coordinates: dict[str, float] | None = None,
|
|
410
|
+
name: str | None = None,
|
|
411
|
+
) -> str:
|
|
412
|
+
"""
|
|
413
|
+
Replace the font with a static instance and return the string describing its location.
|
|
414
|
+
|
|
415
|
+
Axes missing from ``coordinates`` are pinned to their default. For a named instance
|
|
416
|
+
(``name`` given) the name table comes from the STAT table when possible; otherwise,
|
|
417
|
+
and for custom instances, it is rebuilt from ``name`` or from the axis values.
|
|
418
|
+
"""
|
|
419
|
+
if not self.is_variable:
|
|
420
|
+
raise FontConversionError("Font is not a variable font.")
|
|
421
|
+
location = {axis.axisTag: axis.defaultValue for axis in self.axes}
|
|
422
|
+
location.update(coordinates or {})
|
|
423
|
+
label = " ".join(f"{tag}{value:g}" for tag, value in location.items())
|
|
424
|
+
flavor = self.ttfont.flavor
|
|
425
|
+
names_updated = False
|
|
426
|
+
instance = None
|
|
427
|
+
if name is not None:
|
|
428
|
+
try:
|
|
429
|
+
instance = self._instantiate(location, update_names=True)
|
|
430
|
+
names_updated = True
|
|
431
|
+
except (ValueError, AttributeError, KeyError):
|
|
432
|
+
# Incomplete STAT/name data (e.g. missing axis value names): rebuild names.
|
|
433
|
+
pass
|
|
434
|
+
if instance is None:
|
|
435
|
+
instance = self._instantiate(location, update_names=False)
|
|
436
|
+
if not names_updated:
|
|
437
|
+
self._write_instance_names(instance, name or label)
|
|
438
|
+
self.ttfont.close()
|
|
439
|
+
instance.flavor = flavor
|
|
440
|
+
self._ttfont = instance
|
|
441
|
+
return label
|
|
442
|
+
|
|
443
|
+
def _instantiate(self, location: dict[str, float], update_names: bool) -> TTFont:
|
|
444
|
+
return instancer.instantiateVariableFont(
|
|
445
|
+
self.ttfont,
|
|
446
|
+
dict(location),
|
|
447
|
+
inplace=False,
|
|
448
|
+
static=True,
|
|
449
|
+
downgradeCFF2="CFF2" in self.ttfont,
|
|
450
|
+
updateFontNames=update_names,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _write_instance_names(font: TTFont, label: str) -> None:
|
|
455
|
+
name_table = font[const.T_NAME]
|
|
456
|
+
family = str(name_table.getBestFamilyName() or "")
|
|
457
|
+
full = f"{family} {label}".strip()
|
|
458
|
+
ps_name = f"{family}-{label}".replace(" ", "")
|
|
459
|
+
unique_id = f"{full};{font[const.T_HEAD].fontRevision:.3f}"
|
|
460
|
+
for name_id in (1, 2, 3, 4, 6, 16, 17, 25):
|
|
461
|
+
name_table.removeNames(nameID=name_id)
|
|
462
|
+
entries = {1: full, 2: "Regular", 3: unique_id, 4: full, 6: ps_name, 16: family, 17: label}
|
|
463
|
+
for name_id, value in entries.items():
|
|
464
|
+
name_table.setName(value, name_id, 3, 1, 0x409)
|
|
465
|
+
name_table.setName(value, name_id, 1, 0, 0)
|
|
466
|
+
|
|
467
|
+
def scale_upm(self, target_upm: int) -> None:
|
|
468
|
+
"""Scale the font to ``target_upm`` units per em (16 to 16384)."""
|
|
469
|
+
if target_upm < const.MIN_UPM or target_upm > const.MAX_UPM:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
f"units_per_em must be in the range {const.MIN_UPM} to {const.MAX_UPM}."
|
|
472
|
+
)
|
|
473
|
+
if self.units_per_em == target_upm:
|
|
474
|
+
return
|
|
475
|
+
scale_upem(self.ttfont, new_upem=target_upm)
|
|
476
|
+
|
|
477
|
+
def decompose_transformed(self) -> set[str]:
|
|
478
|
+
"""Decompose composite glyphs whose components are scaled, flipped or rotated."""
|
|
479
|
+
glyf = self.ttfont[const.T_GLYF]
|
|
480
|
+
glyph_set = self.ttfont.getGlyphSet()
|
|
481
|
+
decomposed = set()
|
|
482
|
+
for name in self.ttfont.getGlyphOrder():
|
|
483
|
+
glyph = glyf[name]
|
|
484
|
+
if not glyph.isComposite():
|
|
485
|
+
continue
|
|
486
|
+
if all(c.getComponentInfo()[1][0:4] == (1, 0, 0, 1) for c in glyph.components):
|
|
487
|
+
continue
|
|
488
|
+
recorder = DecomposingRecordingPen(glyph_set)
|
|
489
|
+
glyph_set[name].draw(recorder)
|
|
490
|
+
pen = TTGlyphPen(None)
|
|
491
|
+
recorder.replay(pen)
|
|
492
|
+
glyf[name] = pen.glyph()
|
|
493
|
+
decomposed.add(name)
|
|
494
|
+
return decomposed
|
|
495
|
+
|
|
496
|
+
def correct_contours(self) -> set[str]:
|
|
497
|
+
"""Remove overlaps and fix the direction of the contours, dropping stale hinting."""
|
|
498
|
+
if self.is_tt:
|
|
499
|
+
self.decompose_transformed()
|
|
500
|
+
return correct_glyf_contours(self.ttfont, remove_hinting=True, ignore_errors=False)
|
|
501
|
+
return correct_cff_contours(self.ttfont, remove_hinting=True, ignore_errors=False)
|
|
502
|
+
|
|
503
|
+
def subroutinize(self) -> bool:
|
|
504
|
+
"""Subroutinize the CFF table, also for WOFF and WOFF2 fonts."""
|
|
505
|
+
if not self.is_ps:
|
|
506
|
+
raise NotImplementedError("Not a PostScript font.")
|
|
507
|
+
with restore_flavor(self.ttfont):
|
|
508
|
+
subroutinize(self.ttfont)
|
|
509
|
+
return True
|
|
510
|
+
|
|
511
|
+
def ttf_autohint(self) -> bool:
|
|
512
|
+
"""Apply ttfautohint instructions to a TrueType font, also for WOFF and WOFF2 fonts."""
|
|
513
|
+
if not self.is_tt:
|
|
514
|
+
raise NotImplementedError("Not a TrueType font.")
|
|
515
|
+
flavor = self.ttfont.flavor
|
|
516
|
+
try:
|
|
517
|
+
with BytesIO() as buffer:
|
|
518
|
+
modified = self.ttfont[const.T_HEAD].modified
|
|
519
|
+
self.ttfont.flavor = None
|
|
520
|
+
self.save(buffer, reorder_tables=None)
|
|
521
|
+
data = ttfautohint(in_buffer=buffer.getvalue(), no_info=True)
|
|
522
|
+
hinted_font = TTFont(BytesIO(data), recalcTimestamp=False)
|
|
523
|
+
hinted_font[const.T_HEAD].modified = modified
|
|
524
|
+
except Exception as error:
|
|
525
|
+
self.ttfont.flavor = flavor
|
|
526
|
+
raise FontConversionError(f"Autohinting failed: {error}") from error
|
|
527
|
+
self.ttfont.close()
|
|
528
|
+
hinted_font.flavor = flavor
|
|
529
|
+
self._ttfont = hinted_font
|
|
530
|
+
return True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""NotYAFC desktop interface."""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Modal dialog that shows conversion progress, controls and logs."""
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import Qt
|
|
4
|
+
from PySide6.QtGui import QCloseEvent, QKeyEvent
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QDialog,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QLabel,
|
|
9
|
+
QListWidget,
|
|
10
|
+
QProgressBar,
|
|
11
|
+
QVBoxLayout,
|
|
12
|
+
QWidget,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from font_converter.gui.widgets import KeyboardPushButton
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConversionDialog(QDialog):
|
|
19
|
+
"""Hosts status, progress, Pause/Cancel and an expandable log; never closes mid-run."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, parent: QWidget) -> None:
|
|
22
|
+
super().__init__(parent)
|
|
23
|
+
self.setWindowTitle("Conversion")
|
|
24
|
+
self.setModal(True)
|
|
25
|
+
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
|
|
26
|
+
self.setMinimumWidth(700)
|
|
27
|
+
self._running = False
|
|
28
|
+
|
|
29
|
+
self.status = QLabel()
|
|
30
|
+
self.status.setObjectName("statusLabel")
|
|
31
|
+
self.status.setWordWrap(True)
|
|
32
|
+
self.progress = QProgressBar()
|
|
33
|
+
self.progress.setRange(0, 1)
|
|
34
|
+
self.progress.setTextVisible(False)
|
|
35
|
+
|
|
36
|
+
self.pause_button = KeyboardPushButton("Pause")
|
|
37
|
+
self.pause_button.setToolTip(
|
|
38
|
+
"Pause after the font being converted; Resume continues with the next one."
|
|
39
|
+
)
|
|
40
|
+
self.cancel_button = KeyboardPushButton("Cancel")
|
|
41
|
+
self.cancel_button.setToolTip(
|
|
42
|
+
"Stop after the font being converted; converted files are kept."
|
|
43
|
+
)
|
|
44
|
+
self.logs_toggle = KeyboardPushButton("Show logs")
|
|
45
|
+
self.logs_toggle.setCheckable(True)
|
|
46
|
+
self.save_logs_button = KeyboardPushButton("Save logs")
|
|
47
|
+
self.save_logs_button.setEnabled(False)
|
|
48
|
+
self.open_output_button = KeyboardPushButton("Open output folder")
|
|
49
|
+
self.open_output_button.setEnabled(False)
|
|
50
|
+
self.close_button = KeyboardPushButton("Close")
|
|
51
|
+
self.close_button.setObjectName("primaryButton")
|
|
52
|
+
self.close_button.setDefault(True)
|
|
53
|
+
|
|
54
|
+
self.log = QListWidget()
|
|
55
|
+
self.log.setMinimumHeight(260)
|
|
56
|
+
self.log.setVisible(False)
|
|
57
|
+
|
|
58
|
+
buttons = QHBoxLayout()
|
|
59
|
+
buttons.setSpacing(9)
|
|
60
|
+
buttons.addWidget(self.logs_toggle)
|
|
61
|
+
buttons.addWidget(self.save_logs_button)
|
|
62
|
+
buttons.addStretch()
|
|
63
|
+
buttons.addWidget(self.open_output_button)
|
|
64
|
+
buttons.addWidget(self.pause_button)
|
|
65
|
+
buttons.addWidget(self.cancel_button)
|
|
66
|
+
buttons.addWidget(self.close_button)
|
|
67
|
+
|
|
68
|
+
layout = QVBoxLayout(self)
|
|
69
|
+
layout.setContentsMargins(16, 12, 16, 14)
|
|
70
|
+
layout.setSpacing(9)
|
|
71
|
+
layout.addWidget(self.status)
|
|
72
|
+
layout.addWidget(self.progress)
|
|
73
|
+
layout.addLayout(buttons)
|
|
74
|
+
layout.addWidget(self.log, 1)
|
|
75
|
+
|
|
76
|
+
self.logs_toggle.toggled.connect(self._set_logs_visible)
|
|
77
|
+
self.close_button.clicked.connect(self.accept)
|
|
78
|
+
self.set_running(False)
|
|
79
|
+
|
|
80
|
+
def set_running(self, running: bool) -> None:
|
|
81
|
+
self._running = running
|
|
82
|
+
self.pause_button.setVisible(running)
|
|
83
|
+
self.cancel_button.setVisible(running)
|
|
84
|
+
self.close_button.setVisible(not running)
|
|
85
|
+
|
|
86
|
+
def set_status(self, text: str, state: str = "") -> None:
|
|
87
|
+
self.status.setText(text)
|
|
88
|
+
self.status.setToolTip(text if state == "error" else "")
|
|
89
|
+
self.status.setProperty("state", state)
|
|
90
|
+
self.status.style().unpolish(self.status)
|
|
91
|
+
self.status.style().polish(self.status)
|
|
92
|
+
|
|
93
|
+
def _set_logs_visible(self, visible: bool) -> None:
|
|
94
|
+
self.logs_toggle.setText("Hide logs" if visible else "Show logs")
|
|
95
|
+
self.log.setVisible(visible)
|
|
96
|
+
if not visible:
|
|
97
|
+
self.setMinimumHeight(0)
|
|
98
|
+
self.adjustSize()
|
|
99
|
+
|
|
100
|
+
def closeEvent(self, event: QCloseEvent) -> None:
|
|
101
|
+
if self._running:
|
|
102
|
+
event.ignore()
|
|
103
|
+
return
|
|
104
|
+
super().closeEvent(event)
|
|
105
|
+
|
|
106
|
+
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
107
|
+
if event.key() == Qt.Key.Key_Escape and self._running:
|
|
108
|
+
event.ignore()
|
|
109
|
+
return
|
|
110
|
+
super().keyPressEvent(event)
|