Skip to content

Instantly share code, notes, and snippets.

@okay-type
Last active May 7, 2026 18:11
Show Gist options
  • Select an option

  • Save okay-type/97c9e1c9071c16327cf0aaffa5caef3a to your computer and use it in GitHub Desktop.

Select an option

Save okay-type/97c9e1c9071c16327cf0aaffa5caef3a to your computer and use it in GitHub Desktop.
FeaturePreview, but for SpaceCenter and with feaPyFoFum support
# menuTitle : Feature Faker
# shortCut : command+arrowright
from vanilla import *
import AppKit
import os
from compositor.textUtilities import convertCase
from defconAppKit.windows.baseWindow import BaseWindowController
from defconAppKit.controls.openTypeControlsView import OpenTypeControlsView
from defconAppKit.controls.glyphSequenceEditText import GlyphSequenceEditText
from defconAppKit.controls.glyphLineView import GlyphLineView
from ufo2fdk.makeotfParts import forceAbsoluteIncludesInFeatures, extractFeaturesAndTables
from ufo2ft.featureWriters.kernFeatureWriter import KernFeatureWriter, ast
from ufo2ft.util import makeOfficialGlyphOrder
import uharfbuzz as hb
from fontTools import unicodedata
from fontTools.feaLib.parser import Parser as FeatureParser
from fontTools.fontBuilder import FontBuilder
import io
from mojo.UI import CurrentSpaceCenter, OpenSpaceCenter
from mojo.subscriber import Subscriber, WindowController
from mojo.subscriber import registerRoboFontSubscriber, unregisterRoboFontSubscriber
from mojo.subscriber import roboFontSubscriberEventRegistry, registerSubscriberEvent, listRegisteredSubscribers
from mojo.events import postEvent
from mojo.extensions import getExtensionDefault, setExtensionDefault
from feaPyFoFum import compileFeatures
import re
'''
what is this:
use feature preview's guts to preview opentype features in a spacecenter window
additionally, there's an option to use feaPyFoFum for compiling opentype
how to use:
run to initialize
run while running to update the preview text
use command+arrowright as a shortcut to run
things to do:
wire the "Update Features" button to change enabled state when:
disabled after updating
enabled when font feature file has changed
enabled when font glyph set has changed
add a way to toggle between original input string and opentype processed string
add a ui indicator that opentype sub is being previewed
make the ui save and load opentype settings
add a ui for when opentype fails to compile
make other people friendlier trigger, "shortCut : command+arrowright" is brittle and annoying
should this be a floating window or attached more directly to the spacecenter?
copy preview text as a string of glyph names
notes:
the weird 'all' stuff is from another jackson-specific tool
updates:
2026 05 6=7
fixed bug where spacecenter was using the font copy glyphs, not the live glyphs
this probably broke gpos previews for now
2026 05 6
fixed feaPyFoFum bugs
compiles a copy of the ufo so it doesn't trigger an unsaved alert
fixed a window size bug
'''
all_preference_key = AllFonts.ALL.prefKey
class GlyphRecord(object):
def __init__(self, glyph=None, xPlacement=0, yPlacement=0, xAdvance=0, yAdvance=0, alternates=None):
self.glyph = glyph
self.advanceWidth = 0
self.advanceHeight = 0
if glyph is not None:
self.advanceWidth = glyph.width
self.advanceHeight = glyph.height
self.xPlacement = xPlacement
self.yPlacement = yPlacement
self.xAdvance = xAdvance - self.advanceWidth
self.yAdvance = yAdvance - self.advanceHeight
if alternates is None:
alternates = []
self.alternates = alternates
class Table(object):
def wrapValue(self, attribute, value):
def callback():
return value
setattr(self, attribute, callback)
class FeatureFont(object):
def __init__(self, font, useFeaPyFoFum=True):
# copy font
self.font = font.copy()
self.font_path = font.path
self.originalFeatures = self.font.features.text
try:
if useFeaPyFoFum == True:
self.runFeaPyFoFum()
self.buildCMAP()
self.buildBinaryFont()
self.loadFeatures()
self.loadStylisticSetNames()
self.loadAlternates()
self.featureStates = dict()
self.fallbackGlyph = ".notdef"
if useFeaPyFoFum == True:
self.finishFeaPyFoFum()
except Exception as e:
import traceback
traceback.print_exc()
print("!!!\n", "\tCompiling Errors:", str(e), "\n", )
self.finishFeaPyFoFum()
def runFeaPyFoFum(self):
no_include_fea = self.no_include_fea()
if '# >>>' in no_include_fea:
self.font.features.text = compileFeatures(
no_include_fea,
self.font,
compileReferencedFiles=True
)
def no_include_fea(self):
no_include_fea = forceAbsoluteIncludesInFeatures(self.font.features.text, os.path.dirname(self.font_path))
includeRE = re.compile(
"(include\s*\(\s*)"
"([^\)]+)"
"(\s*\))" # this won't actually capture a trailing space
)
replacements = []
for match in reversed(list(includeRE.finditer(no_include_fea))):
start, include_path, close = match.groups()
include_statement = start+include_path+close+';'
include_text = ''
with open(include_path, 'r', encoding='utf-8') as feafile:
include_text = feafile.read()
replacements.append([include_statement, include_text])
for replacement in replacements:
include_statement, include_text = replacement
no_include_fea = no_include_fea.replace(include_statement, include_text)
return no_include_fea
def finishFeaPyFoFum(self):
self.font.features.text = self.originalFeatures
def buildCMAP(self):
font = self.font
self.cmap = {uni: names[0] for uni, names in font.unicodeData.items()}
# add all glyphs, even the un encoded ones at a high unicode...
# see https://github.com/harfbuzz/uharfbuzz/issues/22
unicodeOffset = 0x110000
unencodedCount = 0
for glyph in font:
if not glyph.unicodes:
self.cmap[unicodeOffset + unencodedCount] = glyph.name
unencodedCount += 1
self.reverseCMAP = {name: uni for uni, name in self.cmap.items()}
def buildBinaryFont(self):
font = self.font
glyphOrder = sorted(set(font.glyphOrder) | set(self.cmap.values()))
ff = FontBuilder(int(round(font.info.unitsPerEm)), isTTF=True)
ff.setupGlyphOrder(glyphOrder)
if self.cmap:
ff.setupCharacterMap(self.cmap)
ff.addOpenTypeFeatures(self._getFeatureText(font))
ff.setupHorizontalMetrics({gn: (int(round(font[gn].width)), int(round(font[gn].height))) for gn in glyphOrder})
ff.setupHorizontalHeader(ascent=int(round(font.info.ascender)), descent=int(round(font.info.descender)))
data = io.BytesIO()
ff.save(data)
self.source = ff.font
self._data = data.getvalue()
def loadFeatures(self):
ft = self.source
self.gpos = None
if "GPOS" in ft and ft["GPOS"].table.FeatureList is not None:
self.gpos = Table()
GPOSFeatureTags = set()
GPOSScriptList = set()
GPOSLanguageList = set()
for record in ft["GPOS"].table.FeatureList.FeatureRecord:
GPOSFeatureTags.add(record.FeatureTag)
for record in ft["GPOS"].table.ScriptList.ScriptRecord:
GPOSScriptList.add(record.ScriptTag)
script = record.Script
if script.LangSysCount:
for langSysRecord in script.LangSysRecord:
GPOSLanguageList.add(langSysRecord.LangSysTag)
self.gpos.wrapValue("getFeatureList", list(sorted(GPOSFeatureTags)))
self.gpos.wrapValue("getScriptList", GPOSScriptList)
self.gpos.wrapValue("getLanguageList", GPOSLanguageList)
self.gpos.getFeatureState = self.getFeatureState
self.gpos.setFeatureState = self.setFeatureState
self.gsub = None
if "GSUB" in ft and ft["GSUB"].table.FeatureList is not None:
self.gsub = Table()
GSUBFeatureTags = set()
GSUBScriptList = set()
GSUBLanguageList = set()
for record in ft["GSUB"].table.FeatureList.FeatureRecord:
GSUBFeatureTags.add(record.FeatureTag)
for record in ft["GSUB"].table.ScriptList.ScriptRecord:
GSUBScriptList.add(record.ScriptTag)
script = record.Script
if script.LangSysCount:
for langSysRecord in script.LangSysRecord:
GSUBLanguageList.add(langSysRecord.LangSysTag)
self.gsub.wrapValue("getFeatureList", list(sorted(GSUBFeatureTags)))
self.gsub.wrapValue("getScriptList", GSUBScriptList)
self.gsub.wrapValue("getLanguageList", GSUBLanguageList)
self.gsub.getFeatureState = self.getFeatureState
self.gsub.setFeatureState = self.setFeatureState
def loadStylisticSetNames(self):
ft = self.source
self.stylisticSetNames = dict()
if "GSUB" in ft and ft["GSUB"].table.FeatureList is not None:
# names
nameIDs = {}
if "name" in ft:
for nameRecord in ft["name"].names:
nameID = nameRecord.nameID
platformID = nameRecord.platformID
platEncID = nameRecord.platEncID
langID = nameRecord.langID
nameIDs[nameID, platformID, platEncID, langID] = nameRecord.toUnicode()
for record in ft["GSUB"].table.FeatureList.FeatureRecord:
params = record.Feature.FeatureParams
if hasattr(params, "UINameID"):
ssNameID = params.UINameID
namePriority = [(ssNameID, 1, 0, 0), (ssNameID, 1, None, None), (ssNameID, 3, 1, 1033), (ssNameID, 3, None, None)]
ssName = self._skimNameIDs(nameIDs, namePriority)
if ssName:
self.stylisticSetNames[record.FeatureTag] = ssName
def loadAlternates(self):
self.alternates = {}
ft = self.source
if "GSUB" in ft:
lookup = ft["GSUB"].table.LookupList.Lookup
for record in ft["GSUB"].table.FeatureList.FeatureRecord:
if record.FeatureTag == "aalt":
for lookupIndex in record.Feature.LookupListIndex:
for subTable in lookup[lookupIndex].SubTable:
if subTable.LookupType == 1:
for key, value in subTable.mapping.items():
if key not in self.alternates:
self.alternates[key] = set()
self.alternates[key].add(value)
elif subTable.LookupType == 3:
for key, values in subTable.alternates.items():
if key not in self.alternates:
self.alternates[key] = set()
self.alternates[key] |= set(values)
def process(self, stringOrGlyphList, script="latn", langSys=None, rightToLeft=None, case="unchanged", logger=None):
if not stringOrGlyphList:
return []
if isinstance(stringOrGlyphList, str):
stringOrGlyphList = self.stringToGlyphNames(stringOrGlyphList)
if case != "unchanged":
changedStringOrGlyphList = []
for glyphName in stringOrGlyphList:
if glyphName in self.reverseCMAP:
uni = self.reverseCMAP[glyphName]
try:
char = chr(uni)
except Exception:
char = None
if char is not None:
uni = ord(getattr(char, case)())
if uni in self.cmap:
glyphName = self.cmap[uni]
changedStringOrGlyphList.append(glyphName)
stringOrGlyphList = convertCase(case, stringOrGlyphList, self.cmap, self.fallbackGlyph)
for tag in ["init", "medi", "fina"]:
if tag in self.featureStates and not self.featureStates[tag]:
del self.featureStates[tag]
buf = hb.Buffer()
if script and script != "DFLT":
buf.script = script
if langSys is not None:
buf.language = langSys
if rightToLeft is not None:
if rightToLeft:
buf.direction = "rtl"
else:
buf.direction = "ltr"
buf.add_codepoints([self.reverseCMAP[c] for c in stringOrGlyphList if c in self.reverseCMAP])
buf.guess_segment_properties()
face = hb.Face(self._data)
harfbuzzFont = hb.Font(face)
hb.shape(harfbuzzFont, buf, self.featureStates)
infos = buf.glyph_infos
positions = buf.glyph_positions
glyphRecords = []
for info, pos in zip(infos, positions):
index = info.codepoint
glyphName = self.source.getGlyphName(index)
gr = GlyphRecord(
self.font[glyphName],
pos.x_offset,
pos.y_offset,
pos.x_advance,
pos.y_advance,
alternates=sorted(self.alternates.get(glyphName, []))
)
gr.status = 'main' # spacecenter seems to want more info from the glyph records
gr.selected = False # spacecenter seems to want more info from the glyph records
glyphRecords.append(gr)
return glyphRecords
def stringToGlyphNames(self, string):
glyphNames = []
for c in string:
c = ord(c)
v = chr(c)
if v in self.cmap:
glyphNames.append(self.cmap[v])
elif self.fallbackGlyph is not None:
glyphNames.append(self.fallbackGlyph)
return glyphNames
def setFeatureState(self, featureTag, state):
self.featureStates[featureTag] = state
def getFeatureState(self, featureTag):
return self.featureStates.get(featureTag, False)
def getLanguageList(self):
gsub = set()
gpos = set()
if self.gsub is not None:
gsub = self.gsub.getLanguageList()
if self.gpos is not None:
gpos = self.gpos.getLanguageList()
return sorted(gsub | gpos)
def getScriptList(self):
gsub = set()
gpos = set()
if self.gsub is not None:
gsub = self.gsub.getScriptList()
if self.gpos is not None:
gpos = self.gpos.getScriptList()
return sorted(gsub | gpos)
def _getFeatureText(self, font):
if self.font_path is None:
fea = font.features.text
featuretags, _ = extractFeaturesAndTables(fea)
else:
fea = forceAbsoluteIncludesInFeatures(font.features.text, os.path.dirname(self.font_path))
featuretags, _ = extractFeaturesAndTables(fea, scannedFiles=[os.path.join(self.font_path, "features.fea")])
if "kern" not in featuretags:
languageSystems = set()
for glyph in font:
for uni in glyph.unicodes:
scriptTag = unicodedata.script(chr(uni))
languageSystems.add(scriptTag.lower())
languageSystems -= set(["common", "zyyy", "zinh", "zzzz"])
languageSystems = ["DFLT"] + sorted(languageSystems)
data = io.StringIO(fea)
feaParser = FeatureParser(data, set(font.keys()))
feaFile = feaParser.parse()
existingLanguageSystems = set()
DFLTindex = 0
# search for existing language systems
# and keep the index of the DFLT if existing
for index, st in enumerate(feaFile.statements):
if isinstance(st, ast.LanguageSystemStatement):
existingLanguageSystems.add(st.script)
if st.script == "DFLT":
DFLTindex = index + 1
addedStatement = []
for script in reversed(languageSystems):
if script not in existingLanguageSystems:
statement = ast.LanguageSystemStatement(script=script, language="dflt")
addedStatement.append(statement)
feaFile.statements.insert(DFLTindex, statement)
writer = KernFeatureWriter()
def _kernFeatureWriterSetOrderedGlyphSet():
"""Return OrderedDict[glyphName, glyph] sorted by glyphOrder."""
glyphOrder = makeOfficialGlyphOrder(font, font.glyphOrder)
return {glyphName: font[glyphName] for glyphName in glyphOrder}
writer.getOrderedGlyphSet = _kernFeatureWriterSetOrderedGlyphSet
writer.write(font, feaFile)
# clean up
for statement in addedStatement:
feaFile.statements.remove(statement)
def removeScriptlanguage(block):
for statement in list(block.statements):
if hasattr(statement, "statements"):
removeScriptlanguage(statement)
if isinstance(statement, (ast.ScriptStatement, ast.LanguageStatement)):
block.statements.remove(statement)
for block in ast.iterFeatureBlocks(feaFile, tag="kern"):
removeScriptlanguage(block)
fea = feaFile.asFea()
return fea
def _skimNameIDs(self, nameIDs, priority):
for (nameID, platformID, platEncID, langID) in priority:
for (nID, pID, pEID, lID), text in nameIDs.items():
if nID != nameID:
continue
if pID != platformID and platformID is not None:
continue
if pEID != platEncID and platEncID is not None:
continue
if lID != langID and langID is not None:
continue
return text
class FeatureFaker(Subscriber, BaseWindowController):
featureFontClass = FeatureFont
def build(self):
if CurrentFont() is None:
print("An open UFO is needed")
return
self.font = CurrentFont().naked()
self.featureFont = None
self.view_target = self.view_where()
self.w = Window((200, 800), "Feature Faker")
h = 20
y = 5
self.w.updatePreview = Button((5, y, -5, h), "Update Preview", callback=self.updateFeatureFakerText)
y += h + 5
self.w.copyPreview = Button((5, y, -5, h), "Print Preview Text", callback=self.copyFeatureFakerText)
y += h + 5
self.w.updateFeatures = Button((5, y, -5, h), "Update Features", callback=self.updateFeatureFontCallback)
y += h + 5
self.w.useFeaPyFoFum = CheckBox((5, y, -5, h), "Use FeaPyFoFum", value=True)
y += h + 5
self.w.glyphLineControls = OpenTypeControlsView((0, y, 200, -0), self.glyphLineViewControlsCallback)
self.w.bind("close", self.windowClose)
self.setUpBaseWindowBehavior()
self.w.open()
if self.view_target != 'All!':
self.sc = CurrentSpaceCenter()
if self.sc is None and self.font is not None:
self.sc = OpenSpaceCenter(self.font.asFontParts())
self.updateFeatureFontCallback(None)
def windowClose(self, sender):
self.destroyFeatureFont()
unregisterRoboFontSubscriber(FeatureFaker)
def fontClose(self, sender):
self.w.close()
def destroyFeatureFont(self):
if self.featureFont is not None:
self.featureFont = None
def view_where(self):
if AllFonts.ALL.mlv_ui is not None:
return 'All!'
else:
return 'Spacecenter'
def updateFeatureFontCallback(self, sender):
self._compileFeatureFont()
self.updateGlyphLineViewViewControls()
self.updateGlyphLineView()
def _compileFeatureFont(self, showReport=True):
try:
self.featureFont = self.featureFontClass(self.font, self.w.useFeaPyFoFum.get())
except Exception as e:
self.featureFont = None
import traceback
traceback.print_exc()
print("!!!!!\n", "Compiling Errors:", str(e))
def updateGlyphLineView(self):
settings = self.w.glyphLineControls.get()
self.view_target = self.view_where()
if self.view_target != 'All!':
self.sc = CurrentSpaceCenter()
if self.sc is None and self.font is not None:
self.sc = OpenSpaceCenter(self.font.asFontParts())
if self.view_target == 'All!':
mlv_string = getExtensionDefault(all_preference_key + '.mlv_ui' + '.string')
glyphNames = list(mlv_string)
else:
glyphNames = list(self.sc.get())
if self.featureFont is not None:
script = str(settings["script"])
language = str(settings["language"])
rightToLeft = bool(settings["rightToLeft"])
case = str(settings["case"])
for tag in settings["gsub"].keys():
state = settings["gsub"][tag]
self.featureFont.gsub.setFeatureState(str(tag), bool(state))
glyphRecords = self.featureFont.process(glyphNames, script=script, langSys=language, rightToLeft=rightToLeft, case=case)
new_glyphs = [record.glyph.name for record in glyphRecords]
if self.view_target == 'All!':
txt = self.glyphnames_to_text(new_glyphs)
postEvent(all_preference_key + '.mlv' + '.featurefaker_string_change', string=txt)
else:
old_input = self.sc.getRaw()
self.sc.set(new_glyphs)
self.sc.top.glyphLineInput.set(old_input)
def updateFeatureFakerText(self, info=None):
self.updateGlyphLineView()
def glyphnames_to_text(self, list):
names = list
text = ''
for name in names:
if self.font[name].unicode:
text += chr(self.font[name].unicode)
else:
text += '/'+name
return text
def copyFeatureFakerText(self, info=None):
txt = self.glyphnames_to_text([record.glyph.name for record in self.sc.glyphLineView.get()])
print(txt)
def glyphLineViewControlsCallback(self, sender):
self.updateGlyphLineView()
def updateGlyphLineViewViewControls(self):
if self.featureFont is not None:
existingStates = self.w.glyphLineControls.get()
# GSUB
if self.featureFont.gsub is not None:
for tag in self.featureFont.gsub.getFeatureList():
state = existingStates["gsub"].get(tag, False)
self.featureFont.gsub.setFeatureState(tag, state)
# GPOS
if self.featureFont.gpos is not None:
for tag in self.featureFont.gpos.getFeatureList():
state = existingStates["gpos"].get(tag, False)
self.featureFont.gpos.setFeatureState(tag, state)
self.w.glyphLineControls.setFont(self.featureFont)
extension_key = 'com.okay.featurefaker'
if f'{extension_key}.updateFeatureFakerText' not in roboFontSubscriberEventRegistry:
registerSubscriberEvent(
subscriberEventName=f'{extension_key}.updateFeatureFakerText',
methodName='updateFeatureFakerText',
lowLevelEventNames=[f'{extension_key}.updateFeatureFakerText'],
dispatcher='roboFont',
delay=0.05,
)
# if __name__ == "__main__":
registered_subscribers = listRegisteredSubscribers(subscriberClassName='FeatureFaker')
if len(registered_subscribers) > 0:
postEvent(f'{extension_key}.updateFeatureFakerText')
else:
registerRoboFontSubscriber(FeatureFaker)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment