Last active
May 7, 2026 18:11
-
-
Save okay-type/97c9e1c9071c16327cf0aaffa5caef3a to your computer and use it in GitHub Desktop.
FeaturePreview, but for SpaceCenter and with feaPyFoFum support
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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