/
KernCrasher.py
executable file
·406 lines (342 loc) · 19.5 KB
/
KernCrasher.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# MenuTitle: KernCrasher
# -*- coding: utf-8 -*-
from __future__ import division, print_function, unicode_literals
__doc__ = """
Opens a new tab with Kerning Combos that crash in the current fontmaster.
"""
import vanilla
from timeit import default_timer as timer
from Foundation import NSNotFound
from kernanalysis import intervalList, categoryList, sortedIntervalsFromString, effectiveKerning, minDistanceBetweenTwoLayers
from GlyphsApp import Glyphs, Message
from mekkablue import mekkaObject, caseDict
class KernCrasher(mekkaObject):
prefDict = {
# "prefName": defaultValue,
"minDistance": 0,
"popupScript": "latin",
"popupSpeed": 0,
"popupLeftCat": 0,
"popupRightCat": 0,
"excludeSuffixes": ".locl, .alt, .sups, .sinf, .tf, .tosf, Ldot, ldot, Jacute, jacute",
"excludeNonExporting": 1,
"reportCrashesInMacroWindow": 0,
"ignoreIntervals": "",
"pathGlyphsOnly": 0,
"reuseCurrentTab": 1,
"limitRightSuffixes": "",
"limitLeftSuffixes": "",
"directionSensitive": "",
}
def __init__(self):
# Window 'self.w':
windowWidth = 410
windowHeight = 370
windowWidthResize = 800 # user can resize width by this value
windowHeightResize = 0 # user can resize height by this value
self.w = vanilla.FloatingWindow(
(windowWidth, windowHeight), # default window size
"KernCrasher", # window title
minSize=(windowWidth, windowHeight), # minimum size (for resizing)
maxSize=(windowWidth + windowWidthResize, windowHeight + windowHeightResize), # maximum size (for resizing)
autosaveName=self.domain("mainwindow") # stores last window position and size
)
# UI elements:
linePos, inset, lineHeight = 12, 15, 22
self.w.descriptionText = vanilla.TextBox((inset, linePos + 2, -inset, 14), u"Open tab with kern collisions in current master:", sizeStyle='small', selectable=True)
linePos += lineHeight
self.w.textScript = vanilla.TextBox((inset, linePos + 2, 42, 14), u"Script:", sizeStyle='small', selectable=True)
self.w.popupScript = vanilla.ComboBox((inset + 42, linePos - 1, 110, 18), ("latin", "cyrillic", "greek"), callback=self.SavePreferences, sizeStyle='small')
self.w.popupScript.getNSComboBox().setToolTip_("Limits the kerning pairs only to glyphs of this script, and those of no script.")
self.w.updateScriptsButton = vanilla.SquareButton((inset + 160, linePos, 20, 18), "↺", sizeStyle='small', callback=self.update)
self.w.updateScriptsButton.getNSButton().setToolTip_("Scans the frontost font for the scripts of all its glyphs (Latin, Greek, Cyrillic, Hebrew, Arabic, Thai, ...) and lists only those in the combo box.")
self.w.textDistance = vanilla.TextBox((inset + 200, linePos + 2, 100, 14), "Min distance:", sizeStyle='small')
self.w.minDistance = vanilla.EditText((inset + 280, linePos - 1, -15, 19), "10", sizeStyle='small')
tooltipText = "Minimum distance required between glyphs in any given pairing with the current setup. Measured in units."
self.w.textDistance.getNSTextField().setToolTip_(tooltipText)
self.w.minDistance.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.textSpeed = vanilla.TextBox((inset, linePos + 2, 42, 14), u"Speed:", sizeStyle='small', selectable=True)
self.w.popupSpeed = vanilla.PopUpButton((inset + 42, linePos, 110, 17), ("very slow", "slow", "medium", "fast", "very fast"), callback=self.popupSpeedAction, sizeStyle='small')
intervalIndex = self.pref("popupSpeed")
if intervalIndex is None:
intervalIndex = 0
self.w.text_speedExplanation = vanilla.TextBox((inset + 160, linePos + 2, -inset, 14), "Measuring every %i units." % intervalList[intervalIndex], sizeStyle='small')
tooltipText = "The distances within every glyph pair will be measured from top to bottom. This setting determines how many measurements are taken. Higher speed means less measurements and thus less accuracy. Many measurements are more accurate, but also slow down the script significantly."
self.w.textSpeed.getNSTextField().setToolTip_(tooltipText)
self.w.popupSpeed.getNSPopUpButton().setToolTip_(tooltipText)
self.w.text_speedExplanation.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_popupLeftCat = vanilla.TextBox((inset, linePos + 2, 90, 14), "Left Category:", sizeStyle='small')
self.w.popupLeftCat = vanilla.PopUpButton((inset + 90, linePos, -inset, 17), categoryList, callback=self.SavePreferences, sizeStyle='small')
tooltipText = "The category for the glyph on the LEFT side of the kerning pair. In ‘AV’, that would be ‘A’."
self.w.text_popupLeftCat.getNSTextField().setToolTip_(tooltipText)
self.w.popupLeftCat.getNSPopUpButton().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_popupRightCat = vanilla.TextBox((inset, linePos + 2, 90, 14), "Right Category:", sizeStyle='small')
self.w.popupRightCat = vanilla.PopUpButton((inset + 90, linePos, -inset, 17), categoryList, callback=self.SavePreferences, sizeStyle='small')
tooltipText = "The category for the glyph on the RIGHT side of the kerning pair. In ‘AV’, that would be ‘V’."
self.w.text_popupRightCat.getNSTextField().setToolTip_(tooltipText)
self.w.popupRightCat.getNSPopUpButton().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_limitLeftSuffixes = vanilla.TextBox((inset, linePos + 2, 160, 14), "Left glyph must contain:", sizeStyle='small')
self.w.limitLeftSuffixes = vanilla.EditText((inset + 150, linePos, -inset, 19), "", callback=self.SavePreferences, sizeStyle='small')
self.w.limitLeftSuffixes.getNSTextField().setPlaceholderString_("tilde, macron, dieresis, circumflex, caron")
tooltipText = "Only glyphs with these parts in their glyph names will be considered for the LEFT side. Comma-separated list. Usually not necessary, but useful for tracking down problems with wide diacritics."
self.w.text_limitLeftSuffixes.getNSTextField().setToolTip_(tooltipText)
self.w.limitLeftSuffixes.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_limitRightSuffixes = vanilla.TextBox((inset, linePos + 2, 160, 14), "Right glyph must contain:", sizeStyle='small')
self.w.limitRightSuffixes = vanilla.EditText((inset + 150, linePos, -inset, 19), "", callback=self.SavePreferences, sizeStyle='small')
self.w.limitRightSuffixes.getNSTextField().setPlaceholderString_("tilde, macron, dieresis, circumflex, caron")
tooltipText = "Only glyphs with these parts in their glyph names will be considered for the RIGHT side. Comma-separated list. Usually not necessary, but useful for tracking down problems with wide diacritics."
self.w.text_limitRightSuffixes.getNSTextField().setToolTip_(tooltipText)
self.w.limitRightSuffixes.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_excludeSuffixes = vanilla.TextBox((inset, linePos + 2, 160, 14), "Exclude glyphs containing:", sizeStyle='small')
self.w.excludeSuffixes = vanilla.EditText((inset + 150, linePos, -inset, 19), ".locl, .alt, .sups, .sinf, .tf, .tosf, Ldot, ldot, Jacute, jacute", callback=self.SavePreferences, sizeStyle='small')
tooltipText = "Glyphs with these particles in their glyph names will be ignored. Comma-separated list. Useful for excluding impossible pairings, like ldot (can only appear before l) and jacute (can only appear after iacute), or OpenType variants."
self.w.text_excludeSuffixes.getNSTextField().setToolTip_(tooltipText)
self.w.excludeSuffixes.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.text_ignoreIntervals = vanilla.TextBox((inset, linePos + 2, 160, 14), "Ignore height intervals:", sizeStyle='small')
self.w.ignoreIntervals = vanilla.EditText((inset + 150, linePos, -inset, 19), "", callback=self.SavePreferences, sizeStyle='small')
self.w.ignoreIntervals.getNSTextField().setPlaceholderString_("200:300, 400:370, -200:-150")
tooltipText = "Will not measure between the specified heights. Useful for excluding connectors of connecting script typefaces or rekha lines like in Devanagari, where an overlap is intended by design. Specify two numbers separated by a colon, e.g., 200:300, and it will only measure until y=200 and start measuring again at y=300, skipping everything in between. If you specify multiple ranges, separate them with commas. You can also specify glyph names, e.g. x:o (from bottom of x to top of o)."
self.w.text_ignoreIntervals.getNSTextField().setToolTip_(tooltipText)
self.w.ignoreIntervals.getNSTextField().setToolTip_(tooltipText)
linePos += lineHeight
self.w.pathGlyphsOnly = vanilla.CheckBox((inset, linePos - 1, -inset, 20), u"Limit to glyphs containing paths (i.e., exclude composites)", value=False, callback=self.SavePreferences, sizeStyle='small')
self.w.pathGlyphsOnly.getNSButton().setToolTip_("If enabled, will ignore glyphs that do not have paths. Useful for focusing on the base shapes (before you deal with the specific problems of composite diacritics).")
linePos += lineHeight
self.w.excludeNonExporting = vanilla.CheckBox((inset, linePos, -inset, 20), "Exclude non-exporting glyphs", value=True, sizeStyle='small', callback=self.SavePreferences)
self.w.excludeNonExporting.getNSButton().setToolTip_("If enabled, will ignore glyphs that are set to not export. Recommended, otherwise you may get a lot of false positives.")
linePos += lineHeight
self.w.directionSensitive = vanilla.CheckBox((inset, linePos, -inset, 20), "Auto-detect writing direction (LTR vs. RTL)", value=True, sizeStyle='small', callback=self.SavePreferences)
self.w.directionSensitive.getNSButton().setToolTip_("If enabled, will determine writing direction based on settings in current tab. If disabled, LTR will be used")
linePos += lineHeight
self.w.reportCrashesInMacroWindow = vanilla.CheckBox((inset, linePos, -inset, 20), "Verbose report in Macro Window", value=False, sizeStyle='small', callback=self.SavePreferences)
self.w.reportCrashesInMacroWindow.getNSButton().setToolTip_("Will output a detailed report of the kern crashing in Window > Macro Panel. Will slow down the script a bit. Usually not necessary, but can be useful for checking if a certain pairing has been taken care of or not.")
self.w.reuseCurrentTab = vanilla.CheckBox((inset + 240, linePos, -inset, 20), u"Reuse current tab", value=True, callback=self.SavePreferences, sizeStyle='small')
self.w.reuseCurrentTab.getNSButton().setToolTip_("If enabled, will not open a new tab with newly added kern pairs, but reuse the current Edit tab. Will open an Edit tab if none is open.")
linePos += lineHeight
# Percentage:
self.w.bar = vanilla.ProgressBar((inset, linePos, -inset, 16))
# self.w.percentage = vanilla.TextBox((15 - 1, -30, -100 - 15, -15), "", sizeStyle='small')
# Buttons:
self.w.nextButton = vanilla.Button((-inset - 210, -20 - inset, -inset - 100, -inset), u"Next Master", callback=self.masterSwitch)
# Run Button:
self.w.runButton = vanilla.Button((-90 - inset, -20 - inset, -inset, -inset), "Open Tab", callback=self.KernCrasherMain)
self.w.setDefaultButton(self.w.runButton)
# Load Settings:
self.LoadPreferences()
# Open window and focus on it:
self.w.open()
self.w.makeKey()
def update(self, sender=None):
if sender == self.w.updateScriptsButton and Glyphs.font:
scriptList = [g.script for g in Glyphs.font.glyphs if g.script]
if scriptList:
updatedScriptList = list(set(scriptList))
self.w.popupScript.setItems(updatedScriptList)
self.w.popupScript.set(updatedScriptList[0])
def popupSpeedAction(self, sender):
self.SavePreferences()
intervalIndex = self.pref("popupSpeed")
if intervalIndex is None:
intervalIndex = 0
self.w.text_speedExplanation.set("Measuring every %i units." % intervalList[intervalIndex])
def nameUntilFirstPeriod(self, glyphName):
if "." not in glyphName:
return glyphName
else:
offset = glyphName.find(".")
return glyphName[:offset]
def listOfNamesForCategories(self, thisFont, requiredCategory, requiredSubCategory, requiredScript, excludedGlyphNameParts, excludeNonExporting, pathGlyphsOnly, mustContain):
nameList = []
for thisGlyph in thisFont.glyphs:
thisScript = thisGlyph.script
glyphName = thisGlyph.name
nameIsOK = True
if nameIsOK and excludedGlyphNameParts:
for thisNamePart in excludedGlyphNameParts:
nameIsOK = nameIsOK and not (thisNamePart in glyphName)
if nameIsOK and mustContain:
nameIsOK = False
for thisNamePart in mustContain:
if thisNamePart in glyphName:
nameIsOK = True
if nameIsOK and (thisGlyph.export or not excludeNonExporting):
if thisScript is None or thisScript == requiredScript:
if thisGlyph.category == requiredCategory:
if requiredSubCategory:
if Glyphs.versionNumber >= 3 and requiredSubCategory in caseDict:
requiredCase = caseDict[requiredSubCategory]
if thisGlyph.case == requiredCase:
if (not pathGlyphsOnly) or thisGlyph.layers[0].paths:
nameList.append(glyphName)
else:
if thisGlyph.subCategory == requiredSubCategory or (requiredSubCategory == "Other" and thisGlyph.subCategory is None):
if (not pathGlyphsOnly) or thisGlyph.layers[0].paths:
nameList.append(glyphName)
else:
if (not pathGlyphsOnly) or thisGlyph.layers[0].paths:
nameList.append(glyphName)
return nameList
def masterSwitch(self, sender=None):
if sender is self.w.nextButton:
Glyphs.font.masterIndex += 1
def splitString(self, string, delimiter=":", minimum=2):
# split string into a list:
returnList = string.split(delimiter)
# remove trailing spaces:
for i in range(len(returnList)):
returnList[i] = returnList[i].strip()
# if necessary fill up with None:
while len(returnList) < minimum:
returnList.append(None)
if returnList == [""]:
return None
return returnList
def measureLayerAtHeightFromLeftOrRight(self, thisLayer, height, leftSide=True):
try:
if leftSide:
measurement = thisLayer.lsbAtHeight_(height)
else:
measurement = thisLayer.rsbAtHeight_(height)
if measurement < NSNotFound:
return measurement
else:
return None
except Exception as e:
raise e
return None
def queryPrefs(self):
script = self.pref("popupScript")
firstCategory, firstSubCategory = self.splitString(self.w.popupLeftCat.getItems()[self.pref("popupLeftCat")])
secondCategory, secondSubCategory = self.splitString(self.w.popupRightCat.getItems()[self.pref("popupRightCat")])
return script, firstCategory, firstSubCategory, secondCategory, secondSubCategory
def KernCrasherMain(self, sender):
try:
# update settings to the latest user input:
self.SavePreferences()
# query frontmost fontmaster:
thisFont = Glyphs.font
thisFontMaster = thisFont.selectedFontMaster
thisFontMasterID = thisFontMaster.id
# reset progress bar:
self.w.bar.set(0)
# start taking time:
start = timer()
# start reporting to macro window:
if self.pref("reportCrashesInMacroWindow"):
Glyphs.clearLog()
print("KernCrasher Report for %s, master %s:\n" % (thisFont.familyName, thisFontMaster.name))
# query user input:
script, firstCategory, firstSubCategory, secondCategory, secondSubCategory = self.queryPrefs()
step = intervalList[self.pref("popupSpeed")]
excludedGlyphNameParts = self.splitString(self.pref("excludeSuffixes"), delimiter=",", minimum=0)
excludeNonExporting = self.prefBool("excludeNonExporting")
pathGlyphsOnly = self.prefBool("pathGlyphsOnly")
limitRightSuffixes = self.splitString(self.pref("limitRightSuffixes"), delimiter=",", minimum=0)
limitLeftSuffixes = self.splitString(self.pref("limitLeftSuffixes"), delimiter=",", minimum=0)
minDistance = 0.0
ignoreIntervals = sortedIntervalsFromString(self.pref("ignoreIntervals"), thisFont, thisFontMasterID)
try:
minDistance = self.prefFloat("minDistance")
except Exception as e:
print("Warning: Could not read min distance entry. Will default to 0.\n%s" % e)
import traceback
print(traceback.format_exc())
print()
# save prefs
self.SavePreferences()
# get list of glyph names:
firstList = self.listOfNamesForCategories(
thisFont, firstCategory, firstSubCategory, script, excludedGlyphNameParts, excludeNonExporting, pathGlyphsOnly, limitLeftSuffixes
)
secondList = self.listOfNamesForCategories(
thisFont, secondCategory, secondSubCategory, script, excludedGlyphNameParts, excludeNonExporting, pathGlyphsOnly, limitRightSuffixes
)
directionSensitive = False
if self.w.directionSensitive.get() == 1:
directionSensitive = True
if not firstList or not secondList:
Message(
title="Error: could not find any pairs",
message="The criteria for glyph selection are too strict. With the current settings, there are %i glyphs for the left side in the current font, and %i glyphs for the right side."
% (
len(firstList),
len(secondList),
),
OKButton=None,
)
if self.pref("reportCrashesInMacroWindow"):
print("Minimum Distance: %i\n" % minDistance)
print("Left glyphs:\n%s\n" % ", ".join(firstList))
print("Right glyphs:\n%s\n" % ", ".join(secondList))
tabString = "\n"
crashCount = 0
numOfGlyphs = len(firstList)
for index in range(numOfGlyphs):
# update progress bar:
self.w.bar.set(int(100 * (float(index) / numOfGlyphs)))
# determine left glyph:
firstGlyphName = firstList[index]
leftLayer = thisFont.glyphs[firstGlyphName].layers[thisFontMasterID].copyDecomposedLayer()
leftLayer.decomposeSmartOutlines()
# cycle through right glyphs:
for secondGlyphName in secondList:
rightLayer = thisFont.glyphs[secondGlyphName].layers[thisFontMasterID].copyDecomposedLayer()
rightLayer.decomposeSmartOutlines()
kerning = effectiveKerning(firstGlyphName, secondGlyphName, thisFont, thisFontMasterID, directionSensitive)
distanceBetweenShapes = minDistanceBetweenTwoLayers(leftLayer, rightLayer, interval=step, kerning=kerning, report=False, ignoreIntervals=ignoreIntervals)
if distanceBetweenShapes is not None and distanceBetweenShapes < minDistance:
crashCount += 1
tabString += "/%s/%s/space" % (firstGlyphName, secondGlyphName)
if self.pref("reportCrashesInMacroWindow"):
print("- %s %s: %i" % (firstGlyphName, secondGlyphName, distanceBetweenShapes))
tabString += "\n"
# clean up the tab string:
tabString = tabString[:-6].replace("/space\n", "\n")
while "\n\n" in tabString:
tabString = tabString.replace("\n\n", "\n")
tabString = tabString[1:]
# update progress bar:
self.w.bar.set(100)
# take time:
end = timer()
seconds = end - start
if seconds > 60.0:
timereport = "%i:%02i minutes" % (seconds // 60, seconds % 60)
elif seconds < 1.0:
timereport = "%.2f seconds" % seconds
elif seconds < 20.0:
timereport = "%.1f seconds" % seconds
else:
timereport = "%i seconds" % seconds
# open new Edit tab:
if tabString:
if len(tabString) > 40:
# disable reporters (avoid slowdown)
Glyphs.defaults["visibleReporters"] = None
report = f'{crashCount} kerning crashes have been found. Time elapsed: {timereport}.'
if self.pref("reuseCurrentTab") and thisFont.currentTab:
thisFont.currentTab.text = tabString
else:
thisFont.newTab(tabString)
# or report that nothing was found:
else:
report = 'No collisions found. Time elapsed: %s. Congrats!' % timereport
# Notification:
# notificationTitle = "KernCrasher: %s (%s)" % (thisFont.familyName, thisFontMaster.name)
# Glyphs.showNotification(notificationTitle, report)
# Report in Macro Window:
if self.pref("reportCrashesInMacroWindow"):
print(report)
Glyphs.showMacroWindow()
except Exception as e:
print("KernCrasher Error: %s" % e)
import traceback
print(traceback.format_exc())
KernCrasher()