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/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)