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,583 @@
|
|
|
1
|
+
"""Per-font tree of the static instances to export from variable fonts."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QPoint, Qt, Signal
|
|
6
|
+
from PySide6.QtGui import (
|
|
7
|
+
QColor,
|
|
8
|
+
QDragEnterEvent,
|
|
9
|
+
QDragLeaveEvent,
|
|
10
|
+
QDragMoveEvent,
|
|
11
|
+
QDropEvent,
|
|
12
|
+
QKeySequence,
|
|
13
|
+
QShortcut,
|
|
14
|
+
)
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QDialog,
|
|
17
|
+
QDialogButtonBox,
|
|
18
|
+
QGridLayout,
|
|
19
|
+
QHeaderView,
|
|
20
|
+
QLabel,
|
|
21
|
+
QMenu,
|
|
22
|
+
QMessageBox,
|
|
23
|
+
QTreeWidget,
|
|
24
|
+
QTreeWidgetItem,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from font_converter.gui.styles import DARK, LIGHT
|
|
30
|
+
from font_converter.gui.widgets import (
|
|
31
|
+
DecimalSpinBox,
|
|
32
|
+
StepperField,
|
|
33
|
+
accept_local_files,
|
|
34
|
+
dropped_paths,
|
|
35
|
+
set_drag_over,
|
|
36
|
+
)
|
|
37
|
+
from font_converter.inspection import AxisInfo, FontInfo
|
|
38
|
+
from font_converter.service import InstanceSpec, instance_label, instance_stem
|
|
39
|
+
|
|
40
|
+
_SPEC_ROLE = Qt.ItemDataRole.UserRole
|
|
41
|
+
_CUSTOM_ROLE = Qt.ItemDataRole.UserRole + 1
|
|
42
|
+
_PATH_ROLE = Qt.ItemDataRole.UserRole + 2
|
|
43
|
+
_AXES_ROLE = Qt.ItemDataRole.UserRole + 3
|
|
44
|
+
_OUTLINES_ROLE = Qt.ItemDataRole.UserRole + 4
|
|
45
|
+
_LOCATION_COLUMN = 4
|
|
46
|
+
|
|
47
|
+
_Memory = tuple[set[InstanceSpec], list[InstanceSpec]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _number(value: float) -> str:
|
|
51
|
+
return f"{value:g}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def location_text(coordinates: tuple[tuple[str, float], ...]) -> str:
|
|
55
|
+
if not coordinates:
|
|
56
|
+
return "Default"
|
|
57
|
+
return ", ".join(f"{tag}={_number(value)}" for tag, value in coordinates)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CustomInstanceDialog(QDialog):
|
|
61
|
+
"""Ask for one value per axis of a single variable font."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
axes: tuple[AxisInfo, ...],
|
|
66
|
+
named_instances: tuple[InstanceSpec, ...],
|
|
67
|
+
font_name: str,
|
|
68
|
+
input_path: Path,
|
|
69
|
+
parent: QWidget,
|
|
70
|
+
coordinates: tuple[tuple[str, float], ...] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(parent)
|
|
73
|
+
self.setWindowTitle(
|
|
74
|
+
"Edit custom instance" if coordinates is not None else "Custom instance"
|
|
75
|
+
)
|
|
76
|
+
self._axes = axes
|
|
77
|
+
self._named_instances = named_instances
|
|
78
|
+
self._input_path = input_path
|
|
79
|
+
layout = QVBoxLayout(self)
|
|
80
|
+
layout.setContentsMargins(16, 12, 16, 14)
|
|
81
|
+
layout.setSpacing(9)
|
|
82
|
+
intro = QLabel(f"Axis values for {font_name}")
|
|
83
|
+
intro.setObjectName("hint")
|
|
84
|
+
intro.setWordWrap(True)
|
|
85
|
+
layout.addWidget(intro)
|
|
86
|
+
|
|
87
|
+
grid = QGridLayout()
|
|
88
|
+
grid.setHorizontalSpacing(12)
|
|
89
|
+
grid.setVerticalSpacing(12)
|
|
90
|
+
self._boxes: dict[str, DecimalSpinBox] = {}
|
|
91
|
+
for row, axis in enumerate(axes):
|
|
92
|
+
box = DecimalSpinBox()
|
|
93
|
+
box.setDecimals(2)
|
|
94
|
+
box.setRange(axis.minimum, axis.maximum)
|
|
95
|
+
box.setValue(dict(coordinates or ()).get(axis.tag, axis.default))
|
|
96
|
+
self._boxes[axis.tag] = box
|
|
97
|
+
field = StepperField(box)
|
|
98
|
+
field.setFixedWidth(170)
|
|
99
|
+
range_label = QLabel(f"{_number(axis.minimum)}–{_number(axis.maximum)}")
|
|
100
|
+
range_label.setObjectName("hint")
|
|
101
|
+
grid.addWidget(QLabel(axis.tag), row, 0)
|
|
102
|
+
grid.addWidget(field, row, 1)
|
|
103
|
+
grid.addWidget(range_label, row, 2)
|
|
104
|
+
layout.addLayout(grid)
|
|
105
|
+
|
|
106
|
+
self.filename_preview = QLabel()
|
|
107
|
+
self.filename_preview.setObjectName("hint")
|
|
108
|
+
self.filename_preview.setWordWrap(True)
|
|
109
|
+
layout.addWidget(self.filename_preview)
|
|
110
|
+
for box in self._boxes.values():
|
|
111
|
+
box.valueChanged.connect(self._update_filename_preview)
|
|
112
|
+
self._update_filename_preview()
|
|
113
|
+
|
|
114
|
+
buttons = QDialogButtonBox(
|
|
115
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
116
|
+
)
|
|
117
|
+
buttons.accepted.connect(self.accept)
|
|
118
|
+
buttons.rejected.connect(self.reject)
|
|
119
|
+
layout.addWidget(buttons)
|
|
120
|
+
|
|
121
|
+
def coordinates(self) -> tuple[tuple[str, float], ...]:
|
|
122
|
+
return tuple((tag, box.value()) for tag, box in self._boxes.items())
|
|
123
|
+
|
|
124
|
+
def _update_filename_preview(self) -> None:
|
|
125
|
+
coordinates = self.coordinates()
|
|
126
|
+
label = instance_label(coordinates)
|
|
127
|
+
coordinates_by_tag = dict(coordinates)
|
|
128
|
+
named_instance = next(
|
|
129
|
+
(
|
|
130
|
+
spec
|
|
131
|
+
for spec in self._named_instances
|
|
132
|
+
if spec.name
|
|
133
|
+
and len(spec.coordinates) == len(coordinates_by_tag)
|
|
134
|
+
and all(
|
|
135
|
+
tag in coordinates_by_tag and abs(coordinates_by_tag[tag] - value) < 0.0000005
|
|
136
|
+
for tag, value in spec.coordinates
|
|
137
|
+
)
|
|
138
|
+
),
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
stem = instance_stem(
|
|
142
|
+
self._input_path,
|
|
143
|
+
named_instance.name if named_instance is not None else None,
|
|
144
|
+
label,
|
|
145
|
+
)
|
|
146
|
+
self.filename_preview.setText(f"Output file: {stem}{self._input_path.suffix}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InstanceTree(QTreeWidget):
|
|
150
|
+
"""Variable fonts as parents, their exportable instances as checkable children."""
|
|
151
|
+
|
|
152
|
+
paths_dropped = Signal(list)
|
|
153
|
+
checksChanged = Signal()
|
|
154
|
+
actionsChanged = Signal()
|
|
155
|
+
|
|
156
|
+
def __init__(self) -> None:
|
|
157
|
+
super().__init__()
|
|
158
|
+
self.setObjectName("instanceTree")
|
|
159
|
+
self.setAcceptDrops(True)
|
|
160
|
+
self.setProperty("dragOver", "false")
|
|
161
|
+
self.setColumnCount(5)
|
|
162
|
+
self.setHeaderLabels(["Font / instance", "Format", "Outlines", "UPM", "Location"])
|
|
163
|
+
self.headerItem().setToolTip(
|
|
164
|
+
_LOCATION_COLUMN, "Axis coordinates. Drag the column divider to resize."
|
|
165
|
+
)
|
|
166
|
+
self.setRootIsDecorated(True)
|
|
167
|
+
self.setAlternatingRowColors(True)
|
|
168
|
+
self.setUniformRowHeights(True)
|
|
169
|
+
self.setTabKeyNavigation(False)
|
|
170
|
+
self.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection)
|
|
171
|
+
self.setSortingEnabled(True)
|
|
172
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
173
|
+
header = self.header()
|
|
174
|
+
header.setStretchLastSection(False)
|
|
175
|
+
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
176
|
+
for column in range(1, self.columnCount()):
|
|
177
|
+
header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive)
|
|
178
|
+
header.resizeSection(1, 90)
|
|
179
|
+
header.resizeSection(2, 90)
|
|
180
|
+
header.resizeSection(3, 70)
|
|
181
|
+
header.resizeSection(_LOCATION_COLUMN, 220)
|
|
182
|
+
self.sortByColumn(0, Qt.SortOrder.AscendingOrder)
|
|
183
|
+
self._filter_text = ""
|
|
184
|
+
self._hide_incompatible = False
|
|
185
|
+
self._memory: dict[Path, _Memory] = {}
|
|
186
|
+
self._muted_color = QColor(LIGHT.text_disabled)
|
|
187
|
+
self.itemChanged.connect(self._on_item_changed)
|
|
188
|
+
self.currentItemChanged.connect(lambda *_: self.actionsChanged.emit())
|
|
189
|
+
self.customContextMenuRequested.connect(self._show_menu)
|
|
190
|
+
self.itemDoubleClicked.connect(self._on_item_double_clicked)
|
|
191
|
+
self._delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self)
|
|
192
|
+
self._delete_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
193
|
+
self._delete_shortcut.activated.connect(self.remove_custom)
|
|
194
|
+
self._menu_shortcut = QShortcut(QKeySequence("Shift+F10"), self)
|
|
195
|
+
self._menu_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
|
|
196
|
+
self._menu_shortcut.activated.connect(self._show_current_menu)
|
|
197
|
+
|
|
198
|
+
def set_dark_theme(self, enabled: bool) -> None:
|
|
199
|
+
palette = DARK if enabled else LIGHT
|
|
200
|
+
self._muted_color = QColor(palette.text_disabled)
|
|
201
|
+
for index in range(self.topLevelItemCount()):
|
|
202
|
+
item = self.topLevelItem(index)
|
|
203
|
+
if item is not None and item.data(0, _PATH_ROLE) is None:
|
|
204
|
+
for column in range(self.columnCount()):
|
|
205
|
+
item.setForeground(column, self._muted_color)
|
|
206
|
+
self.viewport().update()
|
|
207
|
+
|
|
208
|
+
def add_skipped(self, path: Path, label: str, reason: str, info: FontInfo) -> None:
|
|
209
|
+
"""List a font that cannot be used, greyed out and with the reason."""
|
|
210
|
+
item = QTreeWidgetItem(self._row_texts(label, info, reason))
|
|
211
|
+
item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
|
212
|
+
item.setToolTip(0, f"{path}\n{reason}")
|
|
213
|
+
item.setToolTip(_LOCATION_COLUMN, reason)
|
|
214
|
+
for column in range(self.columnCount()):
|
|
215
|
+
item.setForeground(column, self._muted_color)
|
|
216
|
+
self.addTopLevelItem(item)
|
|
217
|
+
self.set_filter(self._filter_text)
|
|
218
|
+
self.viewport().update()
|
|
219
|
+
self.checksChanged.emit()
|
|
220
|
+
|
|
221
|
+
def add_font(
|
|
222
|
+
self, path: Path, label: str, info: FontInfo, checked_by_default: bool = True
|
|
223
|
+
) -> None:
|
|
224
|
+
remembered = self._memory.get(path)
|
|
225
|
+
checked, custom = remembered if remembered else (None, [])
|
|
226
|
+
was_blocked = self.blockSignals(True)
|
|
227
|
+
parent = QTreeWidgetItem(self._row_texts(label, info, ""))
|
|
228
|
+
parent.setFlags(
|
|
229
|
+
Qt.ItemFlag.ItemIsEnabled
|
|
230
|
+
| Qt.ItemFlag.ItemIsSelectable
|
|
231
|
+
| Qt.ItemFlag.ItemIsUserCheckable
|
|
232
|
+
| Qt.ItemFlag.ItemIsAutoTristate
|
|
233
|
+
)
|
|
234
|
+
parent.setToolTip(0, str(path))
|
|
235
|
+
parent.setData(0, _PATH_ROLE, path)
|
|
236
|
+
parent.setData(0, _AXES_ROLE, info.axes)
|
|
237
|
+
parent.setData(0, _OUTLINES_ROLE, info.outlines)
|
|
238
|
+
self.addTopLevelItem(parent)
|
|
239
|
+
named = [InstanceSpec(i.coordinates, i.name) for i in info.instances]
|
|
240
|
+
for spec in named or [InstanceSpec(())]:
|
|
241
|
+
self._add_child(
|
|
242
|
+
parent,
|
|
243
|
+
spec,
|
|
244
|
+
custom=False,
|
|
245
|
+
checked=checked_by_default if checked is None else spec in checked,
|
|
246
|
+
)
|
|
247
|
+
for spec in custom:
|
|
248
|
+
self._add_child(parent, spec, custom=True, checked=checked is None or spec in checked)
|
|
249
|
+
self._update_summary(parent)
|
|
250
|
+
self._fit_location_column()
|
|
251
|
+
self.blockSignals(was_blocked)
|
|
252
|
+
self.set_filter(self._filter_text)
|
|
253
|
+
self.viewport().update()
|
|
254
|
+
self.checksChanged.emit()
|
|
255
|
+
|
|
256
|
+
def set_font_checked(self, path: Path, checked: bool) -> None:
|
|
257
|
+
font = next((item for item in self._fonts() if item.data(0, _PATH_ROLE) == path), None)
|
|
258
|
+
if font is None:
|
|
259
|
+
return
|
|
260
|
+
was_blocked = self.blockSignals(True)
|
|
261
|
+
state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
|
|
262
|
+
for child in self._children(font):
|
|
263
|
+
child.setCheckState(0, state)
|
|
264
|
+
self._update_summary(font)
|
|
265
|
+
self._remember(font)
|
|
266
|
+
self.blockSignals(was_blocked)
|
|
267
|
+
self.checksChanged.emit()
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _row_texts(label: str, info: FontInfo, location: str) -> list[str]:
|
|
271
|
+
upm = "Mixed" if info.upm is None else str(info.upm)
|
|
272
|
+
return [label, info.format, info.outlines, upm, location]
|
|
273
|
+
|
|
274
|
+
def _add_child(
|
|
275
|
+
self, parent: QTreeWidgetItem, spec: InstanceSpec, custom: bool, checked: bool
|
|
276
|
+
) -> QTreeWidgetItem:
|
|
277
|
+
name = spec.name or ("Default instance" if not spec.coordinates else "Custom instance")
|
|
278
|
+
location = location_text(spec.coordinates)
|
|
279
|
+
child = QTreeWidgetItem([name, "", "", "", location])
|
|
280
|
+
child.setFlags(
|
|
281
|
+
Qt.ItemFlag.ItemIsEnabled
|
|
282
|
+
| Qt.ItemFlag.ItemIsSelectable
|
|
283
|
+
| Qt.ItemFlag.ItemIsUserCheckable
|
|
284
|
+
)
|
|
285
|
+
child.setCheckState(0, Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
|
|
286
|
+
child.setData(0, _SPEC_ROLE, spec)
|
|
287
|
+
child.setData(0, _CUSTOM_ROLE, custom)
|
|
288
|
+
child.setToolTip(_LOCATION_COLUMN, location)
|
|
289
|
+
parent.addChild(child)
|
|
290
|
+
return child
|
|
291
|
+
|
|
292
|
+
def _fit_location_column(self) -> None:
|
|
293
|
+
self.resizeColumnToContents(_LOCATION_COLUMN)
|
|
294
|
+
self.header().resizeSection(
|
|
295
|
+
_LOCATION_COLUMN, min(max(self.columnWidth(_LOCATION_COLUMN), 180), 360)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def set_hide_incompatible(self, hide: bool) -> None:
|
|
299
|
+
"""Show or hide fonts that cannot be exported as variable instances."""
|
|
300
|
+
self._hide_incompatible = hide
|
|
301
|
+
self.set_filter(self._filter_text)
|
|
302
|
+
|
|
303
|
+
def set_filter(self, text: str) -> None:
|
|
304
|
+
"""Filter fonts and instances while keeping a matching font's children visible."""
|
|
305
|
+
self._filter_text = text.casefold().strip()
|
|
306
|
+
for font in self._fonts():
|
|
307
|
+
font_match = self._matches(font)
|
|
308
|
+
child_match = False
|
|
309
|
+
for child in self._children(font):
|
|
310
|
+
matches = not self._filter_text or font_match or self._matches(child)
|
|
311
|
+
child.setHidden(not matches)
|
|
312
|
+
child_match = child_match or matches
|
|
313
|
+
font.setHidden(bool(self._filter_text) and not font_match and not child_match)
|
|
314
|
+
for index in range(self.topLevelItemCount()):
|
|
315
|
+
item = self.topLevelItem(index)
|
|
316
|
+
if item is not None and item.data(0, _PATH_ROLE) is None:
|
|
317
|
+
item.setHidden(
|
|
318
|
+
self._hide_incompatible or (bool(self._filter_text) and not self._matches(item))
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _matches(self, item: QTreeWidgetItem) -> bool:
|
|
322
|
+
if not self._filter_text:
|
|
323
|
+
return True
|
|
324
|
+
text = " ".join(
|
|
325
|
+
item.text(column) + " " + item.toolTip(column) for column in range(self.columnCount())
|
|
326
|
+
)
|
|
327
|
+
return self._filter_text in text.casefold()
|
|
328
|
+
|
|
329
|
+
def _update_summary(self, parent: QTreeWidgetItem) -> None:
|
|
330
|
+
total = parent.childCount()
|
|
331
|
+
selected = sum(c.checkState(0) == Qt.CheckState.Checked for c in self._children(parent))
|
|
332
|
+
parent.setText(_LOCATION_COLUMN, f"{selected} of {total} selected")
|
|
333
|
+
parent.setToolTip(_LOCATION_COLUMN, f"{selected} of {total} instances selected")
|
|
334
|
+
|
|
335
|
+
def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
|
|
336
|
+
if column != 0:
|
|
337
|
+
return
|
|
338
|
+
parent = item.parent() or item
|
|
339
|
+
was_blocked = self.blockSignals(True)
|
|
340
|
+
self._update_summary(parent)
|
|
341
|
+
self.blockSignals(was_blocked)
|
|
342
|
+
self._remember(parent)
|
|
343
|
+
self.checksChanged.emit()
|
|
344
|
+
|
|
345
|
+
def _fonts(self) -> list[QTreeWidgetItem]:
|
|
346
|
+
items = (self.topLevelItem(i) for i in range(self.topLevelItemCount()))
|
|
347
|
+
return [item for item in items if item and item.data(0, _PATH_ROLE) is not None]
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _children(font: QTreeWidgetItem) -> list[QTreeWidgetItem]:
|
|
351
|
+
return [item for i in range(font.childCount()) if (item := font.child(i))]
|
|
352
|
+
|
|
353
|
+
def _remember(self, font: QTreeWidgetItem) -> None:
|
|
354
|
+
children = self._children(font)
|
|
355
|
+
self._memory[font.data(0, _PATH_ROLE)] = (
|
|
356
|
+
{c.data(0, _SPEC_ROLE) for c in children if c.checkState(0) == Qt.CheckState.Checked},
|
|
357
|
+
[c.data(0, _SPEC_ROLE) for c in children if c.data(0, _CUSTOM_ROLE)],
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def clear_fonts(self) -> None:
|
|
361
|
+
"""Drop the rows for a rescan while keeping the user's choices."""
|
|
362
|
+
was_blocked = self.blockSignals(True)
|
|
363
|
+
self.clear()
|
|
364
|
+
self.blockSignals(was_blocked)
|
|
365
|
+
self.viewport().update()
|
|
366
|
+
self.checksChanged.emit()
|
|
367
|
+
|
|
368
|
+
def forget(self) -> None:
|
|
369
|
+
self._memory.clear()
|
|
370
|
+
|
|
371
|
+
def selection(self) -> tuple[tuple[Path, tuple[InstanceSpec, ...]], ...]:
|
|
372
|
+
result = []
|
|
373
|
+
for font in self._fonts():
|
|
374
|
+
specs = tuple(
|
|
375
|
+
c.data(0, _SPEC_ROLE)
|
|
376
|
+
for c in self._children(font)
|
|
377
|
+
if c.checkState(0) == Qt.CheckState.Checked
|
|
378
|
+
)
|
|
379
|
+
if specs:
|
|
380
|
+
result.append((font.data(0, _PATH_ROLE), specs))
|
|
381
|
+
return tuple(result)
|
|
382
|
+
|
|
383
|
+
def selected_paths(self) -> list[Path]:
|
|
384
|
+
return [path for path, _ in self.selection()]
|
|
385
|
+
|
|
386
|
+
def instance_count(self) -> int:
|
|
387
|
+
return sum(len(specs) for _, specs in self.selection())
|
|
388
|
+
|
|
389
|
+
def font_count(self) -> int:
|
|
390
|
+
return len(self._fonts())
|
|
391
|
+
|
|
392
|
+
def skipped_count(self) -> int:
|
|
393
|
+
return self.topLevelItemCount() - len(self._fonts())
|
|
394
|
+
|
|
395
|
+
def selected_outlines(self) -> set[str]:
|
|
396
|
+
"""Outline kinds ("TrueType"/"PostScript") of the fonts with selected instances."""
|
|
397
|
+
return {
|
|
398
|
+
font.data(0, _OUTLINES_ROLE)
|
|
399
|
+
for font in self._fonts()
|
|
400
|
+
if any(c.checkState(0) == Qt.CheckState.Checked for c in self._children(font))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
def auto_expand(self) -> None:
|
|
404
|
+
"""Show the instances right away when there is a single variable font."""
|
|
405
|
+
fonts = self._fonts()
|
|
406
|
+
if len(fonts) == 1:
|
|
407
|
+
fonts[0].setExpanded(True)
|
|
408
|
+
|
|
409
|
+
def check_state(self) -> Qt.CheckState | None:
|
|
410
|
+
states = {c.checkState(0) for font in self._fonts() for c in self._children(font)}
|
|
411
|
+
if not states:
|
|
412
|
+
return None
|
|
413
|
+
if len(states) > 1:
|
|
414
|
+
return Qt.CheckState.PartiallyChecked
|
|
415
|
+
return states.pop()
|
|
416
|
+
|
|
417
|
+
def set_all_checked(self, checked: bool) -> None:
|
|
418
|
+
state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
|
|
419
|
+
was_blocked = self.blockSignals(True)
|
|
420
|
+
for font in self._fonts():
|
|
421
|
+
for child in self._children(font):
|
|
422
|
+
child.setCheckState(0, state)
|
|
423
|
+
self._update_summary(font)
|
|
424
|
+
self._remember(font)
|
|
425
|
+
self.blockSignals(was_blocked)
|
|
426
|
+
self.checksChanged.emit()
|
|
427
|
+
|
|
428
|
+
def _current_font(self) -> QTreeWidgetItem | None:
|
|
429
|
+
item = self.currentItem()
|
|
430
|
+
if item is None:
|
|
431
|
+
return None
|
|
432
|
+
font = item.parent() or item
|
|
433
|
+
return font if font.data(0, _PATH_ROLE) is not None else None
|
|
434
|
+
|
|
435
|
+
def can_add(self) -> bool:
|
|
436
|
+
return self._current_font() is not None
|
|
437
|
+
|
|
438
|
+
def can_remove(self) -> bool:
|
|
439
|
+
item = self.currentItem()
|
|
440
|
+
return item is not None and bool(item.data(0, _CUSTOM_ROLE))
|
|
441
|
+
|
|
442
|
+
def add_custom(self) -> None:
|
|
443
|
+
self._edit_or_add_custom()
|
|
444
|
+
|
|
445
|
+
def edit_custom(self) -> None:
|
|
446
|
+
item = self.currentItem()
|
|
447
|
+
if item is not None and item.data(0, _CUSTOM_ROLE):
|
|
448
|
+
self._edit_or_add_custom(item)
|
|
449
|
+
|
|
450
|
+
def _edit_or_add_custom(self, editing: QTreeWidgetItem | None = None) -> None:
|
|
451
|
+
font = self._current_font()
|
|
452
|
+
if font is None:
|
|
453
|
+
return
|
|
454
|
+
axes: tuple[AxisInfo, ...] = font.data(0, _AXES_ROLE)
|
|
455
|
+
if not axes:
|
|
456
|
+
return
|
|
457
|
+
named_instances = tuple(
|
|
458
|
+
child.data(0, _SPEC_ROLE)
|
|
459
|
+
for child in self._children(font)
|
|
460
|
+
if not child.data(0, _CUSTOM_ROLE) and child.data(0, _SPEC_ROLE).name is not None
|
|
461
|
+
)
|
|
462
|
+
input_path: Path = font.data(0, _PATH_ROLE)
|
|
463
|
+
dialog = CustomInstanceDialog(
|
|
464
|
+
axes,
|
|
465
|
+
named_instances,
|
|
466
|
+
font.text(0),
|
|
467
|
+
input_path,
|
|
468
|
+
self.window(),
|
|
469
|
+
editing.data(0, _SPEC_ROLE).coordinates if editing is not None else None,
|
|
470
|
+
)
|
|
471
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
472
|
+
return
|
|
473
|
+
spec = InstanceSpec(dialog.coordinates())
|
|
474
|
+
existing = next(
|
|
475
|
+
(
|
|
476
|
+
c
|
|
477
|
+
for c in self._children(font)
|
|
478
|
+
if c is not editing and c.data(0, _SPEC_ROLE).coordinates == spec.coordinates
|
|
479
|
+
),
|
|
480
|
+
None,
|
|
481
|
+
)
|
|
482
|
+
if existing is not None and editing is not None:
|
|
483
|
+
QMessageBox.information(
|
|
484
|
+
self.window(),
|
|
485
|
+
"Instance already exists",
|
|
486
|
+
f'These values match the existing instance "{existing.text(0)}".',
|
|
487
|
+
)
|
|
488
|
+
return
|
|
489
|
+
was_blocked = self.blockSignals(True)
|
|
490
|
+
if existing is not None:
|
|
491
|
+
existing.setCheckState(0, Qt.CheckState.Checked)
|
|
492
|
+
target = existing
|
|
493
|
+
existing_spec: InstanceSpec = existing.data(0, _SPEC_ROLE)
|
|
494
|
+
if existing_spec.name is not None:
|
|
495
|
+
QMessageBox.information(
|
|
496
|
+
self.window(),
|
|
497
|
+
"Named instance already exists",
|
|
498
|
+
f'These values match the named instance "{existing.text(0)}". '
|
|
499
|
+
"The existing instance has been selected.",
|
|
500
|
+
)
|
|
501
|
+
elif editing is not None:
|
|
502
|
+
was_checked = editing.checkState(0) == Qt.CheckState.Checked
|
|
503
|
+
editing.setText(0, "Custom instance")
|
|
504
|
+
editing.setText(_LOCATION_COLUMN, location_text(spec.coordinates))
|
|
505
|
+
editing.setToolTip(_LOCATION_COLUMN, location_text(spec.coordinates))
|
|
506
|
+
editing.setData(0, _SPEC_ROLE, spec)
|
|
507
|
+
editing.setCheckState(
|
|
508
|
+
0, Qt.CheckState.Checked if was_checked else Qt.CheckState.Unchecked
|
|
509
|
+
)
|
|
510
|
+
target = editing
|
|
511
|
+
else:
|
|
512
|
+
target = self._add_child(font, spec, custom=True, checked=True)
|
|
513
|
+
font.setExpanded(True)
|
|
514
|
+
self._update_summary(font)
|
|
515
|
+
self.blockSignals(was_blocked)
|
|
516
|
+
self._remember(font)
|
|
517
|
+
self.setCurrentItem(target)
|
|
518
|
+
self.set_filter(self._filter_text)
|
|
519
|
+
self.checksChanged.emit()
|
|
520
|
+
|
|
521
|
+
def _on_item_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
|
|
522
|
+
if item.data(0, _CUSTOM_ROLE):
|
|
523
|
+
self.setCurrentItem(item)
|
|
524
|
+
self.edit_custom()
|
|
525
|
+
|
|
526
|
+
def remove_custom(self) -> None:
|
|
527
|
+
item = self.currentItem()
|
|
528
|
+
parent = item.parent() if item is not None else None
|
|
529
|
+
if item is None or parent is None or not item.data(0, _CUSTOM_ROLE):
|
|
530
|
+
return
|
|
531
|
+
was_blocked = self.blockSignals(True)
|
|
532
|
+
parent.removeChild(item)
|
|
533
|
+
self._update_summary(parent)
|
|
534
|
+
self.blockSignals(was_blocked)
|
|
535
|
+
self._remember(parent)
|
|
536
|
+
self.actionsChanged.emit()
|
|
537
|
+
self.checksChanged.emit()
|
|
538
|
+
|
|
539
|
+
def _show_menu(self, position: QPoint) -> None:
|
|
540
|
+
item = self.itemAt(position)
|
|
541
|
+
if item is None:
|
|
542
|
+
return
|
|
543
|
+
self.setCurrentItem(item)
|
|
544
|
+
menu = QMenu(self)
|
|
545
|
+
add = menu.addAction("Add custom instance…")
|
|
546
|
+
add.setEnabled(self.can_add())
|
|
547
|
+
edit = menu.addAction("Edit custom instance…")
|
|
548
|
+
edit.setEnabled(self.can_remove())
|
|
549
|
+
remove = menu.addAction("Remove instance")
|
|
550
|
+
remove.setEnabled(self.can_remove())
|
|
551
|
+
chosen = menu.exec(self.viewport().mapToGlobal(position))
|
|
552
|
+
if chosen is add:
|
|
553
|
+
self.add_custom()
|
|
554
|
+
elif chosen is edit:
|
|
555
|
+
self.edit_custom()
|
|
556
|
+
elif chosen is remove:
|
|
557
|
+
self.remove_custom()
|
|
558
|
+
|
|
559
|
+
def _show_current_menu(self) -> None:
|
|
560
|
+
item = self.currentItem()
|
|
561
|
+
if item is not None:
|
|
562
|
+
self.scrollToItem(item)
|
|
563
|
+
self._show_menu(self.visualItemRect(item).center())
|
|
564
|
+
|
|
565
|
+
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
|
566
|
+
accept_local_files(event)
|
|
567
|
+
set_drag_over(self, event.isAccepted())
|
|
568
|
+
|
|
569
|
+
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
|
|
570
|
+
accept_local_files(event)
|
|
571
|
+
|
|
572
|
+
def dragLeaveEvent(self, event: QDragLeaveEvent) -> None:
|
|
573
|
+
set_drag_over(self, False)
|
|
574
|
+
event.accept()
|
|
575
|
+
|
|
576
|
+
def dropEvent(self, event: QDropEvent) -> None:
|
|
577
|
+
set_drag_over(self, False)
|
|
578
|
+
paths = dropped_paths(event)
|
|
579
|
+
if paths:
|
|
580
|
+
self.paths_dropped.emit(paths)
|
|
581
|
+
event.acceptProposedAction()
|
|
582
|
+
else:
|
|
583
|
+
event.ignore()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Structured conversion log records kept in memory by the GUI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
LogLevel = Literal["INFO", "WARNING", "ERROR"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class LogEntry:
|
|
13
|
+
timestamp: str
|
|
14
|
+
level: LogLevel
|
|
15
|
+
event: str
|
|
16
|
+
message: str
|
|
17
|
+
context: dict[str, object] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def create(
|
|
21
|
+
cls,
|
|
22
|
+
level: LogLevel,
|
|
23
|
+
event: str,
|
|
24
|
+
message: str,
|
|
25
|
+
context: dict[str, object] | None = None,
|
|
26
|
+
) -> "LogEntry":
|
|
27
|
+
return cls(
|
|
28
|
+
datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
29
|
+
level,
|
|
30
|
+
event,
|
|
31
|
+
message,
|
|
32
|
+
context or {},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def display_text(self) -> str:
|
|
36
|
+
input_name = self.context.get("input")
|
|
37
|
+
input_label = f" [{input_name}]" if isinstance(input_name, str) else ""
|
|
38
|
+
return f"{self.timestamp} {self.level} [{self.event}]{input_label} {self.message}"
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> str:
|
|
41
|
+
return json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"timestamp": self.timestamp,
|
|
44
|
+
"level": self.level,
|
|
45
|
+
"event": self.event,
|
|
46
|
+
"message": self.message,
|
|
47
|
+
"context": self.context,
|
|
48
|
+
},
|
|
49
|
+
ensure_ascii=False,
|
|
50
|
+
sort_keys=True,
|
|
51
|
+
)
|