├── .gitignore ├── GlyphNannyMechanicIcon.png ├── build.py ├── build └── Glyph Nanny.roboFontExt │ ├── info.plist │ ├── lib │ ├── glyphNanny │ │ ├── __init__.py │ │ ├── defaults.py │ │ ├── defaultsWindow.py │ │ ├── editorLayers.py │ │ ├── fontTestWindow.py │ │ ├── scripting.py │ │ ├── testTabs.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── contour.py │ │ │ ├── glyph.py │ │ │ ├── glyphInfo.py │ │ │ ├── metrics.py │ │ │ ├── point.py │ │ │ ├── registry.py │ │ │ ├── segment.py │ │ │ ├── tools.py │ │ │ └── wrappers.py │ ├── launch.py │ ├── menu_showFontTester.py │ ├── menu_showPrefs.py │ └── menu_toggleObserverVisibility.py │ └── license ├── icon.ai ├── license.txt ├── readme.md ├── screenshot.png ├── source └── code │ ├── glyphNanny │ ├── __init__.py │ ├── defaults.py │ ├── defaultsWindow.py │ ├── editorLayers.py │ ├── fontTestWindow.py │ ├── scripting.py │ ├── testTabs.py │ └── tests │ │ ├── __init__.py │ │ ├── contour.py │ │ ├── glyph.py │ │ ├── glyphInfo.py │ │ ├── metrics.py │ │ ├── point.py │ │ ├── registry.py │ │ ├── segment.py │ │ ├── tools.py │ │ └── wrappers.py │ ├── launch.py │ ├── menu_showFontTester.py │ ├── menu_showPrefs.py │ └── menu_toggleObserverVisibility.py └── test.ufo ├── fontinfo.plist ├── glyphs.background ├── contents.plist └── layerinfo.plist ├── glyphs ├── A_.glif ├── A_grave.glif ├── B_.glif ├── C_.glif ├── D_.glif ├── E_.alt1.glif ├── E_.alt2.glif ├── E_.alt3.glif ├── E_.glif ├── F_.glif ├── contents.plist └── layerinfo.plist ├── layercontents.plist ├── lib.plist └── metainfo.plist /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | .nova/Artwork 4 | .nova/Configuration.json 5 | .DS_Store -------------------------------------------------------------------------------- /GlyphNannyMechanicIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typesupply/glyph-nanny/b45e80615ab4cc3887654d0d244ea867aed8e10d/GlyphNannyMechanicIcon.png -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | # ----------------- 2 | # Extension Details 3 | # ----------------- 4 | 5 | name = "Glyph Nanny" 6 | version = "2.0.5" 7 | developer = "Type Supply" 8 | developerURL = "http://typesupply.com" 9 | roboFontVersion = "4.0" 10 | pycOnly = False 11 | menuItems = [ 12 | dict( 13 | path="menu_toggleObserverVisibility.py", 14 | preferredName="Show/Hide Live Report", 15 | shortKey="" 16 | ), 17 | dict( 18 | path="menu_showFontTester.py", 19 | preferredName="Show Full Font Report", 20 | shortKey="" 21 | ), 22 | dict( 23 | path="menu_showPrefs.py", 24 | preferredName="Edit Preferences", 25 | shortKey="" 26 | ) 27 | ] 28 | 29 | mainScript = "launch.py" 30 | launchAtStartUp = True 31 | installAfterBuild = True 32 | 33 | # ---------------------- 34 | # Don't edit below here. 35 | # ---------------------- 36 | 37 | from AppKit import * 38 | import os 39 | import shutil 40 | from mojo.extensions import ExtensionBundle 41 | 42 | # Convert short key modifiers. 43 | 44 | modifierMap = { 45 | "command": NSCommandKeyMask, 46 | "control": NSControlKeyMask, 47 | "option": NSAlternateKeyMask, 48 | "shift": NSShiftKeyMask, 49 | "capslock": NSAlphaShiftKeyMask, 50 | } 51 | 52 | for menuItem in menuItems: 53 | shortKey = menuItem.get("shortKey") 54 | if isinstance(shortKey, tuple): 55 | shortKey = list(shortKey) 56 | character = shortKey.pop(0) 57 | converted = [modifierMap.get(modifier) for modifier in shortKey] 58 | menuItem["shortKey"] = tuple(converted + [character]) 59 | 60 | # Make the various paths. 61 | 62 | basePath = os.path.dirname(__file__) 63 | sourcePath = os.path.join(basePath, "source") 64 | libPath = os.path.join(sourcePath, "code") 65 | licensePath = os.path.join(basePath, "license.txt") 66 | requirementsPath = os.path.join(basePath, "requirements.txt") 67 | resourcesPath = os.path.join(sourcePath, "resources") 68 | if not os.path.exists(resourcesPath): 69 | resourcesPath = None 70 | extensionFile = "%s.roboFontExt" % name 71 | buildPath = os.path.join(basePath, "build") 72 | extensionPath = os.path.join(buildPath, extensionFile) 73 | 74 | # Build the extension. 75 | 76 | B = ExtensionBundle() 77 | B.name = name 78 | B.developer = developer 79 | B.developerURL = developerURL 80 | B.version = version 81 | B.launchAtStartUp = launchAtStartUp 82 | B.mainScript = mainScript 83 | docPath = os.path.join(sourcePath, "documentation") 84 | haveDocumentation = False 85 | if os.path.exists(os.path.join(docPath, "index.html")): 86 | haveDocumentation = True 87 | elif os.path.exists(os.path.join(docPath, "index.md")): 88 | haveDocumentation = True 89 | if not haveDocumentation: 90 | docPath = None 91 | B.html = haveDocumentation 92 | B.requiresVersionMajor = roboFontVersion.split(".")[0] 93 | B.requiresVersionMinor = roboFontVersion.split(".")[1] 94 | B.addToMenu = menuItems 95 | with open(licensePath) as license: 96 | B.license = license.read() 97 | if os.path.exists(requirementsPath): 98 | with open(requirementsPath) as requirements: 99 | B.requirements = requirements.read() 100 | print("Building extension...", end=" ") 101 | v = B.save(extensionPath, libFolder=libPath, htmlFolder=docPath, resourcesFolder=resourcesPath) 102 | print("done!") 103 | errors = B.validationErrors() 104 | if errors: 105 | print("Uh oh! There were errors:") 106 | print(errors) 107 | 108 | # Install the extension. 109 | 110 | if installAfterBuild: 111 | print("Installing extension...", end=" ") 112 | installDirectory = os.path.expanduser("~/Library/Application Support/RoboFont/plugins") 113 | installPath = os.path.join(installDirectory, extensionFile) 114 | if os.path.exists(installPath): 115 | shutil.rmtree(installPath) 116 | shutil.copytree(extensionPath, installPath) 117 | print("done!") 118 | print("RoboFont must now be restarted.") 119 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | addToMenu 6 | 7 | 8 | path 9 | menu_toggleObserverVisibility.py 10 | preferredName 11 | Show/Hide Live Report 12 | shortKey 13 | 14 | 15 | 16 | path 17 | menu_showFontTester.py 18 | preferredName 19 | Show Full Font Report 20 | shortKey 21 | 22 | 23 | 24 | path 25 | menu_showPrefs.py 26 | preferredName 27 | Edit Preferences 28 | shortKey 29 | 30 | 31 | 32 | developer 33 | Type Supply 34 | developerURL 35 | http://typesupply.com 36 | html 37 | 38 | launchAtStartUp 39 | 40 | mainScript 41 | launch.py 42 | name 43 | Glyph Nanny 44 | requiresVersionMajor 45 | 4 46 | requiresVersionMinor 47 | 0 48 | timeStamp 49 | 1713442187.053955 50 | version 51 | 2.0.5 52 | 53 | 54 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/__init__.py: -------------------------------------------------------------------------------- 1 | from . import tests 2 | from . import defaults 3 | from .scripting import ( 4 | registeredTests, 5 | testGlyph, 6 | testLayer, 7 | testFont, 8 | formatGlyphReport, 9 | formatLayerReport, 10 | formatFontReport 11 | ) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/defaults.py: -------------------------------------------------------------------------------- 1 | from mojo.extensions import ( 2 | registerExtensionDefaults, 3 | getExtensionDefault, 4 | setExtensionDefault 5 | ) 6 | from .tests.registry import testRegistry 7 | 8 | defaultKeyStub = "com.typesupply.GlyphNanny2." 9 | defaults = { 10 | defaultKeyStub + "displayLiveReport" : True, 11 | defaultKeyStub + "testDuringDrag" : False, 12 | defaultKeyStub + "displayTitles" : True, 13 | defaultKeyStub + "colorInform" : (0, 0, 0.7, 0.3), 14 | defaultKeyStub + "colorReview" : (1, 0.7, 0, 0.7), 15 | defaultKeyStub + "colorRemove" : (1, 0, 0, 0.5), 16 | defaultKeyStub + "colorInsert" : (0, 1, 0, 0.75), 17 | defaultKeyStub + "lineWidthRegular" : 1, 18 | defaultKeyStub + "lineWidthHighlight" : 4, 19 | defaultKeyStub + "textFont" : "system", 20 | defaultKeyStub + "textFontWeight" : "medium", 21 | defaultKeyStub + "textPointSize" : 10, 22 | } 23 | for testIdentifier in testRegistry.keys(): 24 | defaults[defaultKeyStub + "testState." + testIdentifier] = True 25 | 26 | registerExtensionDefaults(defaults) 27 | 28 | # ----- 29 | # Tests 30 | # ----- 31 | 32 | def getTestState(testIdentifier): 33 | return getExtensionDefault(defaultKeyStub + "testState." + testIdentifier) 34 | 35 | def setTestState(testIdentifier, value): 36 | setExtensionDefault(defaultKeyStub + "testState." + testIdentifier, value) 37 | 38 | # ------- 39 | # Display 40 | # ------- 41 | 42 | # Live Report 43 | 44 | def getDisplayLiveReport(): 45 | return getExtensionDefault(defaultKeyStub + "displayLiveReport") 46 | 47 | def setDisplayLiveReport(value): 48 | setExtensionDefault(defaultKeyStub + "displayLiveReport", value) 49 | 50 | # Test During Drag 51 | 52 | def getTestDuringDrag(): 53 | return getExtensionDefault(defaultKeyStub + "testDuringDrag") 54 | 55 | def setTestDuringDrag(value): 56 | setExtensionDefault(defaultKeyStub + "testDuringDrag", value) 57 | 58 | # Titles 59 | 60 | def getDisplayTitles(): 61 | return getExtensionDefault(defaultKeyStub + "displayTitles") 62 | 63 | def setDisplayTitles(value): 64 | setExtensionDefault(defaultKeyStub + "displayTitles", value) 65 | 66 | # ------ 67 | # Colors 68 | # ------ 69 | 70 | # Inform 71 | 72 | def getColorInform(): 73 | return getExtensionDefault(defaultKeyStub + "colorInform") 74 | 75 | def setColorInform(value): 76 | setExtensionDefault(defaultKeyStub + "colorInform", value) 77 | 78 | # Review 79 | 80 | def getColorReview(): 81 | return getExtensionDefault(defaultKeyStub + "colorReview") 82 | 83 | def setColorReview(value): 84 | setExtensionDefault(defaultKeyStub + "colorReview", value) 85 | 86 | # Remove 87 | 88 | def getColorRemove(): 89 | return getExtensionDefault(defaultKeyStub + "colorRemove") 90 | 91 | def setColorRemove(value): 92 | setExtensionDefault(defaultKeyStub + "colorRemove", value) 93 | 94 | # Insert 95 | 96 | def getColorInsert(): 97 | return getExtensionDefault(defaultKeyStub + "colorInsert") 98 | 99 | def setColorInsert(value): 100 | setExtensionDefault(defaultKeyStub + "colorInsert", value) 101 | 102 | # ----------- 103 | # Line Widths 104 | # ----------- 105 | 106 | # Line: Regular 107 | 108 | def getLineWidthRegular(): 109 | return getExtensionDefault(defaultKeyStub + "lineWidthRegular") 110 | 111 | def setLineWidthRegular(value): 112 | setExtensionDefault(defaultKeyStub + "lineWidthRegular", value) 113 | 114 | # Line: Highlight 115 | 116 | def getLineWidthHighlight(): 117 | return getExtensionDefault(defaultKeyStub + "lineWidthHighlight") 118 | 119 | def setLineWidthHighlight(value): 120 | setExtensionDefault(defaultKeyStub + "lineWidthHighlight", value) 121 | 122 | # ---- 123 | # Text 124 | # ---- 125 | 126 | def getTextFont(): 127 | data = dict( 128 | font=getExtensionDefault(defaultKeyStub + "textFont"), 129 | weight=getExtensionDefault(defaultKeyStub + "textFontWeight"), 130 | pointSize=getExtensionDefault(defaultKeyStub + "textPointSize"), 131 | ) 132 | return data 133 | 134 | def setTextFont(data): 135 | font = data.get("font") 136 | if font is not None: 137 | setExtensionDefault(defaultKeyStub + "textFont", font) 138 | weight = data.get("textFontWeight") 139 | if weight is not None: 140 | setExtensionDefault(defaultKeyStub + "textFontWeight", weight) 141 | pointSize = data.get("pointSize") 142 | if pointSize is not None: 143 | setExtensionDefault(defaultKeyStub + "textPointSize", pointSize) 144 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/defaultsWindow.py: -------------------------------------------------------------------------------- 1 | import ezui 2 | from mojo.events import postEvent 3 | from . import defaults 4 | from .testTabs import makeTestsTableDescription 5 | 6 | 7 | class GlyphNannyDefaultsWindow(ezui.WindowController): 8 | 9 | def build(self): 10 | # Live Report 11 | liveReportCheckboxDescription = dict( 12 | identifier="liveReport", 13 | type="Checkbox", 14 | text="Show Live Report", 15 | value=defaults.getDisplayLiveReport() 16 | ) 17 | 18 | # Test During Drag 19 | testDuringDragCheckboxDescription = dict( 20 | identifier="testDuringDrag", 21 | type="Checkbox", 22 | text="Test During Drag", 23 | value=defaults.getTestDuringDrag() 24 | ) 25 | 26 | # Tests 27 | testsTableDescription = makeTestsTableDescription() 28 | 29 | # Colors 30 | informationColorWell = dict( 31 | identifier="informationColor", 32 | type="ColorWell", 33 | width=70, 34 | height=25, 35 | color=tuple(defaults.getColorInform()) 36 | ) 37 | reviewColorWell = dict( 38 | identifier="reviewColor", 39 | type="ColorWell", 40 | width=70, 41 | height=25, 42 | color=tuple(defaults.getColorReview()) 43 | ) 44 | insertColorWell = dict( 45 | identifier="insertColor", 46 | type="ColorWell", 47 | width=70, 48 | height=25, 49 | color=tuple(defaults.getColorInsert()) 50 | ) 51 | removeColorWell = dict( 52 | identifier="removeColor", 53 | type="ColorWell", 54 | width=70, 55 | height=25, 56 | color=tuple(defaults.getColorRemove()) 57 | ) 58 | rowDescriptions = [ 59 | dict( 60 | itemDescriptions=[ 61 | informationColorWell, 62 | dict( 63 | type="Label", 64 | text="Information" 65 | ) 66 | ] 67 | ), 68 | dict( 69 | itemDescriptions=[ 70 | reviewColorWell, 71 | dict( 72 | type="Label", 73 | text="Review Something" 74 | ) 75 | ] 76 | ), 77 | dict( 78 | itemDescriptions=[ 79 | insertColorWell, 80 | dict( 81 | type="Label", 82 | text="Insert Something" 83 | ) 84 | ] 85 | ), 86 | dict( 87 | itemDescriptions=[ 88 | removeColorWell, 89 | dict( 90 | type="Label", 91 | text="Remove Something" 92 | ) 93 | ] 94 | ), 95 | ] 96 | columnDescriptions = [ 97 | dict( 98 | width=70 99 | ), 100 | {} 101 | ] 102 | colorsGridDescription = dict( 103 | identifier="colors", 104 | type="Grid", 105 | rowDescriptions=rowDescriptions, 106 | columnPlacement="leading", 107 | rowPlacement="center" 108 | ) 109 | 110 | # Titles 111 | reportTitlesCheckboxDescription = dict( 112 | identifier="reportTitles", 113 | type="Checkbox", 114 | text="Show Titles", 115 | value=defaults.getDisplayTitles() 116 | ) 117 | 118 | windowContent = dict( 119 | identifier="defaultsStack", 120 | type="VerticalStack", 121 | contents=[ 122 | liveReportCheckboxDescription, 123 | testDuringDragCheckboxDescription, 124 | testsTableDescription, 125 | colorsGridDescription, 126 | reportTitlesCheckboxDescription, 127 | ], 128 | spacing=15 129 | ) 130 | windowDescription = dict( 131 | type="Window", 132 | size=(270, "auto"), 133 | title="Glyph Nanny Preferences", 134 | content=windowContent 135 | ) 136 | self.w = ezui.makeItem( 137 | windowDescription, 138 | controller=self 139 | ) 140 | 141 | def started(self): 142 | self.w.open() 143 | 144 | def defaultsStackCallback(self, sender): 145 | values = sender.get() 146 | defaults.setColorInform(values["informationColor"]) 147 | defaults.setColorReview(values["reviewColor"]) 148 | defaults.setColorInsert(values["insertColor"]) 149 | defaults.setColorRemove(values["removeColor"]) 150 | defaults.setDisplayLiveReport(values["liveReport"]) 151 | defaults.setTestDuringDrag(values["testDuringDrag"]) 152 | defaults.setDisplayTitles(values["reportTitles"]) 153 | for testItem in values["testStates"]: 154 | if isinstance(testItem, ezui.TableGroupRow): 155 | continue 156 | defaults.setTestState( 157 | testItem["identifier"], 158 | testItem["state"] 159 | ) 160 | postEvent( 161 | defaults.defaultKeyStub + ".defaultsChanged" 162 | ) 163 | 164 | haveShownTestDuringDragNote = False 165 | 166 | def testDuringDragCallback(self, sender): 167 | if not self.haveShownTestDuringDragNote: 168 | self.showMessage( 169 | "This change will take effect after RoboFont is restarted.", 170 | "You'll have to restart RoboFont yourself." 171 | ) 172 | self.haveShownTestDuringDragNote = True 173 | stack = self.w.findItem("defaultsStack") 174 | self.defaultsStackCallback(stack) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/fontTestWindow.py: -------------------------------------------------------------------------------- 1 | import os 2 | from vanilla import dialogs 3 | import ezui 4 | from defconAppKit.windows.baseWindow import BaseWindowController 5 | from fontParts.world import CurrentFont 6 | from .testTabs import makeTestsTableDescription 7 | from .scripting import testFont, formatFontReport 8 | 9 | 10 | class GlyphNannyFontTestWindow(ezui.WindowController): 11 | 12 | def build(self): 13 | # Tests 14 | testsTableDescription = makeTestsTableDescription() 15 | 16 | # Overlaps 17 | ignoreOverlapCheckBox = dict( 18 | identifier="ignoreOverlap", 19 | type="Checkbox", 20 | text="Ignore Outline Overlaps" 21 | ) 22 | 23 | # Test 24 | testCurrentFontButton = dict( 25 | identifier="testCurrentFontButton", 26 | type="PushButton", 27 | text="Test Current Font" 28 | ) 29 | 30 | windowContent = dict( 31 | identifier="settingsStack", 32 | type="VerticalStack", 33 | contents=[ 34 | testsTableDescription, 35 | ignoreOverlapCheckBox, 36 | testCurrentFontButton, 37 | ], 38 | spacing=15 39 | ) 40 | windowDescription = dict( 41 | type="Window", 42 | size=(270, "auto"), 43 | title="Glyph Nanny", 44 | content=windowContent 45 | ) 46 | self.w = ezui.makeItem( 47 | windowDescription, 48 | controller=self 49 | ) 50 | 51 | def started(self): 52 | self.w.open() 53 | 54 | def testCurrentFontButtonCallback(self, sender): 55 | font = CurrentFont() 56 | if font is None: 57 | dialogs.message("There is no font to test.", "Open a font and try again.") 58 | return 59 | self._processFont(font) 60 | 61 | def _processFont(self, font): 62 | values = self.w.getItem("settingsStack") 63 | tests = [] 64 | for testItem in self.w.getItem("testStates").get(): 65 | if isinstance(testItem, ezui.TableGroupRow): 66 | continue 67 | identifier = testItem["identifier"] 68 | state = testItem["state"] 69 | if state: 70 | tests.append(identifier) 71 | ignoreOverlap = self.w.getItem("ignoreOverlap").get() 72 | # progressBar = self.startProgress(tickCount=len(font)) 73 | if ignoreOverlap: 74 | fontToTest = font.copy() 75 | for glyph in font: 76 | glyph.removeOverlap() 77 | else: 78 | fontToTest = font 79 | try: 80 | report = testFont( 81 | font, 82 | tests, 83 | # progressBar=progressBar 84 | ) 85 | finally: 86 | pass 87 | # progressBar.close() 88 | text = formatFontReport(report) 89 | FontReportWindow( 90 | font=font, 91 | text=text, 92 | glyphsWithIssues=report.keys() 93 | ) 94 | 95 | 96 | class FontReportWindow(ezui.WindowController): 97 | 98 | def build(self, font=None, text=None, glyphsWithIssues=None): 99 | self.font = font 100 | self.glyphsWithIssues = glyphsWithIssues 101 | title = "Glyph Nanny Report: Unsaved Font" 102 | if font.path is not None: 103 | title = "Glyph Nanny Report: %s" % os.path.basename(font.path) 104 | 105 | textEditorDescription = dict( 106 | type="TextEditor", 107 | value=text, 108 | height=">=150" 109 | ) 110 | markButtonDescription = dict( 111 | identifier="markButton", 112 | type="PushButton", 113 | text="Mark Glyphs" 114 | ) 115 | 116 | windowContent = dict( 117 | type="VerticalStack", 118 | contents=[ 119 | textEditorDescription, 120 | markButtonDescription 121 | ] 122 | ) 123 | windowDescription = dict( 124 | type="Window", 125 | size=(600, "auto"), 126 | minSize=(200, 200), 127 | title=title, 128 | content=windowContent 129 | ) 130 | self.w = ezui.makeItem( 131 | windowDescription, 132 | controller=self 133 | ) 134 | 135 | def started(self): 136 | self.w.open() 137 | 138 | def markButtonCallback(self, sender): 139 | for name in self.font.keys(): 140 | if name in self.glyphsWithIssues: 141 | color = (1, 0, 0, 0.5) 142 | else: 143 | color = None 144 | self.font[name].mark = color 145 | 146 | 147 | if __name__ == "__main__": 148 | GlyphNannyFontTestWindow() 149 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/scripting.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .tests.registry import testRegistry 3 | 4 | def registeredTests(): 5 | registered = {} 6 | for testIdentifier, testData in testRegistry.items(): 7 | registered[testIdentifier] = dict( 8 | level=testData["level"], 9 | title=testData["title"], 10 | description=testData["description"], 11 | representationName=testData["representationName"] 12 | ) 13 | return registered 14 | 15 | def testFont( 16 | font, 17 | tests=None, 18 | ignoreOverlap=False, 19 | progressBar=None 20 | ): 21 | if tests is None: 22 | tests = registeredTests().keys() 23 | layer = font.defaultLayer 24 | return testLayer( 25 | layer, 26 | tests=tests, 27 | ignoreOverlap=ignoreOverlap, 28 | progressBar=progressBar 29 | ) 30 | 31 | def testLayer( 32 | layer, 33 | tests=None, 34 | ignoreOverlap=False, 35 | progressBar=None 36 | ): 37 | if tests is None: 38 | tests = registeredTests().keys() 39 | font = layer.font 40 | if font is not None: 41 | glyphOrder = font.glyphOrder 42 | else: 43 | glyphOrder = sorted(layer.keys()) 44 | report = {} 45 | for name in glyphOrder: 46 | if progressBar is not None: 47 | progressBar.update("Analyzing %s..." % name) 48 | glyph = layer[name] 49 | glyphReport = testGlyph(glyph, tests=tests) 50 | report[name] = glyphReport 51 | return report 52 | 53 | def testGlyph(glyph, tests=None): 54 | if tests is None: 55 | tests = registeredTests().keys() 56 | objectLevels = {} 57 | for testIdentifier in sorted(tests): 58 | testData = testRegistry[testIdentifier] 59 | level = testData["level"] 60 | if level not in objectLevels: 61 | objectLevels[level] = [] 62 | objectLevels[level].append(testIdentifier) 63 | glyphLevelTests = ( 64 | objectLevels.get("glyphInfo", []) 65 | + objectLevels.get("metrics", []) 66 | + objectLevels.get("glyph", []) 67 | ) 68 | contourLevelTests = ( 69 | objectLevels.get("contour", []) 70 | + objectLevels.get("segment", []) 71 | + objectLevels.get("point", []) 72 | ) 73 | stub = "GlyphNanny." 74 | report = {} 75 | for testIdentifier in glyphLevelTests: 76 | report[testIdentifier] = glyph.getRepresentation(stub + testIdentifier) 77 | for contourIndex, contour in enumerate(glyph.contours): 78 | for testIdentifier in contourLevelTests: 79 | key = f"contour{contourIndex}: {testIdentifier}" 80 | report[key] = contour.getRepresentation(stub + testIdentifier) 81 | return report 82 | 83 | # -------------- 84 | # Report Purging 85 | # -------------- 86 | 87 | def purgeGlyphReport(report): 88 | purged = {} 89 | for key, value in report.items(): 90 | if isinstance(value, dict): 91 | value = purgeDict(value) 92 | if not value: 93 | continue 94 | purged[key] = value 95 | return purged 96 | 97 | def purgeDict(d): 98 | purged = {} 99 | for k, v in d.items(): 100 | if not v: 101 | continue 102 | purged[k] = v 103 | return purged 104 | 105 | # ---------- 106 | # Formatting 107 | # ---------- 108 | 109 | def formatFontReport(report): 110 | return formatLayerReport(report) 111 | 112 | def formatLayerReport(report): 113 | lines = [] 114 | for glyphName, glyphReport in report.items(): 115 | glyphReport = formatGlyphReport(glyphReport) 116 | if not glyphReport: 117 | continue 118 | lines.append("# " + glyphName) 119 | lines.append("\n") 120 | lines.append(glyphReport) 121 | lines.append("\n") 122 | return "\n".join(lines).strip() 123 | 124 | contourTitle_RE = re.compile(r"contour([\d])+:") 125 | 126 | def formatGlyphReport(report): 127 | report = purgeGlyphReport(report) 128 | notContours = {} 129 | contours = {} 130 | for key, value in report.items(): 131 | m = contourTitle_RE.match(key) 132 | if m: 133 | contourIndex = m.group(1) 134 | if contourIndex not in contours: 135 | contours[contourIndex] = {} 136 | key = key.split(":", 1)[-1].strip() 137 | contours[contourIndex][key] = value 138 | else: 139 | notContours[key] = value 140 | lines = [] 141 | for key, value in sorted(notContours.items()): 142 | title = testRegistry[key]["title"] 143 | lines.append("## " + title) 144 | lines.append(formatValue(value)) 145 | lines.append("") 146 | for contourIndex, contourReport in sorted(contours.items()): 147 | for key, value in sorted(notContours.items()): 148 | title = testRegistry[key]["title"] 149 | lines.append("## {title}: Contour {contourIndex}".format(title=title, contourIndex=contourIndex)) 150 | lines.append(formatValue(value)) 151 | lines.append("") 152 | return "\n".join(lines).strip() 153 | 154 | def formatValue(value): 155 | if isinstance(value, str): 156 | return value 157 | elif isinstance(value, list): 158 | l = [] 159 | for i in value: 160 | l.append("- " + formatValue(i)) 161 | return "\n".join(l) 162 | elif isinstance(value, dict): 163 | l = [] 164 | for k, v in sorted(value.items()): 165 | l.append("- {key}: {value}".format(key=k, value=format(v))) 166 | return "\n".join(l) 167 | return repr(value) 168 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/testTabs.py: -------------------------------------------------------------------------------- 1 | import ezui 2 | from .tests.registry import testRegistry 3 | from . import defaults 4 | 5 | groups = [ 6 | ("glyphInfo", "Glyph Info Tests"), 7 | ("glyph", "Glyph Tests"), 8 | ("metrics", "Metrics Tests"), 9 | ("contour", "Contour Tests"), 10 | ("segment", "Segment Tests"), 11 | ("point", "Point Tests") 12 | ] 13 | groupTitles = [title for (level, title) in groups] 14 | groupLevels = {} 15 | for testIdentifier, testData in testRegistry.items(): 16 | level = testData["level"] 17 | if level not in groupLevels: 18 | groupLevels[level] = [] 19 | groupLevels[level].append((testData["title"], testIdentifier)) 20 | 21 | def makeTestsTableDescription(): 22 | columnDescriptions = [ 23 | dict( 24 | identifier="state", 25 | cellDescription=dict( 26 | cellType="Checkbox" 27 | ), 28 | editable=True, 29 | width=16 30 | ), 31 | dict( 32 | identifier="title" 33 | ) 34 | ] 35 | tableItems = [] 36 | for i, (groupLevel, groupTests) in enumerate(groupLevels.items()): 37 | groupTitle = groupTitles[i] 38 | tableItems.append( 39 | groupTitle 40 | ) 41 | testIdentifiers = groupLevels[groupLevel] 42 | testIdentifiers.sort() 43 | for testTitle, testIdentifier in testIdentifiers: 44 | value = defaults.getTestState(testIdentifier) 45 | item = dict( 46 | identifier=testIdentifier, 47 | title=testTitle, 48 | state=value 49 | ) 50 | tableItems.append(item) 51 | testsTableDescription = dict( 52 | identifier="testStates", 53 | type="Table", 54 | columnDescriptions=columnDescriptions, 55 | items=tableItems, 56 | allowsGroupRows=True, 57 | showColumnTitles=False, 58 | alternatingRowColors=False, 59 | allowsSelection=False, 60 | allowsSorting=False, 61 | height=250 62 | ) 63 | return testsTableDescription -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Import to trigger the registration 2 | # of the representation factories 3 | from . import glyphInfo 4 | from . import glyph 5 | from . import metrics 6 | from . import contour 7 | from . import segment 8 | from . import point -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/contour.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.transformPen import TransformPen 2 | import defcon 3 | from . import registry 4 | from .wrappers import * 5 | from .tools import getOnCurves 6 | 7 | # Small Contours 8 | 9 | def testForSmallContour(contour): 10 | """ 11 | Contours should not have an area less than or equal to 4 units. 12 | 13 | Data structure: 14 | 15 | bool 16 | """ 17 | contour = wrapContour(contour) 18 | if len(contour) > 1: 19 | bounds = contour.bounds 20 | if bounds: 21 | xMin, yMin, xMax, yMax = bounds 22 | w = xMax - xMin 23 | h = yMin - yMax 24 | area = abs(w * h) 25 | if area <= 4: 26 | return True 27 | return False 28 | 29 | registry.registerTest( 30 | identifier="smallContours", 31 | level="contour", 32 | title="Small Contours", 33 | description="One or more contours are suspiciously small.", 34 | testFunction=testForSmallContour, 35 | defconClass=defcon.Contour, 36 | destructiveNotifications=["Contour.PointsChanged"] 37 | ) 38 | 39 | # Open Contours 40 | 41 | def testForOpenContour(contour): 42 | """ 43 | Contours should be closed. 44 | 45 | Data structure: 46 | 47 | (startPoint, endPoint) 48 | """ 49 | contour = wrapContour(contour) 50 | if contour.open: 51 | start = contour[0].onCurve 52 | start = (start.x, start.y) 53 | end = contour[-1].onCurve 54 | end = (end.x, end.y) 55 | if start != end: 56 | return (start, end) 57 | return None 58 | 59 | registry.registerTest( 60 | identifier="openContour", 61 | level="contour", 62 | title="Open Contours", 63 | description="One or more contours are not properly closed.", 64 | testFunction=testForOpenContour, 65 | defconClass=defcon.Contour, 66 | destructiveNotifications=["Contour.PointsChanged"] 67 | ) 68 | 69 | # Extreme Points 70 | 71 | def testForExtremePoints(contour): 72 | """ 73 | Points should be at the extrema. 74 | 75 | Data structure: 76 | 77 | { 78 | (point), 79 | ... 80 | } 81 | """ 82 | glyph = contour.glyph 83 | contourIndex = glyph.contourIndex(contour) 84 | glyph = wrapGlyph(glyph) 85 | contour = glyph[contourIndex] 86 | copyGlyph = glyph.copy() 87 | copyGlyph.clear() 88 | copyGlyph.appendContour(contour) 89 | copyGlyph.extremePoints() 90 | pointsAtExtrema = set() 91 | testPoints = getOnCurves(copyGlyph[0]) 92 | points = getOnCurves(contour) 93 | if points != testPoints: 94 | pointsAtExtrema = testPoints - points 95 | return pointsAtExtrema 96 | 97 | registry.registerTest( 98 | identifier="extremePoints", 99 | level="contour", 100 | title="Extreme Points", 101 | description="One or more curves need an extreme point.", 102 | testFunction=testForExtremePoints, 103 | defconClass=defcon.Contour, 104 | destructiveNotifications=["Contour.PointsChanged"] 105 | ) 106 | 107 | # Symmetrical Curves 108 | 109 | def testForSlightlyAssymmetricCurves(contour): 110 | """ 111 | Note adjacent curves that are almost symmetrical. 112 | 113 | Data structure: 114 | 115 | [ 116 | ( 117 | (on, off, off, on), 118 | (on, off, off, on) 119 | ), 120 | ... 121 | ] 122 | """ 123 | contour = wrapContour(contour) 124 | slightlyAsymmetricalCurves = [] 125 | # gather pairs of curves that could potentially be related 126 | curvePairs = [] 127 | for index, segment in enumerate(contour): 128 | # curve + h/v line + curve 129 | if segment.type == "line": 130 | prev = index - 1 131 | next = index + 1 132 | if next == len(contour): 133 | next = 0 134 | prevSegment = contour[prev] 135 | nextSegment = contour[next] 136 | if prevSegment.type == "curve" and nextSegment.type == "curve": 137 | px = prevSegment[-1].x 138 | py = prevSegment[-1].y 139 | x = segment[-1].x 140 | y = segment[-1].y 141 | if px == x or py == y: 142 | prevPrevSegment = contour[prev - 1] 143 | c1 = ( 144 | (prevPrevSegment[-1].x, prevPrevSegment[-1].y), 145 | (prevSegment[0].x, prevSegment[0].y), 146 | (prevSegment[1].x, prevSegment[1].y), 147 | (prevSegment[2].x, prevSegment[2].y) 148 | ) 149 | c2 = ( 150 | (segment[-1].x, segment[-1].y), 151 | (nextSegment[0].x, nextSegment[0].y), 152 | (nextSegment[1].x, nextSegment[1].y), 153 | (nextSegment[2].x, nextSegment[2].y) 154 | ) 155 | curvePairs.append((c1, c2)) 156 | curvePairs.append((c2, c1)) 157 | # curve + curve 158 | elif segment.type == "curve": 159 | prev = index - 1 160 | prevSegment = contour[prev] 161 | if prevSegment.type == "curve": 162 | prevPrevSegment = contour[prev - 1] 163 | c1 = ( 164 | (prevPrevSegment[-1].x, prevPrevSegment[-1].y), 165 | (prevSegment[0].x, prevSegment[0].y), 166 | (prevSegment[1].x, prevSegment[1].y), 167 | (prevSegment[2].x, prevSegment[2].y) 168 | ) 169 | c2 = ( 170 | (prevSegment[2].x, prevSegment[2].y), 171 | (segment[0].x, segment[0].y), 172 | (segment[1].x, segment[1].y), 173 | (segment[2].x, segment[2].y) 174 | ) 175 | curvePairs.append((c1, c2)) 176 | curvePairs.append((c2, c1)) 177 | # relativize the pairs and compare 178 | for curve1, curve2 in curvePairs: 179 | curve1Compare = _relativizeCurve(curve1) 180 | curve2Compare = _relativizeCurve(curve2) 181 | if curve1 is None or curve2 is None: 182 | continue 183 | if curve1Compare is None or curve2Compare is None: 184 | continue 185 | flipped = curve1Compare.getFlip(curve2Compare) 186 | if flipped: 187 | slightlyAsymmetricalCurves.append((flipped, curve2)) 188 | # done 189 | if not slightlyAsymmetricalCurves: 190 | return None 191 | return slightlyAsymmetricalCurves 192 | 193 | def _relativizeCurve(curve): 194 | pt0, pt1, pt2, pt3 = curve 195 | # bcps aren't horizontal or vertical 196 | if (pt0[0] != pt1[0]) and (pt0[1] != pt1[1]): 197 | return None 198 | if (pt3[0] != pt2[0]) and (pt3[1] != pt2[1]): 199 | return None 200 | # xxx validate that the bcps aren't backwards here 201 | w = abs(pt3[0] - pt0[0]) 202 | h = abs(pt3[1] - pt0[1]) 203 | bw = None 204 | bh = None 205 | # pt0 -> pt1 is vertical 206 | if pt0[0] == pt1[0]: 207 | bh = abs(pt1[1] - pt0[1]) 208 | # pt0 -> pt1 is horizontal 209 | elif pt0[1] == pt1[1]: 210 | bw = abs(pt1[0] - pt0[0]) 211 | # pt2 -> pt3 is vertical 212 | if pt2[0] == pt3[0]: 213 | bh = abs(pt3[1] - pt2[1]) 214 | # pt2 -> pt3 is horizontal 215 | elif pt2[1] == pt3[1]: 216 | bw = abs(pt3[0] - pt2[0]) 217 | # safety 218 | if bw is None or bh is None: 219 | return None 220 | # done 221 | curve = _CurveFlipper((w, h, bw, bh), curve, 5, 10) 222 | return curve 223 | 224 | 225 | class _CurveFlipper(object): 226 | 227 | def __init__(self, relativeCurve, curve, sizeThreshold, bcpThreshold): 228 | self.w, self.h, self.bcpw, self.bcph = relativeCurve 229 | self.pt0, self.pt1, self.pt2, self.pt3 = curve 230 | self.sizeThreshold = sizeThreshold 231 | self.bcpThreshold = bcpThreshold 232 | 233 | def getFlip(self, other): 234 | ## determine if they need a flip 235 | # curves are exactly the same 236 | if (self.w, self.h, self.bcpw, self.bcph) == (other.w, other.h, other.bcpw, other.bcph): 237 | return None 238 | # width/height are too different 239 | if abs(self.w - other.w) > self.sizeThreshold: 240 | return None 241 | if abs(self.h - other.h) > self.sizeThreshold: 242 | return None 243 | # bcp deltas are too different 244 | if abs(self.bcpw - other.bcpw) > self.bcpThreshold: 245 | return None 246 | if abs(self.bcph - other.bcph) > self.bcpThreshold: 247 | return None 248 | # determine the flip direction 249 | minX = min((self.pt0[0], self.pt3[0])) 250 | otherMinX = min((other.pt0[0], other.pt3[0])) 251 | minY = min((self.pt0[1], self.pt3[1])) 252 | otherMinY = min((other.pt0[1], other.pt3[1])) 253 | direction = None 254 | if abs(minX - otherMinX) <= self.sizeThreshold: 255 | direction = "v" 256 | elif abs(minY - otherMinY) <= self.sizeThreshold: 257 | direction = "h" 258 | if direction is None: 259 | return None 260 | # flip 261 | if direction == "h": 262 | transformation = (-1, 0, 0, 1, 0, 0) 263 | else: 264 | transformation = (1, 0, 0, -1, 0, 0) 265 | self._transformedPoints = [] 266 | transformPen = TransformPen(self, transformation) 267 | transformPen.moveTo(self.pt0) 268 | transformPen.curveTo(self.pt1, self.pt2, self.pt3) 269 | points = self._transformedPoints 270 | del self._transformedPoints 271 | # offset 272 | oX = oY = 0 273 | if direction == "v": 274 | oY = points[-1][1] - other.pt0[1] 275 | else: 276 | oX = points[-1][0] - other.pt0[0] 277 | offset = [] 278 | for x, y in points: 279 | x -= oX 280 | y -= oY 281 | offset.append((x, y)) 282 | points = offset 283 | # done 284 | return points 285 | 286 | def moveTo(self, pt): 287 | self._transformedPoints.append(pt) 288 | 289 | def curveTo(self, pt1, pt2, pt3): 290 | self._transformedPoints.append(pt1) 291 | self._transformedPoints.append(pt2) 292 | self._transformedPoints.append(pt3) 293 | 294 | 295 | registry.registerTest( 296 | identifier="curveSymmetry", 297 | level="contour", 298 | title="Curve Symmetry", 299 | description="One or more curve pairs are slightly asymmetrical.", 300 | testFunction=testForSlightlyAssymmetricCurves, 301 | defconClass=defcon.Contour, 302 | destructiveNotifications=["Contour.PointsChanged"] 303 | ) 304 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/glyph.py: -------------------------------------------------------------------------------- 1 | from fontPens.digestPointPen import DigestPointPen 2 | from fontTools.misc import arrayTools as ftArrayTools 3 | import defcon 4 | from .tools import ( 5 | unwrapPoint, 6 | calculateAngle 7 | ) 8 | from . import registry 9 | from .wrappers import * 10 | 11 | # Stem Consistency 12 | 13 | def testStemWidths(glyph): 14 | """ 15 | Stem widths should be consistent. 16 | 17 | Data structure: 18 | 19 | { 20 | horizontal : [(y1, y2, [x1, x2, ...]), ...] 21 | vertical : [(x1, x2, [y1, y2, ...]), ...] 22 | } 23 | """ 24 | font = wrapFont(glyph.font) 25 | layer = font.getLayer(glyph.layer.name) 26 | glyph = layer[glyph.name] 27 | hProblems = vProblems = None 28 | tolerance = 5 29 | # horizontal 30 | hStems = [_StemWrapper(v, tolerance) for v in font.info.postscriptStemSnapH] 31 | if hStems: 32 | hProblems = _findStemProblems(glyph, hStems, "h") 33 | # vertical 34 | vStems = [_StemWrapper(v, tolerance) for v in font.info.postscriptStemSnapV] 35 | if vStems: 36 | vProblems = _findStemProblems(glyph, vStems, "v") 37 | # report 38 | data = dict(horizontal=hProblems, vertical=vProblems) 39 | return data 40 | 41 | def _findStemProblems(glyph, targetStems, stemDirection): 42 | stems = set() 43 | # h/v abstraction 44 | if stemDirection == "h": 45 | primaryCoordinate = 1 46 | secondaryCoordinate = 0 47 | desiredClockwiseAngle = 0 48 | desiredCounterAngle = 180 49 | else: 50 | primaryCoordinate = 0 51 | secondaryCoordinate = 1 52 | desiredClockwiseAngle = -90 53 | desiredCounterAngle = 90 54 | # structure the contour and line data for efficient processing 55 | contours = { 56 | True : [], 57 | False : [] 58 | } 59 | for contour in glyph: 60 | contourDirection = contour.clockwise 61 | bounds = contour.bounds 62 | lines = {} 63 | # line to 64 | previous = unwrapPoint(contour[-1].onCurve) 65 | for segment in contour: 66 | point = unwrapPoint(segment.onCurve) 67 | if segment.type == "line": 68 | # only process completely horizontal/vertical lines 69 | # that have a length greater than 0 70 | if (previous[primaryCoordinate] == point[primaryCoordinate]) and (previous[secondaryCoordinate] != point[secondaryCoordinate]): 71 | angle = calculateAngle(previous, point) 72 | p = point[primaryCoordinate] 73 | s1 = previous[secondaryCoordinate] 74 | s2 = point[secondaryCoordinate] 75 | s1, s2 = sorted((s1, s2)) 76 | if angle not in lines: 77 | lines[angle] = {} 78 | if p not in lines[angle]: 79 | lines[angle][p] = [] 80 | lines[angle][p].append((s1, s2)) 81 | previous = point 82 | # imply stems from curves by using BCP handles 83 | previous = contour[-1] 84 | for segment in contour: 85 | if segment.type == "curve" and previous.type == "curve": 86 | bcp1 = unwrapPoint(previous[1]) 87 | bcp2 = unwrapPoint(segment[-1]) 88 | if bcp1[primaryCoordinate] == bcp2[primaryCoordinate]: 89 | angle = calculateAngle(bcp1, bcp2) 90 | p = bcp1[primaryCoordinate] 91 | s1 = bcp1[secondaryCoordinate] 92 | s2 = bcp2[secondaryCoordinate] 93 | s1, s2 = sorted((s1, s2)) 94 | if angle not in lines: 95 | lines[angle] = {} 96 | if p not in lines[angle]: 97 | lines[angle][p] = [] 98 | lines[angle][p].append((s1, s2)) 99 | previous = segment 100 | contours[contourDirection].append((bounds, lines)) 101 | # single contours 102 | for clockwise, directionContours in contours.items(): 103 | for contour in directionContours: 104 | bounds, data = contour 105 | for angle1, lineData1 in data.items(): 106 | for angle2, lineData2 in data.items(): 107 | if angle1 == angle2: 108 | continue 109 | if clockwise and angle1 == desiredClockwiseAngle: 110 | continue 111 | if not clockwise and angle1 == desiredCounterAngle: 112 | continue 113 | for p1, lines1 in lineData1.items(): 114 | for p2, lines2 in lineData2.items(): 115 | if p2 <= p1: 116 | continue 117 | for s1a, s1b in lines1: 118 | for s2a, s2b in lines2: 119 | overlap = _linesOverlap(s1a, s1b, s2a, s2b) 120 | if not overlap: 121 | continue 122 | w = p2 - p1 123 | hits = [] 124 | for stem in targetStems: 125 | if w == stem: 126 | d = stem.diff(w) 127 | if d: 128 | hits.append((d, stem.value, (s1a, s1b, s2a, s2b))) 129 | if hits: 130 | hit = min(hits) 131 | w = hit[1] 132 | s = hit[2] 133 | stems.add((p1, p1 + w, s)) 134 | # double contours to test 135 | for clockwiseContour in contours[True]: 136 | clockwiseBounds = clockwiseContour[0] 137 | for counterContour in contours[False]: 138 | counterBounds = counterContour[0] 139 | overlap = ftArrayTools.sectRect(clockwiseBounds, counterBounds)[0] 140 | if not overlap: 141 | continue 142 | clockwiseData = clockwiseContour[1] 143 | counterData = counterContour[1] 144 | for clockwiseAngle, clockwiseLineData in clockwiseContour[1].items(): 145 | for counterAngle, counterLineData in counterContour[1].items(): 146 | if clockwiseAngle == counterAngle: 147 | continue 148 | for clockwiseP, clockwiseLines in clockwiseLineData.items(): 149 | for counterP, counterLines in counterLineData.items(): 150 | for clockwiseSA, clockwiseSB in clockwiseLines: 151 | for counterSA, counterSB in counterLines: 152 | overlap = _linesOverlap(clockwiseSA, clockwiseSB, counterSA, counterSB) 153 | if not overlap: 154 | continue 155 | w = abs(counterP - clockwiseP) 156 | hits = [] 157 | for stem in targetStems: 158 | if w == stem: 159 | d = stem.diff(w) 160 | if d: 161 | hits.append((d, stem.value, (clockwiseSA, clockwiseSB, counterSA, counterSB))) 162 | if hits: 163 | p = min((clockwiseP, counterP)) 164 | hit = min(hits) 165 | w = hit[1] 166 | s = hit[2] 167 | stems.add((p, p + w, s)) 168 | # done 169 | return stems 170 | 171 | class _StemWrapper(object): 172 | 173 | def __init__(self, value, threshold): 174 | self.value = value 175 | self.threshold = threshold 176 | 177 | def __repr__(self): 178 | return "" % (self.value, self.threshold) 179 | 180 | def __eq__(self, other): 181 | d = abs(self.value - other) 182 | return d <= self.threshold 183 | 184 | def diff(self, other): 185 | return abs(self.value - other) 186 | 187 | 188 | def _linesOverlap(a1, a2, b1, b2): 189 | if a1 > b2 or a2 < b1: 190 | return False 191 | return True 192 | 193 | registry.registerTest( 194 | identifier="stemWidths", 195 | level="glyph", 196 | title="Stem Widths", 197 | description="One or more stems do not match the registered values.", 198 | testFunction=testStemWidths, 199 | defconClass=defcon.Glyph, 200 | destructiveNotifications=["Glyph.ContoursChanged"] 201 | ) 202 | 203 | # Duplicate Contours 204 | 205 | def testDuplicateContours(glyph): 206 | """ 207 | Contours shouldn't be duplicated on each other. 208 | 209 | Data structure: 210 | 211 | [ 212 | (contourIndex, bounds), 213 | ... 214 | ] 215 | """ 216 | glyph = wrapGlyph(glyph) 217 | contours = {} 218 | for index, contour in enumerate(glyph): 219 | contour = contour.copy() 220 | if not contour.open: 221 | contour.autoStartSegment() 222 | pen = DigestPointPen() 223 | contour.drawPoints(pen) 224 | digest = pen.getDigest() 225 | if digest not in contours: 226 | contours[digest] = [] 227 | contours[digest].append(index) 228 | duplicateContours = [] 229 | for digest, indexes in contours.items(): 230 | if len(indexes) > 1: 231 | duplicateContours.append((indexes[0], contour.bounds)) 232 | return duplicateContours 233 | 234 | registry.registerTest( 235 | identifier="duplicateContours", 236 | level="glyph", 237 | title="Duplicate Contours", 238 | description="One or more contours are duplicated.", 239 | testFunction=testDuplicateContours, 240 | defconClass=defcon.Glyph, 241 | destructiveNotifications=["Glyph.ContoursChanged"] 242 | ) 243 | 244 | # Duplicate Components 245 | 246 | def testDuplicateComponents(glyph): 247 | """ 248 | Components shouldn't be duplicated on each other. 249 | 250 | [ 251 | (componentIndex, bounds), 252 | ... 253 | ] 254 | 255 | """ 256 | glyph = wrapGlyph(glyph) 257 | duplicateComponents = [] 258 | components = set() 259 | for index, component in enumerate(glyph.components): 260 | key = (component.baseGlyph, component.transformation) 261 | if key in components: 262 | duplicateComponents.append((index, component.bounds)) 263 | components.add(key) 264 | return duplicateComponents 265 | 266 | registry.registerTest( 267 | identifier="duplicateComponents", 268 | level="glyph", 269 | title="Duplicate Components", 270 | description="One or more components are duplicated.", 271 | testFunction=testDuplicateComponents, 272 | defconClass=defcon.Glyph, 273 | destructiveNotifications=["Glyph.ComponentsChanged"] 274 | ) 275 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/glyphInfo.py: -------------------------------------------------------------------------------- 1 | import re 2 | from fontTools.agl import AGL2UV 3 | import defcon 4 | from . import registry 5 | from .wrappers import * 6 | 7 | # Unicode Value 8 | 9 | uniNamePattern = re.compile( 10 | "uni" 11 | "([0-9A-Fa-f]{4})" 12 | "$" 13 | ) 14 | 15 | def testUnicodeValue(glyph): 16 | """ 17 | A Unicode value should appear only once per font. 18 | """ 19 | font = wrapFont(glyph.font) 20 | layer = font.getLayer(glyph.layer.name) 21 | glyph = layer[glyph.name] 22 | report = [] 23 | uni = glyph.unicode 24 | name = glyph.name 25 | # test for uniXXXX name 26 | m = uniNamePattern.match(name) 27 | if m is not None: 28 | uniFromName = m.group(1) 29 | uniFromName = int(uniFromName, 16) 30 | if uni != uniFromName: 31 | report.append("The Unicode value for this glyph does not match its name.") 32 | # test against AGLFN 33 | else: 34 | expectedUni = AGL2UV.get(name) 35 | if expectedUni != uni: 36 | report.append("The Unicode value for this glyph may not be correct.") 37 | # look for duplicates 38 | if uni is not None: 39 | duplicates = [] 40 | for name in sorted(font.keys()): 41 | if name == glyph.name: 42 | continue 43 | other = font[name] 44 | if other.unicode == uni: 45 | duplicates.append(name) 46 | if duplicates: 47 | report.append("The Unicode for this glyph is also used by: %s." % " ".join(duplicates)) 48 | return report 49 | 50 | registry.registerTest( 51 | identifier="unicodeValue", 52 | level="glyphInfo", 53 | title="Unicode Value", 54 | description="Unicode value may have problems.", 55 | testFunction=testUnicodeValue, 56 | defconClass=defcon.Glyph, 57 | destructiveNotifications=["Glyph.UnicodesChanged"] 58 | ) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/metrics.py: -------------------------------------------------------------------------------- 1 | import defcon 2 | from . import registry 3 | from .wrappers import * 4 | 5 | # Ligatures 6 | 7 | def testLigatureMetrics(glyph): 8 | """ 9 | Sometimes ligatures should have the same 10 | metrics as the glyphs they represent. 11 | 12 | Data structure: 13 | 14 | { 15 | leftMessage : string 16 | rightMessage : string 17 | left : number 18 | right : number 19 | width : number 20 | bounds : (xMin, yMin, xMax, yMax) 21 | } 22 | """ 23 | font = wrapFont(glyph.font) 24 | layer = font.getLayer(glyph.layer.name) 25 | glyph = layer[glyph.name] 26 | name = glyph.name 27 | if "_" not in name: 28 | return 29 | base = name 30 | suffix = None 31 | if "." in name: 32 | base, suffix = name.split(".", 1) 33 | # guess at the ligature parts 34 | parts = base.split("_") 35 | leftPart = parts[0] 36 | rightPart = parts[-1] 37 | # try snapping on the suffixes 38 | if suffix: 39 | if leftPart + "." + suffix in font: 40 | leftPart += "." + suffix 41 | if rightPart + "." + suffix in font: 42 | rightPart += "." + suffix 43 | # test 44 | left = glyph.leftMargin 45 | right = glyph.rightMargin 46 | report = dict(leftMessage=None, rightMessage=None, left=left, right=right, width=glyph.width, bounds=glyph.bounds) 47 | if leftPart not in font: 48 | report["leftMessage"] = "Couldn't find the ligature's left component." 49 | else: 50 | expectedLeft = font[leftPart].leftMargin 51 | if left != expectedLeft: 52 | report["leftMessage"] = "Left doesn't match the presumed part %s left" % leftPart 53 | if rightPart not in font: 54 | report["rightMessage"] = "Couldn't find the ligature's right component." 55 | else: 56 | expectedRight = font[rightPart].rightMargin 57 | if right != expectedRight: 58 | report["rightMessage"] = "Right doesn't match the presumed part %s right" % rightPart 59 | if report["leftMessage"] or report["rightMessage"]: 60 | return report 61 | return None 62 | 63 | registry.registerTest( 64 | identifier="ligatureMetrics", 65 | level="metrics", 66 | title="Ligature Side-Bearings", 67 | description="The side-bearings don't match the ligature's presumed part metrics.", 68 | testFunction=testLigatureMetrics, 69 | defconClass=defcon.Glyph, 70 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 71 | ) 72 | 73 | # Components 74 | 75 | def testComponentMetrics(glyph): 76 | """ 77 | If components are present, check their base margins. 78 | 79 | Data structure: 80 | 81 | { 82 | leftMessage : string 83 | rightMessage : string 84 | left : number 85 | right : number 86 | width : number 87 | bounds : (xMin, yMin, xMax, yMax) 88 | } 89 | """ 90 | font = wrapFont(glyph.font) 91 | layer = font.getLayer(glyph.layer.name) 92 | glyph = layer[glyph.name] 93 | components = [c for c in glyph.components if c.baseGlyph in font] 94 | # no components 95 | if len(components) == 0: 96 | return 97 | boxes = [c.bounds for c in components] 98 | # a component has no contours 99 | if None in boxes: 100 | return 101 | report = dict(leftMessage=None, rightMessage=None, left=None, right=None, width=glyph.width, box=glyph.bounds) 102 | problem = False 103 | if len(components) > 1: 104 | # filter marks 105 | nonMarks = [] 106 | markCategories = ("Sk", "Zs", "Lm") 107 | for component in components: 108 | baseGlyphName = component.baseGlyph 109 | category = font.naked().unicodeData.categoryForGlyphName(baseGlyphName, allowPseudoUnicode=True) 110 | if category not in markCategories: 111 | nonMarks.append(component) 112 | if nonMarks: 113 | components = nonMarks 114 | # order the components from left to right based on their boxes 115 | if len(components) > 1: 116 | leftComponent, rightComponent = _getXMinMaxComponents(components) 117 | else: 118 | leftComponent = rightComponent = components[0] 119 | expectedLeft = _getComponentBaseMargins(font, leftComponent)[0] 120 | expectedRight = _getComponentBaseMargins(font, rightComponent)[1] 121 | left = leftComponent.bounds[0] 122 | right = glyph.width - rightComponent.bounds[2] 123 | if left != expectedLeft: 124 | problem = True 125 | report["leftMessage"] = "%s component left does not match %s left" % (leftComponent.baseGlyph, leftComponent.baseGlyph) 126 | report["left"] = left 127 | if right != expectedRight: 128 | problem = True 129 | report["rightMessage"] = "%s component right does not match %s right" % (rightComponent.baseGlyph, rightComponent.baseGlyph) 130 | report["right"] = right 131 | if problem: 132 | return report 133 | 134 | def _getComponentBaseMargins(font, component): 135 | baseGlyphName = component.baseGlyph 136 | baseGlyph = font[baseGlyphName] 137 | scale = component.scale[0] 138 | left = baseGlyph.leftMargin * scale 139 | right = baseGlyph.rightMargin * scale 140 | return left, right 141 | 142 | def _getXMinMaxComponents(components): 143 | minSide = [] 144 | maxSide = [] 145 | for component in components: 146 | xMin, yMin, xMax, yMax = component.bounds 147 | minSide.append((xMin, component)) 148 | maxSide.append((xMax, component)) 149 | o = [ 150 | min(minSide, key=lambda v: (v[0], v[1].baseGlyph))[-1], 151 | max(maxSide, key=lambda v: (v[0], v[1].baseGlyph))[-1], 152 | ] 153 | return o 154 | 155 | registry.registerTest( 156 | identifier="componentMetrics", 157 | level="metrics", 158 | title="Component Side-Bearings", 159 | description="The side-bearings don't match the component's metrics.", 160 | testFunction=testComponentMetrics, 161 | defconClass=defcon.Glyph, 162 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 163 | ) 164 | 165 | # Symmetry 166 | 167 | def testMetricsSymmetry(glyph): 168 | """ 169 | Sometimes glyphs are almost symmetrical, but could be. 170 | 171 | Data structure: 172 | 173 | { 174 | message : string 175 | left : number 176 | right : number 177 | width : number 178 | bounds : (xMin, yMin, xMax, yMax) 179 | } 180 | """ 181 | glyph = wrapGlyph(glyph) 182 | if glyph.leftMargin == None: 183 | return 184 | left = glyph.leftMargin 185 | right = glyph.rightMargin 186 | if left is None or right is None: 187 | return None 188 | diff = int(round(abs(left - right))) 189 | if diff == 1: 190 | message = "The side-bearings are 1 unit from being equal." 191 | else: 192 | message = "The side-bearings are %d units from being equal." % diff 193 | data = dict(left=left, right=right, width=glyph.width, message=message) 194 | if 0 < diff <= 5: 195 | return data 196 | return None 197 | 198 | registry.registerTest( 199 | identifier="metricsSymmetry", 200 | level="metrics", 201 | title="Symmetry", 202 | description="The side-bearings are almost equal.", 203 | testFunction=testMetricsSymmetry, 204 | defconClass=defcon.Glyph, 205 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 206 | ) 207 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/point.py: -------------------------------------------------------------------------------- 1 | from fontTools.misc import bezierTools as ftBezierTools 2 | import defcon 3 | from fontPens.penTools import distance 4 | from . import registry 5 | from .wrappers import * 6 | from .tools import ( 7 | unwrapPoint, 8 | calculateAngle 9 | ) 10 | 11 | # Stray Points 12 | 13 | def testForStrayPoints(contour): 14 | """ 15 | There should be no stray points. 16 | 17 | Data structure: 18 | 19 | (x, y) 20 | """ 21 | contour = wrapContour(contour) 22 | if len(contour) == 1: 23 | pt = contour[0].onCurve 24 | pt = (pt.x, pt.y) 25 | return pt 26 | return None 27 | 28 | registry.registerTest( 29 | identifier="strayPoints", 30 | level="point", 31 | title="Stray Points", 32 | description="One or more stray points are present.", 33 | testFunction=testForStrayPoints, 34 | defconClass=defcon.Contour, 35 | destructiveNotifications=["Contour.PointsChanged"] 36 | ) 37 | 38 | # Unnecessary Points 39 | 40 | def testForUnnecessaryPoints(contour): 41 | """ 42 | Consecutive segments shouldn't have the same angle. 43 | Non-exteme curve points between curve points shouldn't 44 | exist unless they are needed to support the curve. 45 | 46 | Data structure: 47 | 48 | [ 49 | point, 50 | ... 51 | ] 52 | """ 53 | contour = wrapContour(contour) 54 | unnecessaryPoints = _testForUnnecessaryLinePoints(contour) 55 | unnecessaryPoints += _testForUnnecessaryCurvePoints(contour) 56 | return unnecessaryPoints 57 | 58 | def _testForUnnecessaryLinePoints(contour): 59 | unnecessaryPoints = [] 60 | for segmentIndex, segment in enumerate(contour): 61 | if segment.type == "line": 62 | prevSegment = contour[segmentIndex - 1] 63 | nextSegment = contour[(segmentIndex + 1) % len(contour)] 64 | if nextSegment.type == "line": 65 | thisAngle = calculateAngle(prevSegment.onCurve, segment.onCurve) 66 | nextAngle = calculateAngle(segment.onCurve, nextSegment.onCurve) 67 | if thisAngle == nextAngle: 68 | unnecessaryPoints.append(unwrapPoint(segment.onCurve)) 69 | return unnecessaryPoints 70 | 71 | def _testForUnnecessaryCurvePoints(contour): 72 | # Art School Graduate Implementation of Fréchet Distance 73 | # ------------------------------------------------------ 74 | # aka "a probably poor understanding of Fréchet Distance with a 75 | # clumsy implementation, but, hey, we're not doing rocket science." 76 | # 77 | # 1. find the relative T for the first segment in the before. 78 | # 2. split the after segment into two segments. 79 | # 3. divide all for segments into flattened subsegments. 80 | # 4. determine the maximum distance between corresponding points 81 | # in the corresponding subsegments. 82 | # 5. if the distance exceeds the "leash" length, the point 83 | # is necessary. 84 | tolerance = 0.035 85 | unnecessaryPoints = [] 86 | bPoints = list(contour.bPoints) 87 | if len(bPoints) < 3: 88 | return unnecessaryPoints 89 | for i, bPoint in enumerate(bPoints): 90 | if bPoint.type == "curve": 91 | inX, inY = bPoint.bcpIn 92 | outX, outY = bPoint.bcpOut 93 | if all((inX != outX, inX != 0, outX != 0, inY != outY, inY != 0, outY != 0)): 94 | afterContour = contour.copy() 95 | afterContour.removeBPoint(afterContour.bPoints[i], preserveCurve=True) 96 | afterBPoints = afterContour.bPoints 97 | # calculate before length 98 | start = i - 1 99 | middle = i 100 | end = i + 1 101 | if start == -1: 102 | start = len(bPoints) - 1 103 | if end == len(bPoints): 104 | end = 0 105 | start = bPoints[start] 106 | middle = bPoints[middle] 107 | end = bPoints[end] 108 | beforeSegment1 = ( 109 | start.anchor, 110 | _makeBCPAbsolute(start.anchor, start.bcpOut), 111 | _makeBCPAbsolute(middle.anchor, middle.bcpIn), 112 | middle.anchor 113 | ) 114 | beforeSegment2 = ( 115 | middle.anchor, 116 | _makeBCPAbsolute(middle.anchor, middle.bcpOut), 117 | _makeBCPAbsolute(end.anchor, end.bcpIn), 118 | end.anchor 119 | ) 120 | beforeSegment1Length = abs(ftBezierTools.approximateCubicArcLength(*beforeSegment1)) 121 | beforeSegment2Length = abs(ftBezierTools.approximateCubicArcLength(*beforeSegment2)) 122 | beforeLength = beforeSegment1Length + beforeSegment2Length 123 | # calculate after length 124 | start = i - 1 125 | end = i 126 | if start == -1: 127 | start = len(afterBPoints) - 1 128 | if end == len(afterBPoints): 129 | end = 0 130 | start = afterBPoints[start] 131 | end = afterBPoints[end] 132 | afterSegment = ( 133 | start.anchor, 134 | _makeBCPAbsolute(start.anchor, start.bcpOut), 135 | _makeBCPAbsolute(end.anchor, end.bcpIn), 136 | end.anchor 137 | ) 138 | midT = beforeSegment1Length / beforeLength 139 | afterSegment1, afterSegment2 = ftBezierTools.splitCubicAtT(*afterSegment, midT) 140 | subSegmentCount = 10 141 | beforeSegment1Points = _splitSegmentByCount(*beforeSegment1, subSegmentCount=subSegmentCount) 142 | beforeSegment2Points = _splitSegmentByCount(*beforeSegment2, subSegmentCount=subSegmentCount) 143 | afterSegment1Points = _splitSegmentByCount(*afterSegment1, subSegmentCount=subSegmentCount) 144 | afterSegment2Points = _splitSegmentByCount(*afterSegment2, subSegmentCount=subSegmentCount) 145 | beforePoints = beforeSegment1Points + beforeSegment2Points[1:] 146 | afterPoints = afterSegment1Points + afterSegment2Points[1:] 147 | leashLength = beforeLength * tolerance 148 | isUnnecessary = True 149 | for i, b in enumerate(beforePoints): 150 | a = afterPoints[i] 151 | d = abs(distance(a, b)) 152 | if d > leashLength: 153 | isUnnecessary = False 154 | break 155 | if isUnnecessary: 156 | unnecessaryPoints.append(bPoint.anchor) 157 | return unnecessaryPoints 158 | 159 | def _makeBCPAbsolute(anchor, bcp): 160 | x1, y1 = anchor 161 | x2, y2 = bcp 162 | return (x1 + x2, y1 + y2) 163 | 164 | def _splitSegmentByCount(pt1, pt2, pt3, pt4, subSegmentCount=10): 165 | ts = [i / subSegmentCount for i in range(subSegmentCount + 1)] 166 | splits = ftBezierTools.splitCubicAtT(pt1, pt2, pt3, pt4, *ts) 167 | anchors = [] 168 | for segment in splits: 169 | anchors.append(segment[-1]) 170 | return anchors 171 | 172 | registry.registerTest( 173 | identifier="unnecessaryPoints", 174 | level="point", 175 | title="Unnecessary Points", 176 | description="One or more unnecessary points are present.", 177 | testFunction=testForUnnecessaryPoints, 178 | defconClass=defcon.Contour, 179 | destructiveNotifications=["Contour.PointsChanged"] 180 | ) 181 | 182 | # Overlapping Points 183 | 184 | def testForOverlappingPoints(contour): 185 | """ 186 | Consecutive points should not overlap. 187 | 188 | Data structure: 189 | 190 | [ 191 | point, 192 | ... 193 | ] 194 | """ 195 | contour = wrapContour(contour) 196 | overlappingPoints = [] 197 | if len(contour) > 1: 198 | prev = unwrapPoint(contour[-1].onCurve) 199 | for segment in contour: 200 | point = unwrapPoint(segment.onCurve) 201 | if point == prev: 202 | overlappingPoints.append(point) 203 | prev = point 204 | return overlappingPoints 205 | 206 | registry.registerTest( 207 | identifier="overlappingPoints", 208 | level="point", 209 | title="Overlapping Points", 210 | description="Two or more points are overlapping.", 211 | testFunction=testForOverlappingPoints, 212 | defconClass=defcon.Contour, 213 | destructiveNotifications=["Contour.PointsChanged"] 214 | ) 215 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/registry.py: -------------------------------------------------------------------------------- 1 | import defcon 2 | 3 | testRegistry = {} 4 | 5 | fallbackDestructiveNotifications = { 6 | defcon.Glyph : ["Glyph.Changed"], 7 | defcon.Contour : ["Contour.Changed"] 8 | } 9 | 10 | def registerTest( 11 | identifier=None, 12 | level=None, 13 | title=None, 14 | description=None, 15 | testFunction=None, 16 | defconClass=None, 17 | destructiveNotifications=None 18 | ): 19 | representationName = "GlyphNanny." + identifier 20 | if destructiveNotifications is None: 21 | destructiveNotifications = fallbackDestructiveNotifications.get(defconClass, None) 22 | defcon.registerRepresentationFactory( 23 | cls=defconClass, 24 | name=representationName, 25 | factory=testFunction, 26 | destructiveNotifications=destructiveNotifications 27 | ) 28 | testRegistry[identifier] = dict( 29 | level=level, 30 | description=description, 31 | title=title, 32 | representationName=representationName 33 | ) 34 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/segment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Segment level tests. 3 | """ 4 | 5 | from fontTools.misc import bezierTools as ftBezierTools 6 | import defcon 7 | from .tools import ( 8 | roundPoint, 9 | unwrapPoint, 10 | calculateAngle, 11 | calculateAngleOffset, 12 | calculateLineLineIntersection, 13 | calculateLineCurveIntersection, 14 | calculateLineLength, 15 | calculateLineThroughPoint 16 | ) 17 | from . import registry 18 | from .wrappers import * 19 | 20 | # Straight Lines 21 | 22 | def testForAngleNearMiss(contour): 23 | """ 24 | Lines shouldn't be just shy of vertical or horizontal. 25 | 26 | Data structure: 27 | 28 | set( 29 | (pt1, pt2), 30 | ... 31 | ) 32 | 33 | """ 34 | contour = wrapContour(contour) 35 | segments = contour.segments 36 | prev = unwrapPoint(segments[-1].onCurve) 37 | slightlyOffLines = set() 38 | for segment in segments: 39 | point = unwrapPoint(segment.onCurve) 40 | if segment[-1].type == "line": 41 | x = abs(prev[0] - point[0]) 42 | y = abs(prev[1] - point[1]) 43 | if x > 0 and x <= 5 and prev[1] != point[1]: 44 | slightlyOffLines.add((prev, point)) 45 | if y > 0 and y <= 5 and prev[0] != point[0]: 46 | slightlyOffLines.add((prev, point)) 47 | prev = point 48 | return slightlyOffLines 49 | 50 | registry.registerTest( 51 | identifier="angleNearMiss", 52 | level="segment", 53 | title="Angle Near Miss", 54 | description="One or more lines are nearly at important angles.", 55 | testFunction=testForAngleNearMiss, 56 | defconClass=defcon.Contour, 57 | destructiveNotifications=["Contour.PointsChanged"] 58 | ) 59 | 60 | # Segments Near Vertical Metrics 61 | 62 | def testForSegmentsNearVerticalMetrics(contour): 63 | """ 64 | Points shouldn't be just off a vertical metric or blue zone. 65 | 66 | Data structure: 67 | 68 | { 69 | vertical metric y value : set(pt, ...), 70 | ... 71 | } 72 | 73 | """ 74 | font = wrapFont(contour.font) 75 | glyph = wrapGlyph(contour.glyph) 76 | contour = wrapContour(contour) 77 | threshold = 5 78 | # gather the blues into top and bottom groups 79 | topZones = _makeZonePairs(font.info.postscriptBlueValues) 80 | bottomZones = _makeZonePairs(font.info.postscriptOtherBlues) 81 | if topZones: 82 | t = topZones[0] 83 | if t[0] <= 0 and t[1] == 0: 84 | bottomZones.append(topZones.pop(0)) 85 | # insert vertical metrics into the zones 86 | topMetrics = [getattr(font.info, attr) for attr in "xHeight capHeight ascender".split(" ") if getattr(font.info, attr) is not None] 87 | bottomMetrics = [getattr(font.info, attr) for attr in "descender".split(" ") if getattr(font.info, attr) is not None] + [0] 88 | for value in topMetrics: 89 | found = False 90 | for b, t in topZones: 91 | if b <= value and t >= value: 92 | found = True 93 | break 94 | if not found: 95 | topZones.append((value, value)) 96 | for value in bottomMetrics: 97 | found = False 98 | for b, t in bottomZones: 99 | if b <= value and t >= value: 100 | found = True 101 | break 102 | if not found: 103 | bottomZones.append((value, value)) 104 | # find points 105 | found = {} 106 | if len(contour) >= 3: 107 | for segmentIndex, segment in enumerate(contour): 108 | prev = segmentIndex - 1 109 | next = segmentIndex + 1 110 | if next == len(contour): 111 | next = 0 112 | prevSegment = contour[prev] 113 | nextSegment = contour[next] 114 | pt = (segment.onCurve.x, segment.onCurve.y) 115 | prevPt = (prevSegment.onCurve.x, prevSegment.onCurve.y) 116 | nextPt = (nextSegment.onCurve.x, nextSegment.onCurve.y) 117 | pY = prevPt[1] 118 | x, y = pt 119 | nY = nextPt[1] 120 | # top point 121 | if y >= pY and y >= nY: 122 | for b, t in topZones: 123 | test = None 124 | # point is above zone 125 | if y > t and abs(t - y) <= threshold: 126 | test = t 127 | # point is below zone 128 | elif y < b and abs(b - y) <= threshold: 129 | test = b 130 | if test is not None: 131 | if contour.pointInside((x, y - 1)): 132 | if test not in found: 133 | found[test] = set() 134 | found[test].add((x, y)) 135 | # bottom point 136 | if y <= pY and y <= nY: 137 | for b, t in bottomZones: 138 | test = None 139 | # point is above zone 140 | if y > t and abs(t - y) <= threshold: 141 | test = t 142 | # point is below zone 143 | elif y < b and abs(b - y) <= threshold: 144 | test = b 145 | if test is not None: 146 | if contour.pointInside((x, y + 1)): 147 | if test not in found: 148 | found[test] = set() 149 | found[test].add((x, y)) 150 | return found 151 | 152 | def _makeZonePairs(blues): 153 | blues = list(blues) 154 | pairs = [] 155 | if not len(blues) % 2: 156 | while blues: 157 | bottom = blues.pop(0) 158 | top = blues.pop(0) 159 | pairs.append((bottom, top)) 160 | return pairs 161 | 162 | registry.registerTest( 163 | identifier="pointsNearVerticalMetrics", 164 | level="segment", 165 | title="Near Vertical Metrics", 166 | description="Two or more points are just off a vertical metric.", 167 | testFunction=testForSegmentsNearVerticalMetrics, 168 | defconClass=defcon.Contour, 169 | destructiveNotifications=["Contour.PointsChanged"] 170 | ) 171 | 172 | # Unsmooth Smooths 173 | 174 | def testUnsmoothSmooths(contour): 175 | """ 176 | Smooth segments should have bcps in the right places. 177 | 178 | Data structure: 179 | 180 | [ 181 | (offcurvePoint, point, offcurvePoint), 182 | ... 183 | ] 184 | """ 185 | contour = wrapContour(contour) 186 | unsmoothSmooths = [] 187 | prev = contour[-1] 188 | for segment in contour: 189 | if prev.type == "curve" and segment.type == "curve": 190 | if prev.smooth: 191 | angle1 = calculateAngle(prev.offCurve[1], prev.onCurve, r=0) 192 | angle2 = calculateAngle(prev.onCurve, segment.offCurve[0], r=0) 193 | if angle1 != angle2: 194 | pt1 = unwrapPoint(prev.offCurve[1]) 195 | pt2 = unwrapPoint(prev.onCurve) 196 | pt3 = unwrapPoint(segment.offCurve[0]) 197 | unsmoothSmooths.append((pt1, pt2, pt3)) 198 | prev = segment 199 | return unsmoothSmooths 200 | 201 | registry.registerTest( 202 | identifier="unsmoothSmooths", 203 | level="segment", 204 | title="Unsmooth Smooths", 205 | description="One or more smooth points do not have handles that are properly placed.", 206 | testFunction=testUnsmoothSmooths, 207 | defconClass=defcon.Contour, 208 | destructiveNotifications=["Contour.PointsChanged"] 209 | ) 210 | 211 | # Complex Curves 212 | 213 | def testForComplexCurves(contour): 214 | """ 215 | S curves are suspicious. 216 | 217 | Data structure: 218 | 219 | [ 220 | (onCurve, offCurve, offCurve, onCurve), 221 | ... 222 | ] 223 | """ 224 | contour = wrapContour(contour) 225 | impliedS = [] 226 | prev = unwrapPoint(contour[-1].onCurve) 227 | for segment in contour: 228 | if segment.type == "curve": 229 | pt0 = prev 230 | pt1, pt2 = [unwrapPoint(p) for p in segment.offCurve] 231 | pt3 = unwrapPoint(segment.onCurve) 232 | line1 = (pt0, pt3) 233 | line2 = (pt1, pt2) 234 | if calculateLineLineIntersection(line1, line2): 235 | impliedS.append((prev, pt1, pt2, pt3)) 236 | prev = unwrapPoint(segment.onCurve) 237 | return impliedS 238 | 239 | registry.registerTest( 240 | identifier="complexCurves", 241 | level="segment", 242 | title="Complex Curves", 243 | description="One or more curves is suspiciously complex.", 244 | testFunction=testForComplexCurves, 245 | defconClass=defcon.Contour, 246 | destructiveNotifications=["Contour.PointsChanged"] 247 | ) 248 | 249 | 250 | # Crossed Handles 251 | 252 | def testForCrossedHandles(contour): 253 | """ 254 | Handles shouldn't intersect. 255 | 256 | Data structure: 257 | 258 | [ 259 | { 260 | points : (pt1, pt2, pt3, pt4), 261 | intersection : pt 262 | }, 263 | ... 264 | ] 265 | """ 266 | contour = wrapContour(contour) 267 | crossedHandles = [] 268 | pt0 = unwrapPoint(contour[-1].onCurve) 269 | for segment in contour: 270 | pt3 = unwrapPoint(segment.onCurve) 271 | if segment.type == "curve": 272 | pt1, pt2 = [unwrapPoint(p) for p in segment.offCurve] 273 | # direct intersection 274 | direct = calculateLineLineIntersection((pt0, pt1), (pt2, pt3)) 275 | if direct: 276 | if _crossedHanldeWithNoOtherOptions(direct, pt0, pt1, pt2, pt3): 277 | pass 278 | else: 279 | crossedHandles.append(dict(points=(pt0, pt1, pt2, pt3), intersection=direct)) 280 | # indirect intersection 281 | else: 282 | while 1: 283 | # bcp1 = ray, bcp2 = segment 284 | angle = calculateAngle(pt0, pt1) 285 | if angle in (0, 180.0): 286 | t1 = (pt0[0] + 1000, pt0[1]) 287 | t2 = (pt0[0] - 1000, pt0[1]) 288 | else: 289 | yOffset = calculateAngleOffset(angle, 1000) 290 | t1 = (pt0[0] + 1000, pt0[1] + yOffset) 291 | t2 = (pt0[0] - 1000, pt0[1] - yOffset) 292 | indirect = calculateLineLineIntersection((t1, t2), (pt2, pt3)) 293 | if indirect: 294 | if _crossedHanldeWithNoOtherOptions(indirect, pt0, pt1, pt2, pt3): 295 | pass 296 | else: 297 | crossedHandles.append(dict(points=(pt0, indirect, pt2, pt3), intersection=indirect)) 298 | break 299 | # bcp1 = segment, bcp2 = ray 300 | angle = calculateAngle(pt3, pt2) 301 | if angle in (90.0, 270.0): 302 | t1 = (pt3[0], pt3[1] + 1000) 303 | t2 = (pt3[0], pt3[1] - 1000) 304 | else: 305 | yOffset = calculateAngleOffset(angle, 1000) 306 | t1 = (pt3[0] + 1000, pt3[1] + yOffset) 307 | t2 = (pt3[0] - 1000, pt3[1] - yOffset) 308 | indirect = calculateLineLineIntersection((t1, t2), (pt0, pt1)) 309 | if indirect: 310 | if _crossedHanldeWithNoOtherOptions(indirect, pt0, pt1, pt2, pt3): 311 | pass 312 | else: 313 | crossedHandles.append(dict(points=(pt0, pt1, indirect, pt3), intersection=indirect)) 314 | break 315 | break 316 | pt0 = pt3 317 | return crossedHandles 318 | 319 | def _crossedHanldeWithNoOtherOptions(hit, pt0, pt1, pt2, pt3): 320 | hitWidth = max((abs(hit[0] - pt0[0]), abs(hit[0] - pt3[0]))) 321 | hitHeight = max((abs(hit[1] - pt0[1]), abs(hit[1] - pt3[1]))) 322 | w = abs(pt0[0] - pt3[0]) 323 | h = abs(pt0[1] - pt3[1]) 324 | bw = max((abs(pt0[0] - pt1[0]), abs(pt3[0] - pt2[0]))) 325 | bh = max((abs(pt0[1] - pt1[1]), abs(pt3[1] - pt2[1]))) 326 | if w == 1 and bw == 1 and not bh > h: 327 | return True 328 | elif h == 1 and bh == 1 and not bw > w: 329 | return True 330 | return False 331 | 332 | registry.registerTest( 333 | identifier="crossedHandles", 334 | level="segment", 335 | title="Crossed Handles", 336 | description="One or more curves contain crossed handles.", 337 | testFunction=testForCrossedHandles, 338 | defconClass=defcon.Contour, 339 | destructiveNotifications=["Contour.PointsChanged"] 340 | ) 341 | 342 | 343 | # Unnecessary Handles 344 | 345 | def testForUnnecessaryHandles(contour): 346 | """ 347 | Handles shouldn't be used if they aren't doing anything. 348 | 349 | Data structure: 350 | 351 | [ 352 | (pt1, pt2), 353 | ... 354 | ] 355 | """ 356 | contour = wrapContour(contour) 357 | unnecessaryHandles = [] 358 | prevPoint = contour[-1].onCurve 359 | for segment in contour: 360 | if segment.type == "curve": 361 | pt0 = prevPoint 362 | pt1, pt2 = segment.offCurve 363 | pt3 = segment.onCurve 364 | lineAngle = calculateAngle(pt0, pt3, 0) 365 | bcpAngle1 = bcpAngle2 = None 366 | if (pt0.x, pt0.y) != (pt1.x, pt1.y): 367 | bcpAngle1 = calculateAngle(pt0, pt1, 0) 368 | if (pt2.x, pt2.y) != (pt3.x, pt3.y): 369 | bcpAngle2 = calculateAngle(pt2, pt3, 0) 370 | if bcpAngle1 == lineAngle and bcpAngle2 == lineAngle: 371 | unnecessaryHandles.append((unwrapPoint(pt1), unwrapPoint(pt2))) 372 | prevPoint = segment.onCurve 373 | return unnecessaryHandles 374 | 375 | registry.registerTest( 376 | identifier="unnecessaryHandles", 377 | level="segment", 378 | title="Unnecessary Handles", 379 | description="One or more curves has unnecessary handles.", 380 | testFunction=testForUnnecessaryHandles, 381 | defconClass=defcon.Contour, 382 | destructiveNotifications=["Contour.PointsChanged"] 383 | ) 384 | 385 | 386 | # Uneven Handles 387 | 388 | def testForUnevenHandles(contour): 389 | """ 390 | Handles should share the workload as evenly as possible. 391 | 392 | Data structure: 393 | 394 | [ 395 | (off1, off2, off1Shape, off2Shape), 396 | ... 397 | ] 398 | 399 | """ 400 | contour = wrapContour(contour) 401 | unevenHandles = [] 402 | prevPoint = contour[-1].onCurve 403 | for segment in contour: 404 | if segment.type == "curve": 405 | # create rays perpendicular to the 406 | # angle between the on and off 407 | # through the on 408 | on1 = unwrapPoint(prevPoint) 409 | off1, off2 = [unwrapPoint(pt) for pt in segment.offCurve] 410 | on2 = unwrapPoint(segment.onCurve) 411 | curve = (on1, off1, off2, on2) 412 | off1Angle = calculateAngle(on1, off1) - 90 413 | on1Ray = calculateLineThroughPoint(on1, off1Angle) 414 | off2Angle = calculateAngle(off2, on2) - 90 415 | on2Ray = calculateLineThroughPoint(on2, off2Angle) 416 | # find the intersection of the rays 417 | rayIntersection = calculateLineLineIntersection(on1Ray, on2Ray) 418 | if rayIntersection is not None: 419 | # draw a line between the off curves and the intersection 420 | # and find out where these lines intersect the curve 421 | off1Intersection = calculateLineCurveIntersection((off1, rayIntersection), curve) 422 | off2Intersection = calculateLineCurveIntersection((off2, rayIntersection), curve) 423 | if off1Intersection is not None and off2Intersection is not None: 424 | if off1Intersection.points and off2Intersection.points: 425 | off1IntersectionPoint = (off1Intersection.points[0].x, off1Intersection.points[0].y) 426 | off2IntersectionPoint = (off2Intersection.points[0].x, off2Intersection.points[0].y) 427 | # assemble the off curves and their intersections into lines 428 | off1Line = (off1, off1IntersectionPoint) 429 | off2Line = (off2, off2IntersectionPoint) 430 | # measure and compare these 431 | # if they are not both very short calculate the ratio 432 | length1, length2 = sorted((calculateLineLength(*off1Line), calculateLineLength(*off2Line))) 433 | if length1 >= 3 and length2 >= 3: 434 | ratio = length2 / float(length1) 435 | # if outside acceptable range, flag 436 | if ratio > 1.5: 437 | off1Shape = _getUnevenHandleShape(on1, off1, off2, on2, off1Intersection, on1, off1IntersectionPoint, off1) 438 | off2Shape = _getUnevenHandleShape(on1, off1, off2, on2, off2Intersection, off2IntersectionPoint, on2, off2) 439 | unevenHandles.append((off1, off2, off1Shape, off2Shape)) 440 | prevPoint = segment.onCurve 441 | return unevenHandles 442 | 443 | def _getUnevenHandleShape(pt0, pt1, pt2, pt3, intersection, start, end, off): 444 | splitSegments = ftBezierTools.splitCubicAtT(pt0, pt1, pt2, pt3, *intersection.t) 445 | curves = [] 446 | for segment in splitSegments: 447 | if roundPoint(segment[0]) != roundPoint(start) and not curves: 448 | continue 449 | curves.append(segment[1:]) 450 | if roundPoint(segment[-1]) == roundPoint(end): 451 | break 452 | return curves + [off, start] 453 | 454 | registry.registerTest( 455 | identifier="unevenHandles", 456 | level="segment", 457 | title="Uneven Handles", 458 | description="One or more curves has uneven handles.", 459 | testFunction=testForUnevenHandles, 460 | defconClass=defcon.Contour, 461 | destructiveNotifications=["Contour.PointsChanged"] 462 | ) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/tools.py: -------------------------------------------------------------------------------- 1 | import math 2 | from fontTools.misc.arrayTools import calcBounds 3 | from lib.tools import bezierTools as rfBezierTools 4 | 5 | # ----------- 6 | # Conversions 7 | # ----------- 8 | 9 | def roundPoint(pt): 10 | return round(pt[0]), round(pt[1]) 11 | 12 | def unwrapPoint(pt): 13 | return pt.x, pt.y 14 | 15 | def convertBoundsToRect(bounds): 16 | if bounds is None: 17 | return (0, 0, 0, 0) 18 | xMin, yMin, xMax, yMax = bounds 19 | x = xMin 20 | y = yMin 21 | w = xMax - xMin 22 | h = yMax - yMin 23 | return (x, y, w, h) 24 | 25 | def getOnCurves(contour): 26 | points = set() 27 | for segment in contour: 28 | pt = segment.onCurve 29 | points.add((pt.x, pt.y)) 30 | return points 31 | 32 | # ------------ 33 | # Calculations 34 | # ------------ 35 | 36 | def calculateMidpoint(*points): 37 | if len(points) != 2: 38 | xMin, yMin, xMax, yMax = calcBounds(points) 39 | points = ( 40 | (xMin, yMin), 41 | (xMax, yMax) 42 | ) 43 | pt1, pt2 = points 44 | x1, y1 = pt1 45 | x2, y2 = pt2 46 | x = (x1 + x2) / 2 47 | y = (y1 + y2) / 2 48 | return (x, y) 49 | 50 | def calculateAngle(point1, point2, r=None): 51 | if not isinstance(point1, tuple): 52 | point1 = unwrapPoint(point1) 53 | if not isinstance(point2, tuple): 54 | point2 = unwrapPoint(point2) 55 | width = point2[0] - point1[0] 56 | height = point2[1] - point1[1] 57 | angle = round(math.atan2(height, width) * 180 / math.pi, 3) 58 | if r is not None: 59 | angle = round(angle, r) 60 | return angle 61 | 62 | def calculateLineLineIntersection(a1a2, b1b2): 63 | # adapted from: http://www.kevlindev.com/gui/math/intersection/Intersection.js 64 | a1, a2 = a1a2 65 | b1, b2 = b1b2 66 | ua_t = (b2[0] - b1[0]) * (a1[1] - b1[1]) - (b2[1] - b1[1]) * (a1[0] - b1[0]) 67 | ub_t = (a2[0] - a1[0]) * (a1[1] - b1[1]) - (a2[1] - a1[1]) * (a1[0] - b1[0]) 68 | u_b = (b2[1] - b1[1]) * (a2[0] - a1[0]) - (b2[0] - b1[0]) * (a2[1] - a1[1]) 69 | if u_b != 0: 70 | ua = ua_t / float(u_b) 71 | ub = ub_t / float(u_b) 72 | if 0 <= ua and ua <= 1 and 0 <= ub and ub <= 1: 73 | return a1[0] + ua * (a2[0] - a1[0]), a1[1] + ua * (a2[1] - a1[1]) 74 | else: 75 | return None 76 | else: 77 | return None 78 | 79 | def calculateLineCurveIntersection(line, curve): 80 | points = curve + line 81 | intersection = rfBezierTools.intersectCubicLine(*points) 82 | return intersection 83 | 84 | def calculateAngleOffset(angle, distance): 85 | A = 90 86 | B = angle 87 | C = 180 - (A + B) 88 | if C == 0: 89 | return 0 90 | c = distance 91 | A = math.radians(A) 92 | B = math.radians(B) 93 | C = math.radians(C) 94 | b = (c * math.sin(B)) / math.sin(C) 95 | return round(b, 5) 96 | 97 | def calculateLineLength(pt1, pt2): 98 | return math.hypot(pt1[0] - pt2[0], pt1[1] - pt2[1]) 99 | 100 | def calculateAreaOfTriangle(pt1, pt2, pt3): 101 | a = calculateLineLength(pt1, pt2) 102 | b = calculateLineLength(pt2, pt3) 103 | c = calculateLineLength(pt3, pt1) 104 | s = (a + b + c) / 2.0 105 | area = math.sqrt(s * (s - a) * (s - b) * (s - c)) 106 | return area 107 | 108 | def calculateLineThroughPoint(pt, angle): 109 | angle = math.radians(angle) 110 | length = 100000 111 | x1 = math.cos(angle) * -length + pt[0] 112 | y1 = math.sin(angle) * -length + pt[1] 113 | x2 = math.cos(angle) * length + pt[0] 114 | y2 = math.sin(angle) * length + pt[1] 115 | return (x1, y1), (x2, y2) 116 | -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/glyphNanny/tests/wrappers.py: -------------------------------------------------------------------------------- 1 | """ 2 | These functions convert the incoming 3 | objects to fontParts objects if necessary. 4 | """ 5 | 6 | __all__ = ( 7 | "wrapFont", 8 | "wrapGlyph", 9 | "wrapContour" 10 | ) 11 | 12 | import defcon 13 | from fontParts.world import dispatcher 14 | 15 | RFont = dispatcher["RFont"] 16 | RGlyph = dispatcher["RGlyph"] 17 | RContour = dispatcher["RContour"] 18 | 19 | def wrapFont(font): 20 | if isinstance(font, defcon.Font): 21 | return RFont(font) 22 | return font 23 | 24 | def wrapGlyph(glyph): 25 | if isinstance(glyph, defcon.Glyph): 26 | return RGlyph(glyph) 27 | return glyph 28 | 29 | def wrapContour(contour): 30 | if isinstance(contour, defcon.Contour): 31 | return RContour(contour) 32 | return contour -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/launch.py: -------------------------------------------------------------------------------- 1 | import glyphNanny.defaults 2 | import glyphNanny.editorLayers -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/menu_showFontTester.py: -------------------------------------------------------------------------------- 1 | from mojo.roboFont import OpenWindow 2 | from glyphNanny.fontTestWindow import GlyphNannyFontTestWindow 3 | 4 | OpenWindow(GlyphNannyFontTestWindow) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/menu_showPrefs.py: -------------------------------------------------------------------------------- 1 | from mojo.roboFont import OpenWindow 2 | from glyphNanny.defaultsWindow import GlyphNannyDefaultsWindow 3 | 4 | OpenWindow(GlyphNannyDefaultsWindow) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/lib/menu_toggleObserverVisibility.py: -------------------------------------------------------------------------------- 1 | from mojo.events import postEvent 2 | from glyphNanny import defaults 3 | 4 | state = defaults.getDisplayLiveReport() 5 | defaults.setDisplayLiveReport(not state) 6 | postEvent( 7 | defaults.defaultKeyStub + ".defaultsChanged" 8 | ) -------------------------------------------------------------------------------- /build/Glyph Nanny.roboFontExt/license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Type Supply LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typesupply/glyph-nanny/b45e80615ab4cc3887654d0d244ea867aed8e10d/icon.ai -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Type Supply LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Glyph Nanny 2 | 3 | ![Screen Shot](screenshot.png "Screen Shot") 4 | 5 | This tool provides live feedback about the technical quality of a glyph. It's designed to help both new designers and experienced pros: 6 | 7 | _New Designers_ 8 | 9 | Did you just start drawing with beziers? Yeah, I totally agree. They are **frustrating.** This tool is designed to help you quickly see where problems are so that you can learn how to fix them and how to avoid them in the future. Keep in mind that the displayed notes and marks are generated by math and are not a definitive list of things that *must* be corrected. It's always (okay, not always) acceptable to break the rules as long as you have a good reason. Think of the comments as reminders to think through why you have things where they are. 10 | 11 | _Experienced Pros_ 12 | 13 | Do you get super annoyed when things are perfectly straight or you have duplicated contours or other dumb accidents like that? Me too. You can use this tool to look for whatever things you want it to in real time so that you have to stop wondering "Is that segment straight?" and "Did I hit command-V twice?" Please open an issue if there are tests that you'd like to have added to the repertoire. 14 | 15 | ## Usage 16 | 17 | If you want to turn the display on or off, or edit the list of tests performed, look under Extensions > Glyph Nanny in the application menu. 18 | 19 | Also located under Extensions > Glyph Nanny in the application menu is an option for testing an entire font. 20 | 21 | ## Scripting 22 | 23 | Glyph Nanny's tests are available through a scripting API that is accessible through the `glyphNanny` module. 24 | 25 | ### API 26 | 27 | `registeredTests()` 28 | 29 | Returns a dictionary of all registered tests. The keys are the test identifiers and the values are dictionaries of data about the tests. 30 | 31 | `testGlyph(glyph, tests=None)` 32 | 33 | Test `glyph` and return a report in the form of a dictionary. `tests` is a lists of the test identifiers that should be executed. If `tests` is `None` all registered tests will be executed. 34 | 35 | `testLayer(layer, tests=None, ignoreOverlap=False, progressBar=None)` 36 | 37 | Test `layer` and return a report in the form of a dictionary. `tests` is a lists of the test identifiers that should be executed. If `tests` is `None` all registered tests will be executed. If `ignoreOverlap` is `True` a non-destructive "remove overlap" operation will be performed on the data that will be tested. 38 | 39 | `testFont(font, tests=None, ignoreOverlap=False, progressBar=None)` 40 | 41 | Test `font` and return a report in the form of a dictionary. `tests` is a lists of the test identifiers that should be executed. If `tests` is `None` all registered tests will be executed. If `ignoreOverlap` is `True` a non-destructive "remove overlap" operation will be performed on the data that will be tested. 42 | 43 | `formatGlyphReport(report)` 44 | 45 | Format a dictionary report into a string. 46 | 47 | `formatLayerReport(report)` 48 | 49 | Format a dictionary report into a string. 50 | 51 | `formatFontReport(report)` 52 | 53 | Format a dictionary report into a string. 54 | 55 | ### Example 56 | 57 | ```python 58 | import glyphNanny 59 | 60 | glyph = CurrentGlyph() 61 | report = glyphNanny.testGlyph(glyph) 62 | report = glyphNanny.formatGlyphReport(report) 63 | print(report) 64 | ``` 65 | 66 | ## Versions 67 | 68 | ### 2.0.5 69 | 70 | - Text background colors now follow the dark/light mode settings. 71 | - Fixed an issue with open contours in the duplicate contour test. 72 | - Added a test for unnecessary non-extreme curve points. 73 | - The font test window works again. 74 | 75 | ### 2.0.4 76 | 77 | - Made empty Merz layers invisible. 78 | - Fixed a situation when margin values could be undefined. 79 | 80 | ### 2.0.3 81 | 82 | - Fixed some ezui mistakes. 83 | 84 | ### 2.0.2 85 | 86 | - Fixed some embarrassing mistakes on outdated Subscriber method names. (Hey! Glyph Nanny really did make me come up with the idea for Subscriber and it really did drive the development of Subscriber!) 87 | - The report titles option in the preferences now works. 88 | - Added an option for setting if you want tests to occur during drag or after the drag is complete. 89 | - Changed the default behavior to process tests after a drag is complete rather than during the drag. This makes everything feel exponentially faster. 90 | - Fixed a bug that caused all tests to be on by default. 91 | 92 | ### 2.0 93 | 94 | - Everything has been updated for RoboFont 4. 95 | - Rendering is significantly faster. 96 | - Improved the speed of many tests. 97 | - It's easier to add new tests. 98 | - Made some improvements to make this more useful to experienced designers. 99 | - The font report is now text only. The image generation caused problems in very large fonts. It also required a lot of complicated code. 100 | - **GLYPH NANNY IS NOW SCRIPTABLE.** 101 | - Lots of bug fixes. 102 | 103 | ### 0.3 104 | 105 | - The font report is now formatted HTML, with marked up glyph images, instead of a text dump. 106 | - Removed the "unusually high number of contours" test. It took an unusually high number of seconds to execute. 107 | - Added a new test that looks for slightly asymmetric, adjacent curves. 108 | - Added a new test that looks for slight stem width inconsistencies. This uses the Stem Snap values defined in the PostScript hinting settings to determine the desired stem widths. 109 | - The vertical metrics alignment test now incorporates blue zones in the evaluation ranges. 110 | - Unnamed anchors are now caught by the stray point test. 111 | - When testing a full font, overlapping data can now be ignored. 112 | - The crossed handle test is now more lenient when a curve is very small. 113 | - The text in the displayed report can now be turned off. 114 | - Improved speed. (Results may vary.) 115 | - Improved report aesthetics. (Opinions may vary.) 116 | - Small bug fixes. 117 | 118 | ### 0.2 119 | 120 | Minor bug fixes. 121 | 122 | ### 0.1 123 | 124 | Initial version. -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typesupply/glyph-nanny/b45e80615ab4cc3887654d0d244ea867aed8e10d/screenshot.png -------------------------------------------------------------------------------- /source/code/glyphNanny/__init__.py: -------------------------------------------------------------------------------- 1 | from . import tests 2 | from . import defaults 3 | from .scripting import ( 4 | registeredTests, 5 | testGlyph, 6 | testLayer, 7 | testFont, 8 | formatGlyphReport, 9 | formatLayerReport, 10 | formatFontReport 11 | ) -------------------------------------------------------------------------------- /source/code/glyphNanny/defaults.py: -------------------------------------------------------------------------------- 1 | from mojo.extensions import ( 2 | registerExtensionDefaults, 3 | getExtensionDefault, 4 | setExtensionDefault 5 | ) 6 | from .tests.registry import testRegistry 7 | 8 | defaultKeyStub = "com.typesupply.GlyphNanny2." 9 | defaults = { 10 | defaultKeyStub + "displayLiveReport" : True, 11 | defaultKeyStub + "testDuringDrag" : False, 12 | defaultKeyStub + "displayTitles" : True, 13 | defaultKeyStub + "colorInform" : (0, 0, 0.7, 0.3), 14 | defaultKeyStub + "colorReview" : (1, 0.7, 0, 0.7), 15 | defaultKeyStub + "colorRemove" : (1, 0, 0, 0.5), 16 | defaultKeyStub + "colorInsert" : (0, 1, 0, 0.75), 17 | defaultKeyStub + "lineWidthRegular" : 1, 18 | defaultKeyStub + "lineWidthHighlight" : 4, 19 | defaultKeyStub + "textFont" : "system", 20 | defaultKeyStub + "textFontWeight" : "medium", 21 | defaultKeyStub + "textPointSize" : 10, 22 | } 23 | for testIdentifier in testRegistry.keys(): 24 | defaults[defaultKeyStub + "testState." + testIdentifier] = True 25 | 26 | registerExtensionDefaults(defaults) 27 | 28 | # ----- 29 | # Tests 30 | # ----- 31 | 32 | def getTestState(testIdentifier): 33 | return getExtensionDefault(defaultKeyStub + "testState." + testIdentifier) 34 | 35 | def setTestState(testIdentifier, value): 36 | setExtensionDefault(defaultKeyStub + "testState." + testIdentifier, value) 37 | 38 | # ------- 39 | # Display 40 | # ------- 41 | 42 | # Live Report 43 | 44 | def getDisplayLiveReport(): 45 | return getExtensionDefault(defaultKeyStub + "displayLiveReport") 46 | 47 | def setDisplayLiveReport(value): 48 | setExtensionDefault(defaultKeyStub + "displayLiveReport", value) 49 | 50 | # Test During Drag 51 | 52 | def getTestDuringDrag(): 53 | return getExtensionDefault(defaultKeyStub + "testDuringDrag") 54 | 55 | def setTestDuringDrag(value): 56 | setExtensionDefault(defaultKeyStub + "testDuringDrag", value) 57 | 58 | # Titles 59 | 60 | def getDisplayTitles(): 61 | return getExtensionDefault(defaultKeyStub + "displayTitles") 62 | 63 | def setDisplayTitles(value): 64 | setExtensionDefault(defaultKeyStub + "displayTitles", value) 65 | 66 | # ------ 67 | # Colors 68 | # ------ 69 | 70 | # Inform 71 | 72 | def getColorInform(): 73 | return getExtensionDefault(defaultKeyStub + "colorInform") 74 | 75 | def setColorInform(value): 76 | setExtensionDefault(defaultKeyStub + "colorInform", value) 77 | 78 | # Review 79 | 80 | def getColorReview(): 81 | return getExtensionDefault(defaultKeyStub + "colorReview") 82 | 83 | def setColorReview(value): 84 | setExtensionDefault(defaultKeyStub + "colorReview", value) 85 | 86 | # Remove 87 | 88 | def getColorRemove(): 89 | return getExtensionDefault(defaultKeyStub + "colorRemove") 90 | 91 | def setColorRemove(value): 92 | setExtensionDefault(defaultKeyStub + "colorRemove", value) 93 | 94 | # Insert 95 | 96 | def getColorInsert(): 97 | return getExtensionDefault(defaultKeyStub + "colorInsert") 98 | 99 | def setColorInsert(value): 100 | setExtensionDefault(defaultKeyStub + "colorInsert", value) 101 | 102 | # ----------- 103 | # Line Widths 104 | # ----------- 105 | 106 | # Line: Regular 107 | 108 | def getLineWidthRegular(): 109 | return getExtensionDefault(defaultKeyStub + "lineWidthRegular") 110 | 111 | def setLineWidthRegular(value): 112 | setExtensionDefault(defaultKeyStub + "lineWidthRegular", value) 113 | 114 | # Line: Highlight 115 | 116 | def getLineWidthHighlight(): 117 | return getExtensionDefault(defaultKeyStub + "lineWidthHighlight") 118 | 119 | def setLineWidthHighlight(value): 120 | setExtensionDefault(defaultKeyStub + "lineWidthHighlight", value) 121 | 122 | # ---- 123 | # Text 124 | # ---- 125 | 126 | def getTextFont(): 127 | data = dict( 128 | font=getExtensionDefault(defaultKeyStub + "textFont"), 129 | weight=getExtensionDefault(defaultKeyStub + "textFontWeight"), 130 | pointSize=getExtensionDefault(defaultKeyStub + "textPointSize"), 131 | ) 132 | return data 133 | 134 | def setTextFont(data): 135 | font = data.get("font") 136 | if font is not None: 137 | setExtensionDefault(defaultKeyStub + "textFont", font) 138 | weight = data.get("textFontWeight") 139 | if weight is not None: 140 | setExtensionDefault(defaultKeyStub + "textFontWeight", weight) 141 | pointSize = data.get("pointSize") 142 | if pointSize is not None: 143 | setExtensionDefault(defaultKeyStub + "textPointSize", pointSize) 144 | -------------------------------------------------------------------------------- /source/code/glyphNanny/defaultsWindow.py: -------------------------------------------------------------------------------- 1 | import ezui 2 | from mojo.events import postEvent 3 | from . import defaults 4 | from .testTabs import makeTestsTableDescription 5 | 6 | 7 | class GlyphNannyDefaultsWindow(ezui.WindowController): 8 | 9 | def build(self): 10 | # Live Report 11 | liveReportCheckboxDescription = dict( 12 | identifier="liveReport", 13 | type="Checkbox", 14 | text="Show Live Report", 15 | value=defaults.getDisplayLiveReport() 16 | ) 17 | 18 | # Test During Drag 19 | testDuringDragCheckboxDescription = dict( 20 | identifier="testDuringDrag", 21 | type="Checkbox", 22 | text="Test During Drag", 23 | value=defaults.getTestDuringDrag() 24 | ) 25 | 26 | # Tests 27 | testsTableDescription = makeTestsTableDescription() 28 | 29 | # Colors 30 | informationColorWell = dict( 31 | identifier="informationColor", 32 | type="ColorWell", 33 | width=70, 34 | height=25, 35 | color=tuple(defaults.getColorInform()) 36 | ) 37 | reviewColorWell = dict( 38 | identifier="reviewColor", 39 | type="ColorWell", 40 | width=70, 41 | height=25, 42 | color=tuple(defaults.getColorReview()) 43 | ) 44 | insertColorWell = dict( 45 | identifier="insertColor", 46 | type="ColorWell", 47 | width=70, 48 | height=25, 49 | color=tuple(defaults.getColorInsert()) 50 | ) 51 | removeColorWell = dict( 52 | identifier="removeColor", 53 | type="ColorWell", 54 | width=70, 55 | height=25, 56 | color=tuple(defaults.getColorRemove()) 57 | ) 58 | rowDescriptions = [ 59 | dict( 60 | itemDescriptions=[ 61 | informationColorWell, 62 | dict( 63 | type="Label", 64 | text="Information" 65 | ) 66 | ] 67 | ), 68 | dict( 69 | itemDescriptions=[ 70 | reviewColorWell, 71 | dict( 72 | type="Label", 73 | text="Review Something" 74 | ) 75 | ] 76 | ), 77 | dict( 78 | itemDescriptions=[ 79 | insertColorWell, 80 | dict( 81 | type="Label", 82 | text="Insert Something" 83 | ) 84 | ] 85 | ), 86 | dict( 87 | itemDescriptions=[ 88 | removeColorWell, 89 | dict( 90 | type="Label", 91 | text="Remove Something" 92 | ) 93 | ] 94 | ), 95 | ] 96 | columnDescriptions = [ 97 | dict( 98 | width=70 99 | ), 100 | {} 101 | ] 102 | colorsGridDescription = dict( 103 | identifier="colors", 104 | type="Grid", 105 | rowDescriptions=rowDescriptions, 106 | columnPlacement="leading", 107 | rowPlacement="center" 108 | ) 109 | 110 | # Titles 111 | reportTitlesCheckboxDescription = dict( 112 | identifier="reportTitles", 113 | type="Checkbox", 114 | text="Show Titles", 115 | value=defaults.getDisplayTitles() 116 | ) 117 | 118 | windowContent = dict( 119 | identifier="defaultsStack", 120 | type="VerticalStack", 121 | contents=[ 122 | liveReportCheckboxDescription, 123 | testDuringDragCheckboxDescription, 124 | testsTableDescription, 125 | colorsGridDescription, 126 | reportTitlesCheckboxDescription, 127 | ], 128 | spacing=15 129 | ) 130 | windowDescription = dict( 131 | type="Window", 132 | size=(270, "auto"), 133 | title="Glyph Nanny Preferences", 134 | content=windowContent 135 | ) 136 | self.w = ezui.makeItem( 137 | windowDescription, 138 | controller=self 139 | ) 140 | 141 | def started(self): 142 | self.w.open() 143 | 144 | def defaultsStackCallback(self, sender): 145 | values = sender.get() 146 | defaults.setColorInform(values["informationColor"]) 147 | defaults.setColorReview(values["reviewColor"]) 148 | defaults.setColorInsert(values["insertColor"]) 149 | defaults.setColorRemove(values["removeColor"]) 150 | defaults.setDisplayLiveReport(values["liveReport"]) 151 | defaults.setTestDuringDrag(values["testDuringDrag"]) 152 | defaults.setDisplayTitles(values["reportTitles"]) 153 | for testItem in values["testStates"]: 154 | if isinstance(testItem, ezui.TableGroupRow): 155 | continue 156 | defaults.setTestState( 157 | testItem["identifier"], 158 | testItem["state"] 159 | ) 160 | postEvent( 161 | defaults.defaultKeyStub + ".defaultsChanged" 162 | ) 163 | 164 | haveShownTestDuringDragNote = False 165 | 166 | def testDuringDragCallback(self, sender): 167 | if not self.haveShownTestDuringDragNote: 168 | self.showMessage( 169 | "This change will take effect after RoboFont is restarted.", 170 | "You'll have to restart RoboFont yourself." 171 | ) 172 | self.haveShownTestDuringDragNote = True 173 | stack = self.w.findItem("defaultsStack") 174 | self.defaultsStackCallback(stack) -------------------------------------------------------------------------------- /source/code/glyphNanny/fontTestWindow.py: -------------------------------------------------------------------------------- 1 | import os 2 | from vanilla import dialogs 3 | import ezui 4 | from defconAppKit.windows.baseWindow import BaseWindowController 5 | from fontParts.world import CurrentFont 6 | from .testTabs import makeTestsTableDescription 7 | from .scripting import testFont, formatFontReport 8 | 9 | 10 | class GlyphNannyFontTestWindow(ezui.WindowController): 11 | 12 | def build(self): 13 | # Tests 14 | testsTableDescription = makeTestsTableDescription() 15 | 16 | # Overlaps 17 | ignoreOverlapCheckBox = dict( 18 | identifier="ignoreOverlap", 19 | type="Checkbox", 20 | text="Ignore Outline Overlaps" 21 | ) 22 | 23 | # Test 24 | testCurrentFontButton = dict( 25 | identifier="testCurrentFontButton", 26 | type="PushButton", 27 | text="Test Current Font" 28 | ) 29 | 30 | windowContent = dict( 31 | identifier="settingsStack", 32 | type="VerticalStack", 33 | contents=[ 34 | testsTableDescription, 35 | ignoreOverlapCheckBox, 36 | testCurrentFontButton, 37 | ], 38 | spacing=15 39 | ) 40 | windowDescription = dict( 41 | type="Window", 42 | size=(270, "auto"), 43 | title="Glyph Nanny", 44 | content=windowContent 45 | ) 46 | self.w = ezui.makeItem( 47 | windowDescription, 48 | controller=self 49 | ) 50 | 51 | def started(self): 52 | self.w.open() 53 | 54 | def testCurrentFontButtonCallback(self, sender): 55 | font = CurrentFont() 56 | if font is None: 57 | dialogs.message("There is no font to test.", "Open a font and try again.") 58 | return 59 | self._processFont(font) 60 | 61 | def _processFont(self, font): 62 | values = self.w.getItem("settingsStack") 63 | tests = [] 64 | for testItem in self.w.getItem("testStates").get(): 65 | if isinstance(testItem, ezui.TableGroupRow): 66 | continue 67 | identifier = testItem["identifier"] 68 | state = testItem["state"] 69 | if state: 70 | tests.append(identifier) 71 | ignoreOverlap = self.w.getItem("ignoreOverlap").get() 72 | # progressBar = self.startProgress(tickCount=len(font)) 73 | if ignoreOverlap: 74 | fontToTest = font.copy() 75 | for glyph in font: 76 | glyph.removeOverlap() 77 | else: 78 | fontToTest = font 79 | try: 80 | report = testFont( 81 | font, 82 | tests, 83 | # progressBar=progressBar 84 | ) 85 | finally: 86 | pass 87 | # progressBar.close() 88 | text = formatFontReport(report) 89 | FontReportWindow( 90 | font=font, 91 | text=text, 92 | glyphsWithIssues=report.keys() 93 | ) 94 | 95 | 96 | class FontReportWindow(ezui.WindowController): 97 | 98 | def build(self, font=None, text=None, glyphsWithIssues=None): 99 | self.font = font 100 | self.glyphsWithIssues = glyphsWithIssues 101 | title = "Glyph Nanny Report: Unsaved Font" 102 | if font.path is not None: 103 | title = "Glyph Nanny Report: %s" % os.path.basename(font.path) 104 | 105 | textEditorDescription = dict( 106 | type="TextEditor", 107 | value=text, 108 | height=">=150" 109 | ) 110 | markButtonDescription = dict( 111 | identifier="markButton", 112 | type="PushButton", 113 | text="Mark Glyphs" 114 | ) 115 | 116 | windowContent = dict( 117 | type="VerticalStack", 118 | contents=[ 119 | textEditorDescription, 120 | markButtonDescription 121 | ] 122 | ) 123 | windowDescription = dict( 124 | type="Window", 125 | size=(600, "auto"), 126 | minSize=(200, 200), 127 | title=title, 128 | content=windowContent 129 | ) 130 | self.w = ezui.makeItem( 131 | windowDescription, 132 | controller=self 133 | ) 134 | 135 | def started(self): 136 | self.w.open() 137 | 138 | def markButtonCallback(self, sender): 139 | for name in self.font.keys(): 140 | if name in self.glyphsWithIssues: 141 | color = (1, 0, 0, 0.5) 142 | else: 143 | color = None 144 | self.font[name].mark = color 145 | 146 | 147 | if __name__ == "__main__": 148 | GlyphNannyFontTestWindow() 149 | -------------------------------------------------------------------------------- /source/code/glyphNanny/scripting.py: -------------------------------------------------------------------------------- 1 | import re 2 | from .tests.registry import testRegistry 3 | 4 | def registeredTests(): 5 | registered = {} 6 | for testIdentifier, testData in testRegistry.items(): 7 | registered[testIdentifier] = dict( 8 | level=testData["level"], 9 | title=testData["title"], 10 | description=testData["description"], 11 | representationName=testData["representationName"] 12 | ) 13 | return registered 14 | 15 | def testFont( 16 | font, 17 | tests=None, 18 | ignoreOverlap=False, 19 | progressBar=None 20 | ): 21 | if tests is None: 22 | tests = registeredTests().keys() 23 | layer = font.defaultLayer 24 | return testLayer( 25 | layer, 26 | tests=tests, 27 | ignoreOverlap=ignoreOverlap, 28 | progressBar=progressBar 29 | ) 30 | 31 | def testLayer( 32 | layer, 33 | tests=None, 34 | ignoreOverlap=False, 35 | progressBar=None 36 | ): 37 | if tests is None: 38 | tests = registeredTests().keys() 39 | font = layer.font 40 | if font is not None: 41 | glyphOrder = font.glyphOrder 42 | else: 43 | glyphOrder = sorted(layer.keys()) 44 | report = {} 45 | for name in glyphOrder: 46 | if progressBar is not None: 47 | progressBar.update("Analyzing %s..." % name) 48 | glyph = layer[name] 49 | glyphReport = testGlyph(glyph, tests=tests) 50 | report[name] = glyphReport 51 | return report 52 | 53 | def testGlyph(glyph, tests=None): 54 | if tests is None: 55 | tests = registeredTests().keys() 56 | objectLevels = {} 57 | for testIdentifier in sorted(tests): 58 | testData = testRegistry[testIdentifier] 59 | level = testData["level"] 60 | if level not in objectLevels: 61 | objectLevels[level] = [] 62 | objectLevels[level].append(testIdentifier) 63 | glyphLevelTests = ( 64 | objectLevels.get("glyphInfo", []) 65 | + objectLevels.get("metrics", []) 66 | + objectLevels.get("glyph", []) 67 | ) 68 | contourLevelTests = ( 69 | objectLevels.get("contour", []) 70 | + objectLevels.get("segment", []) 71 | + objectLevels.get("point", []) 72 | ) 73 | stub = "GlyphNanny." 74 | report = {} 75 | for testIdentifier in glyphLevelTests: 76 | report[testIdentifier] = glyph.getRepresentation(stub + testIdentifier) 77 | for contourIndex, contour in enumerate(glyph.contours): 78 | for testIdentifier in contourLevelTests: 79 | key = f"contour{contourIndex}: {testIdentifier}" 80 | report[key] = contour.getRepresentation(stub + testIdentifier) 81 | return report 82 | 83 | # -------------- 84 | # Report Purging 85 | # -------------- 86 | 87 | def purgeGlyphReport(report): 88 | purged = {} 89 | for key, value in report.items(): 90 | if isinstance(value, dict): 91 | value = purgeDict(value) 92 | if not value: 93 | continue 94 | purged[key] = value 95 | return purged 96 | 97 | def purgeDict(d): 98 | purged = {} 99 | for k, v in d.items(): 100 | if not v: 101 | continue 102 | purged[k] = v 103 | return purged 104 | 105 | # ---------- 106 | # Formatting 107 | # ---------- 108 | 109 | def formatFontReport(report): 110 | return formatLayerReport(report) 111 | 112 | def formatLayerReport(report): 113 | lines = [] 114 | for glyphName, glyphReport in report.items(): 115 | glyphReport = formatGlyphReport(glyphReport) 116 | if not glyphReport: 117 | continue 118 | lines.append("# " + glyphName) 119 | lines.append("\n") 120 | lines.append(glyphReport) 121 | lines.append("\n") 122 | return "\n".join(lines).strip() 123 | 124 | contourTitle_RE = re.compile(r"contour([\d])+:") 125 | 126 | def formatGlyphReport(report): 127 | report = purgeGlyphReport(report) 128 | notContours = {} 129 | contours = {} 130 | for key, value in report.items(): 131 | m = contourTitle_RE.match(key) 132 | if m: 133 | contourIndex = m.group(1) 134 | if contourIndex not in contours: 135 | contours[contourIndex] = {} 136 | key = key.split(":", 1)[-1].strip() 137 | contours[contourIndex][key] = value 138 | else: 139 | notContours[key] = value 140 | lines = [] 141 | for key, value in sorted(notContours.items()): 142 | title = testRegistry[key]["title"] 143 | lines.append("## " + title) 144 | lines.append(formatValue(value)) 145 | lines.append("") 146 | for contourIndex, contourReport in sorted(contours.items()): 147 | for key, value in sorted(notContours.items()): 148 | title = testRegistry[key]["title"] 149 | lines.append("## {title}: Contour {contourIndex}".format(title=title, contourIndex=contourIndex)) 150 | lines.append(formatValue(value)) 151 | lines.append("") 152 | return "\n".join(lines).strip() 153 | 154 | def formatValue(value): 155 | if isinstance(value, str): 156 | return value 157 | elif isinstance(value, list): 158 | l = [] 159 | for i in value: 160 | l.append("- " + formatValue(i)) 161 | return "\n".join(l) 162 | elif isinstance(value, dict): 163 | l = [] 164 | for k, v in sorted(value.items()): 165 | l.append("- {key}: {value}".format(key=k, value=format(v))) 166 | return "\n".join(l) 167 | return repr(value) 168 | -------------------------------------------------------------------------------- /source/code/glyphNanny/testTabs.py: -------------------------------------------------------------------------------- 1 | import ezui 2 | from .tests.registry import testRegistry 3 | from . import defaults 4 | 5 | groups = [ 6 | ("glyphInfo", "Glyph Info Tests"), 7 | ("glyph", "Glyph Tests"), 8 | ("metrics", "Metrics Tests"), 9 | ("contour", "Contour Tests"), 10 | ("segment", "Segment Tests"), 11 | ("point", "Point Tests") 12 | ] 13 | groupTitles = [title for (level, title) in groups] 14 | groupLevels = {} 15 | for testIdentifier, testData in testRegistry.items(): 16 | level = testData["level"] 17 | if level not in groupLevels: 18 | groupLevels[level] = [] 19 | groupLevels[level].append((testData["title"], testIdentifier)) 20 | 21 | def makeTestsTableDescription(): 22 | columnDescriptions = [ 23 | dict( 24 | identifier="state", 25 | cellDescription=dict( 26 | cellType="Checkbox" 27 | ), 28 | editable=True, 29 | width=16 30 | ), 31 | dict( 32 | identifier="title" 33 | ) 34 | ] 35 | tableItems = [] 36 | for i, (groupLevel, groupTests) in enumerate(groupLevels.items()): 37 | groupTitle = groupTitles[i] 38 | tableItems.append( 39 | groupTitle 40 | ) 41 | testIdentifiers = groupLevels[groupLevel] 42 | testIdentifiers.sort() 43 | for testTitle, testIdentifier in testIdentifiers: 44 | value = defaults.getTestState(testIdentifier) 45 | item = dict( 46 | identifier=testIdentifier, 47 | title=testTitle, 48 | state=value 49 | ) 50 | tableItems.append(item) 51 | testsTableDescription = dict( 52 | identifier="testStates", 53 | type="Table", 54 | columnDescriptions=columnDescriptions, 55 | items=tableItems, 56 | allowsGroupRows=True, 57 | showColumnTitles=False, 58 | alternatingRowColors=False, 59 | allowsSelection=False, 60 | allowsSorting=False, 61 | height=250 62 | ) 63 | return testsTableDescription -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Import to trigger the registration 2 | # of the representation factories 3 | from . import glyphInfo 4 | from . import glyph 5 | from . import metrics 6 | from . import contour 7 | from . import segment 8 | from . import point -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/contour.py: -------------------------------------------------------------------------------- 1 | from fontTools.pens.transformPen import TransformPen 2 | import defcon 3 | from . import registry 4 | from .wrappers import * 5 | from .tools import getOnCurves 6 | 7 | # Small Contours 8 | 9 | def testForSmallContour(contour): 10 | """ 11 | Contours should not have an area less than or equal to 4 units. 12 | 13 | Data structure: 14 | 15 | bool 16 | """ 17 | contour = wrapContour(contour) 18 | if len(contour) > 1: 19 | bounds = contour.bounds 20 | if bounds: 21 | xMin, yMin, xMax, yMax = bounds 22 | w = xMax - xMin 23 | h = yMin - yMax 24 | area = abs(w * h) 25 | if area <= 4: 26 | return True 27 | return False 28 | 29 | registry.registerTest( 30 | identifier="smallContours", 31 | level="contour", 32 | title="Small Contours", 33 | description="One or more contours are suspiciously small.", 34 | testFunction=testForSmallContour, 35 | defconClass=defcon.Contour, 36 | destructiveNotifications=["Contour.PointsChanged"] 37 | ) 38 | 39 | # Open Contours 40 | 41 | def testForOpenContour(contour): 42 | """ 43 | Contours should be closed. 44 | 45 | Data structure: 46 | 47 | (startPoint, endPoint) 48 | """ 49 | contour = wrapContour(contour) 50 | if contour.open: 51 | start = contour[0].onCurve 52 | start = (start.x, start.y) 53 | end = contour[-1].onCurve 54 | end = (end.x, end.y) 55 | if start != end: 56 | return (start, end) 57 | return None 58 | 59 | registry.registerTest( 60 | identifier="openContour", 61 | level="contour", 62 | title="Open Contours", 63 | description="One or more contours are not properly closed.", 64 | testFunction=testForOpenContour, 65 | defconClass=defcon.Contour, 66 | destructiveNotifications=["Contour.PointsChanged"] 67 | ) 68 | 69 | # Extreme Points 70 | 71 | def testForExtremePoints(contour): 72 | """ 73 | Points should be at the extrema. 74 | 75 | Data structure: 76 | 77 | { 78 | (point), 79 | ... 80 | } 81 | """ 82 | glyph = contour.glyph 83 | contourIndex = glyph.contourIndex(contour) 84 | glyph = wrapGlyph(glyph) 85 | contour = glyph[contourIndex] 86 | copyGlyph = glyph.copy() 87 | copyGlyph.clear() 88 | copyGlyph.appendContour(contour) 89 | copyGlyph.extremePoints() 90 | pointsAtExtrema = set() 91 | testPoints = getOnCurves(copyGlyph[0]) 92 | points = getOnCurves(contour) 93 | if points != testPoints: 94 | pointsAtExtrema = testPoints - points 95 | return pointsAtExtrema 96 | 97 | registry.registerTest( 98 | identifier="extremePoints", 99 | level="contour", 100 | title="Extreme Points", 101 | description="One or more curves need an extreme point.", 102 | testFunction=testForExtremePoints, 103 | defconClass=defcon.Contour, 104 | destructiveNotifications=["Contour.PointsChanged"] 105 | ) 106 | 107 | # Symmetrical Curves 108 | 109 | def testForSlightlyAssymmetricCurves(contour): 110 | """ 111 | Note adjacent curves that are almost symmetrical. 112 | 113 | Data structure: 114 | 115 | [ 116 | ( 117 | (on, off, off, on), 118 | (on, off, off, on) 119 | ), 120 | ... 121 | ] 122 | """ 123 | contour = wrapContour(contour) 124 | slightlyAsymmetricalCurves = [] 125 | # gather pairs of curves that could potentially be related 126 | curvePairs = [] 127 | for index, segment in enumerate(contour): 128 | # curve + h/v line + curve 129 | if segment.type == "line": 130 | prev = index - 1 131 | next = index + 1 132 | if next == len(contour): 133 | next = 0 134 | prevSegment = contour[prev] 135 | nextSegment = contour[next] 136 | if prevSegment.type == "curve" and nextSegment.type == "curve": 137 | px = prevSegment[-1].x 138 | py = prevSegment[-1].y 139 | x = segment[-1].x 140 | y = segment[-1].y 141 | if px == x or py == y: 142 | prevPrevSegment = contour[prev - 1] 143 | c1 = ( 144 | (prevPrevSegment[-1].x, prevPrevSegment[-1].y), 145 | (prevSegment[0].x, prevSegment[0].y), 146 | (prevSegment[1].x, prevSegment[1].y), 147 | (prevSegment[2].x, prevSegment[2].y) 148 | ) 149 | c2 = ( 150 | (segment[-1].x, segment[-1].y), 151 | (nextSegment[0].x, nextSegment[0].y), 152 | (nextSegment[1].x, nextSegment[1].y), 153 | (nextSegment[2].x, nextSegment[2].y) 154 | ) 155 | curvePairs.append((c1, c2)) 156 | curvePairs.append((c2, c1)) 157 | # curve + curve 158 | elif segment.type == "curve": 159 | prev = index - 1 160 | prevSegment = contour[prev] 161 | if prevSegment.type == "curve": 162 | prevPrevSegment = contour[prev - 1] 163 | c1 = ( 164 | (prevPrevSegment[-1].x, prevPrevSegment[-1].y), 165 | (prevSegment[0].x, prevSegment[0].y), 166 | (prevSegment[1].x, prevSegment[1].y), 167 | (prevSegment[2].x, prevSegment[2].y) 168 | ) 169 | c2 = ( 170 | (prevSegment[2].x, prevSegment[2].y), 171 | (segment[0].x, segment[0].y), 172 | (segment[1].x, segment[1].y), 173 | (segment[2].x, segment[2].y) 174 | ) 175 | curvePairs.append((c1, c2)) 176 | curvePairs.append((c2, c1)) 177 | # relativize the pairs and compare 178 | for curve1, curve2 in curvePairs: 179 | curve1Compare = _relativizeCurve(curve1) 180 | curve2Compare = _relativizeCurve(curve2) 181 | if curve1 is None or curve2 is None: 182 | continue 183 | if curve1Compare is None or curve2Compare is None: 184 | continue 185 | flipped = curve1Compare.getFlip(curve2Compare) 186 | if flipped: 187 | slightlyAsymmetricalCurves.append((flipped, curve2)) 188 | # done 189 | if not slightlyAsymmetricalCurves: 190 | return None 191 | return slightlyAsymmetricalCurves 192 | 193 | def _relativizeCurve(curve): 194 | pt0, pt1, pt2, pt3 = curve 195 | # bcps aren't horizontal or vertical 196 | if (pt0[0] != pt1[0]) and (pt0[1] != pt1[1]): 197 | return None 198 | if (pt3[0] != pt2[0]) and (pt3[1] != pt2[1]): 199 | return None 200 | # xxx validate that the bcps aren't backwards here 201 | w = abs(pt3[0] - pt0[0]) 202 | h = abs(pt3[1] - pt0[1]) 203 | bw = None 204 | bh = None 205 | # pt0 -> pt1 is vertical 206 | if pt0[0] == pt1[0]: 207 | bh = abs(pt1[1] - pt0[1]) 208 | # pt0 -> pt1 is horizontal 209 | elif pt0[1] == pt1[1]: 210 | bw = abs(pt1[0] - pt0[0]) 211 | # pt2 -> pt3 is vertical 212 | if pt2[0] == pt3[0]: 213 | bh = abs(pt3[1] - pt2[1]) 214 | # pt2 -> pt3 is horizontal 215 | elif pt2[1] == pt3[1]: 216 | bw = abs(pt3[0] - pt2[0]) 217 | # safety 218 | if bw is None or bh is None: 219 | return None 220 | # done 221 | curve = _CurveFlipper((w, h, bw, bh), curve, 5, 10) 222 | return curve 223 | 224 | 225 | class _CurveFlipper(object): 226 | 227 | def __init__(self, relativeCurve, curve, sizeThreshold, bcpThreshold): 228 | self.w, self.h, self.bcpw, self.bcph = relativeCurve 229 | self.pt0, self.pt1, self.pt2, self.pt3 = curve 230 | self.sizeThreshold = sizeThreshold 231 | self.bcpThreshold = bcpThreshold 232 | 233 | def getFlip(self, other): 234 | ## determine if they need a flip 235 | # curves are exactly the same 236 | if (self.w, self.h, self.bcpw, self.bcph) == (other.w, other.h, other.bcpw, other.bcph): 237 | return None 238 | # width/height are too different 239 | if abs(self.w - other.w) > self.sizeThreshold: 240 | return None 241 | if abs(self.h - other.h) > self.sizeThreshold: 242 | return None 243 | # bcp deltas are too different 244 | if abs(self.bcpw - other.bcpw) > self.bcpThreshold: 245 | return None 246 | if abs(self.bcph - other.bcph) > self.bcpThreshold: 247 | return None 248 | # determine the flip direction 249 | minX = min((self.pt0[0], self.pt3[0])) 250 | otherMinX = min((other.pt0[0], other.pt3[0])) 251 | minY = min((self.pt0[1], self.pt3[1])) 252 | otherMinY = min((other.pt0[1], other.pt3[1])) 253 | direction = None 254 | if abs(minX - otherMinX) <= self.sizeThreshold: 255 | direction = "v" 256 | elif abs(minY - otherMinY) <= self.sizeThreshold: 257 | direction = "h" 258 | if direction is None: 259 | return None 260 | # flip 261 | if direction == "h": 262 | transformation = (-1, 0, 0, 1, 0, 0) 263 | else: 264 | transformation = (1, 0, 0, -1, 0, 0) 265 | self._transformedPoints = [] 266 | transformPen = TransformPen(self, transformation) 267 | transformPen.moveTo(self.pt0) 268 | transformPen.curveTo(self.pt1, self.pt2, self.pt3) 269 | points = self._transformedPoints 270 | del self._transformedPoints 271 | # offset 272 | oX = oY = 0 273 | if direction == "v": 274 | oY = points[-1][1] - other.pt0[1] 275 | else: 276 | oX = points[-1][0] - other.pt0[0] 277 | offset = [] 278 | for x, y in points: 279 | x -= oX 280 | y -= oY 281 | offset.append((x, y)) 282 | points = offset 283 | # done 284 | return points 285 | 286 | def moveTo(self, pt): 287 | self._transformedPoints.append(pt) 288 | 289 | def curveTo(self, pt1, pt2, pt3): 290 | self._transformedPoints.append(pt1) 291 | self._transformedPoints.append(pt2) 292 | self._transformedPoints.append(pt3) 293 | 294 | 295 | registry.registerTest( 296 | identifier="curveSymmetry", 297 | level="contour", 298 | title="Curve Symmetry", 299 | description="One or more curve pairs are slightly asymmetrical.", 300 | testFunction=testForSlightlyAssymmetricCurves, 301 | defconClass=defcon.Contour, 302 | destructiveNotifications=["Contour.PointsChanged"] 303 | ) 304 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/glyph.py: -------------------------------------------------------------------------------- 1 | from fontPens.digestPointPen import DigestPointPen 2 | from fontTools.misc import arrayTools as ftArrayTools 3 | import defcon 4 | from .tools import ( 5 | unwrapPoint, 6 | calculateAngle 7 | ) 8 | from . import registry 9 | from .wrappers import * 10 | 11 | # Stem Consistency 12 | 13 | def testStemWidths(glyph): 14 | """ 15 | Stem widths should be consistent. 16 | 17 | Data structure: 18 | 19 | { 20 | horizontal : [(y1, y2, [x1, x2, ...]), ...] 21 | vertical : [(x1, x2, [y1, y2, ...]), ...] 22 | } 23 | """ 24 | font = wrapFont(glyph.font) 25 | layer = font.getLayer(glyph.layer.name) 26 | glyph = layer[glyph.name] 27 | hProblems = vProblems = None 28 | tolerance = 5 29 | # horizontal 30 | hStems = [_StemWrapper(v, tolerance) for v in font.info.postscriptStemSnapH] 31 | if hStems: 32 | hProblems = _findStemProblems(glyph, hStems, "h") 33 | # vertical 34 | vStems = [_StemWrapper(v, tolerance) for v in font.info.postscriptStemSnapV] 35 | if vStems: 36 | vProblems = _findStemProblems(glyph, vStems, "v") 37 | # report 38 | data = dict(horizontal=hProblems, vertical=vProblems) 39 | return data 40 | 41 | def _findStemProblems(glyph, targetStems, stemDirection): 42 | stems = set() 43 | # h/v abstraction 44 | if stemDirection == "h": 45 | primaryCoordinate = 1 46 | secondaryCoordinate = 0 47 | desiredClockwiseAngle = 0 48 | desiredCounterAngle = 180 49 | else: 50 | primaryCoordinate = 0 51 | secondaryCoordinate = 1 52 | desiredClockwiseAngle = -90 53 | desiredCounterAngle = 90 54 | # structure the contour and line data for efficient processing 55 | contours = { 56 | True : [], 57 | False : [] 58 | } 59 | for contour in glyph: 60 | contourDirection = contour.clockwise 61 | bounds = contour.bounds 62 | lines = {} 63 | # line to 64 | previous = unwrapPoint(contour[-1].onCurve) 65 | for segment in contour: 66 | point = unwrapPoint(segment.onCurve) 67 | if segment.type == "line": 68 | # only process completely horizontal/vertical lines 69 | # that have a length greater than 0 70 | if (previous[primaryCoordinate] == point[primaryCoordinate]) and (previous[secondaryCoordinate] != point[secondaryCoordinate]): 71 | angle = calculateAngle(previous, point) 72 | p = point[primaryCoordinate] 73 | s1 = previous[secondaryCoordinate] 74 | s2 = point[secondaryCoordinate] 75 | s1, s2 = sorted((s1, s2)) 76 | if angle not in lines: 77 | lines[angle] = {} 78 | if p not in lines[angle]: 79 | lines[angle][p] = [] 80 | lines[angle][p].append((s1, s2)) 81 | previous = point 82 | # imply stems from curves by using BCP handles 83 | previous = contour[-1] 84 | for segment in contour: 85 | if segment.type == "curve" and previous.type == "curve": 86 | bcp1 = unwrapPoint(previous[1]) 87 | bcp2 = unwrapPoint(segment[-1]) 88 | if bcp1[primaryCoordinate] == bcp2[primaryCoordinate]: 89 | angle = calculateAngle(bcp1, bcp2) 90 | p = bcp1[primaryCoordinate] 91 | s1 = bcp1[secondaryCoordinate] 92 | s2 = bcp2[secondaryCoordinate] 93 | s1, s2 = sorted((s1, s2)) 94 | if angle not in lines: 95 | lines[angle] = {} 96 | if p not in lines[angle]: 97 | lines[angle][p] = [] 98 | lines[angle][p].append((s1, s2)) 99 | previous = segment 100 | contours[contourDirection].append((bounds, lines)) 101 | # single contours 102 | for clockwise, directionContours in contours.items(): 103 | for contour in directionContours: 104 | bounds, data = contour 105 | for angle1, lineData1 in data.items(): 106 | for angle2, lineData2 in data.items(): 107 | if angle1 == angle2: 108 | continue 109 | if clockwise and angle1 == desiredClockwiseAngle: 110 | continue 111 | if not clockwise and angle1 == desiredCounterAngle: 112 | continue 113 | for p1, lines1 in lineData1.items(): 114 | for p2, lines2 in lineData2.items(): 115 | if p2 <= p1: 116 | continue 117 | for s1a, s1b in lines1: 118 | for s2a, s2b in lines2: 119 | overlap = _linesOverlap(s1a, s1b, s2a, s2b) 120 | if not overlap: 121 | continue 122 | w = p2 - p1 123 | hits = [] 124 | for stem in targetStems: 125 | if w == stem: 126 | d = stem.diff(w) 127 | if d: 128 | hits.append((d, stem.value, (s1a, s1b, s2a, s2b))) 129 | if hits: 130 | hit = min(hits) 131 | w = hit[1] 132 | s = hit[2] 133 | stems.add((p1, p1 + w, s)) 134 | # double contours to test 135 | for clockwiseContour in contours[True]: 136 | clockwiseBounds = clockwiseContour[0] 137 | for counterContour in contours[False]: 138 | counterBounds = counterContour[0] 139 | overlap = ftArrayTools.sectRect(clockwiseBounds, counterBounds)[0] 140 | if not overlap: 141 | continue 142 | clockwiseData = clockwiseContour[1] 143 | counterData = counterContour[1] 144 | for clockwiseAngle, clockwiseLineData in clockwiseContour[1].items(): 145 | for counterAngle, counterLineData in counterContour[1].items(): 146 | if clockwiseAngle == counterAngle: 147 | continue 148 | for clockwiseP, clockwiseLines in clockwiseLineData.items(): 149 | for counterP, counterLines in counterLineData.items(): 150 | for clockwiseSA, clockwiseSB in clockwiseLines: 151 | for counterSA, counterSB in counterLines: 152 | overlap = _linesOverlap(clockwiseSA, clockwiseSB, counterSA, counterSB) 153 | if not overlap: 154 | continue 155 | w = abs(counterP - clockwiseP) 156 | hits = [] 157 | for stem in targetStems: 158 | if w == stem: 159 | d = stem.diff(w) 160 | if d: 161 | hits.append((d, stem.value, (clockwiseSA, clockwiseSB, counterSA, counterSB))) 162 | if hits: 163 | p = min((clockwiseP, counterP)) 164 | hit = min(hits) 165 | w = hit[1] 166 | s = hit[2] 167 | stems.add((p, p + w, s)) 168 | # done 169 | return stems 170 | 171 | class _StemWrapper(object): 172 | 173 | def __init__(self, value, threshold): 174 | self.value = value 175 | self.threshold = threshold 176 | 177 | def __repr__(self): 178 | return "" % (self.value, self.threshold) 179 | 180 | def __eq__(self, other): 181 | d = abs(self.value - other) 182 | return d <= self.threshold 183 | 184 | def diff(self, other): 185 | return abs(self.value - other) 186 | 187 | 188 | def _linesOverlap(a1, a2, b1, b2): 189 | if a1 > b2 or a2 < b1: 190 | return False 191 | return True 192 | 193 | registry.registerTest( 194 | identifier="stemWidths", 195 | level="glyph", 196 | title="Stem Widths", 197 | description="One or more stems do not match the registered values.", 198 | testFunction=testStemWidths, 199 | defconClass=defcon.Glyph, 200 | destructiveNotifications=["Glyph.ContoursChanged"] 201 | ) 202 | 203 | # Duplicate Contours 204 | 205 | def testDuplicateContours(glyph): 206 | """ 207 | Contours shouldn't be duplicated on each other. 208 | 209 | Data structure: 210 | 211 | [ 212 | (contourIndex, bounds), 213 | ... 214 | ] 215 | """ 216 | glyph = wrapGlyph(glyph) 217 | contours = {} 218 | for index, contour in enumerate(glyph): 219 | contour = contour.copy() 220 | if not contour.open: 221 | contour.autoStartSegment() 222 | pen = DigestPointPen() 223 | contour.drawPoints(pen) 224 | digest = pen.getDigest() 225 | if digest not in contours: 226 | contours[digest] = [] 227 | contours[digest].append(index) 228 | duplicateContours = [] 229 | for digest, indexes in contours.items(): 230 | if len(indexes) > 1: 231 | duplicateContours.append((indexes[0], contour.bounds)) 232 | return duplicateContours 233 | 234 | registry.registerTest( 235 | identifier="duplicateContours", 236 | level="glyph", 237 | title="Duplicate Contours", 238 | description="One or more contours are duplicated.", 239 | testFunction=testDuplicateContours, 240 | defconClass=defcon.Glyph, 241 | destructiveNotifications=["Glyph.ContoursChanged"] 242 | ) 243 | 244 | # Duplicate Components 245 | 246 | def testDuplicateComponents(glyph): 247 | """ 248 | Components shouldn't be duplicated on each other. 249 | 250 | [ 251 | (componentIndex, bounds), 252 | ... 253 | ] 254 | 255 | """ 256 | glyph = wrapGlyph(glyph) 257 | duplicateComponents = [] 258 | components = set() 259 | for index, component in enumerate(glyph.components): 260 | key = (component.baseGlyph, component.transformation) 261 | if key in components: 262 | duplicateComponents.append((index, component.bounds)) 263 | components.add(key) 264 | return duplicateComponents 265 | 266 | registry.registerTest( 267 | identifier="duplicateComponents", 268 | level="glyph", 269 | title="Duplicate Components", 270 | description="One or more components are duplicated.", 271 | testFunction=testDuplicateComponents, 272 | defconClass=defcon.Glyph, 273 | destructiveNotifications=["Glyph.ComponentsChanged"] 274 | ) 275 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/glyphInfo.py: -------------------------------------------------------------------------------- 1 | import re 2 | from fontTools.agl import AGL2UV 3 | import defcon 4 | from . import registry 5 | from .wrappers import * 6 | 7 | # Unicode Value 8 | 9 | uniNamePattern = re.compile( 10 | "uni" 11 | "([0-9A-Fa-f]{4})" 12 | "$" 13 | ) 14 | 15 | def testUnicodeValue(glyph): 16 | """ 17 | A Unicode value should appear only once per font. 18 | """ 19 | font = wrapFont(glyph.font) 20 | layer = font.getLayer(glyph.layer.name) 21 | glyph = layer[glyph.name] 22 | report = [] 23 | uni = glyph.unicode 24 | name = glyph.name 25 | # test for uniXXXX name 26 | m = uniNamePattern.match(name) 27 | if m is not None: 28 | uniFromName = m.group(1) 29 | uniFromName = int(uniFromName, 16) 30 | if uni != uniFromName: 31 | report.append("The Unicode value for this glyph does not match its name.") 32 | # test against AGLFN 33 | else: 34 | expectedUni = AGL2UV.get(name) 35 | if expectedUni != uni: 36 | report.append("The Unicode value for this glyph may not be correct.") 37 | # look for duplicates 38 | if uni is not None: 39 | duplicates = [] 40 | for name in sorted(font.keys()): 41 | if name == glyph.name: 42 | continue 43 | other = font[name] 44 | if other.unicode == uni: 45 | duplicates.append(name) 46 | if duplicates: 47 | report.append("The Unicode for this glyph is also used by: %s." % " ".join(duplicates)) 48 | return report 49 | 50 | registry.registerTest( 51 | identifier="unicodeValue", 52 | level="glyphInfo", 53 | title="Unicode Value", 54 | description="Unicode value may have problems.", 55 | testFunction=testUnicodeValue, 56 | defconClass=defcon.Glyph, 57 | destructiveNotifications=["Glyph.UnicodesChanged"] 58 | ) -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/metrics.py: -------------------------------------------------------------------------------- 1 | import defcon 2 | from . import registry 3 | from .wrappers import * 4 | 5 | # Ligatures 6 | 7 | def testLigatureMetrics(glyph): 8 | """ 9 | Sometimes ligatures should have the same 10 | metrics as the glyphs they represent. 11 | 12 | Data structure: 13 | 14 | { 15 | leftMessage : string 16 | rightMessage : string 17 | left : number 18 | right : number 19 | width : number 20 | bounds : (xMin, yMin, xMax, yMax) 21 | } 22 | """ 23 | font = wrapFont(glyph.font) 24 | layer = font.getLayer(glyph.layer.name) 25 | glyph = layer[glyph.name] 26 | name = glyph.name 27 | if "_" not in name: 28 | return 29 | base = name 30 | suffix = None 31 | if "." in name: 32 | base, suffix = name.split(".", 1) 33 | # guess at the ligature parts 34 | parts = base.split("_") 35 | leftPart = parts[0] 36 | rightPart = parts[-1] 37 | # try snapping on the suffixes 38 | if suffix: 39 | if leftPart + "." + suffix in font: 40 | leftPart += "." + suffix 41 | if rightPart + "." + suffix in font: 42 | rightPart += "." + suffix 43 | # test 44 | left = glyph.leftMargin 45 | right = glyph.rightMargin 46 | report = dict(leftMessage=None, rightMessage=None, left=left, right=right, width=glyph.width, bounds=glyph.bounds) 47 | if leftPart not in font: 48 | report["leftMessage"] = "Couldn't find the ligature's left component." 49 | else: 50 | expectedLeft = font[leftPart].leftMargin 51 | if left != expectedLeft: 52 | report["leftMessage"] = "Left doesn't match the presumed part %s left" % leftPart 53 | if rightPart not in font: 54 | report["rightMessage"] = "Couldn't find the ligature's right component." 55 | else: 56 | expectedRight = font[rightPart].rightMargin 57 | if right != expectedRight: 58 | report["rightMessage"] = "Right doesn't match the presumed part %s right" % rightPart 59 | if report["leftMessage"] or report["rightMessage"]: 60 | return report 61 | return None 62 | 63 | registry.registerTest( 64 | identifier="ligatureMetrics", 65 | level="metrics", 66 | title="Ligature Side-Bearings", 67 | description="The side-bearings don't match the ligature's presumed part metrics.", 68 | testFunction=testLigatureMetrics, 69 | defconClass=defcon.Glyph, 70 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 71 | ) 72 | 73 | # Components 74 | 75 | def testComponentMetrics(glyph): 76 | """ 77 | If components are present, check their base margins. 78 | 79 | Data structure: 80 | 81 | { 82 | leftMessage : string 83 | rightMessage : string 84 | left : number 85 | right : number 86 | width : number 87 | bounds : (xMin, yMin, xMax, yMax) 88 | } 89 | """ 90 | font = wrapFont(glyph.font) 91 | layer = font.getLayer(glyph.layer.name) 92 | glyph = layer[glyph.name] 93 | components = [c for c in glyph.components if c.baseGlyph in font] 94 | # no components 95 | if len(components) == 0: 96 | return 97 | boxes = [c.bounds for c in components] 98 | # a component has no contours 99 | if None in boxes: 100 | return 101 | report = dict(leftMessage=None, rightMessage=None, left=None, right=None, width=glyph.width, box=glyph.bounds) 102 | problem = False 103 | if len(components) > 1: 104 | # filter marks 105 | nonMarks = [] 106 | markCategories = ("Sk", "Zs", "Lm") 107 | for component in components: 108 | baseGlyphName = component.baseGlyph 109 | category = font.naked().unicodeData.categoryForGlyphName(baseGlyphName, allowPseudoUnicode=True) 110 | if category not in markCategories: 111 | nonMarks.append(component) 112 | if nonMarks: 113 | components = nonMarks 114 | # order the components from left to right based on their boxes 115 | if len(components) > 1: 116 | leftComponent, rightComponent = _getXMinMaxComponents(components) 117 | else: 118 | leftComponent = rightComponent = components[0] 119 | expectedLeft = _getComponentBaseMargins(font, leftComponent)[0] 120 | expectedRight = _getComponentBaseMargins(font, rightComponent)[1] 121 | left = leftComponent.bounds[0] 122 | right = glyph.width - rightComponent.bounds[2] 123 | if left != expectedLeft: 124 | problem = True 125 | report["leftMessage"] = "%s component left does not match %s left" % (leftComponent.baseGlyph, leftComponent.baseGlyph) 126 | report["left"] = left 127 | if right != expectedRight: 128 | problem = True 129 | report["rightMessage"] = "%s component right does not match %s right" % (rightComponent.baseGlyph, rightComponent.baseGlyph) 130 | report["right"] = right 131 | if problem: 132 | return report 133 | 134 | def _getComponentBaseMargins(font, component): 135 | baseGlyphName = component.baseGlyph 136 | baseGlyph = font[baseGlyphName] 137 | scale = component.scale[0] 138 | left = baseGlyph.leftMargin * scale 139 | right = baseGlyph.rightMargin * scale 140 | return left, right 141 | 142 | def _getXMinMaxComponents(components): 143 | minSide = [] 144 | maxSide = [] 145 | for component in components: 146 | xMin, yMin, xMax, yMax = component.bounds 147 | minSide.append((xMin, component)) 148 | maxSide.append((xMax, component)) 149 | o = [ 150 | min(minSide, key=lambda v: (v[0], v[1].baseGlyph))[-1], 151 | max(maxSide, key=lambda v: (v[0], v[1].baseGlyph))[-1], 152 | ] 153 | return o 154 | 155 | registry.registerTest( 156 | identifier="componentMetrics", 157 | level="metrics", 158 | title="Component Side-Bearings", 159 | description="The side-bearings don't match the component's metrics.", 160 | testFunction=testComponentMetrics, 161 | defconClass=defcon.Glyph, 162 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 163 | ) 164 | 165 | # Symmetry 166 | 167 | def testMetricsSymmetry(glyph): 168 | """ 169 | Sometimes glyphs are almost symmetrical, but could be. 170 | 171 | Data structure: 172 | 173 | { 174 | message : string 175 | left : number 176 | right : number 177 | width : number 178 | bounds : (xMin, yMin, xMax, yMax) 179 | } 180 | """ 181 | glyph = wrapGlyph(glyph) 182 | if glyph.leftMargin == None: 183 | return 184 | left = glyph.leftMargin 185 | right = glyph.rightMargin 186 | if left is None or right is None: 187 | return None 188 | diff = int(round(abs(left - right))) 189 | if diff == 1: 190 | message = "The side-bearings are 1 unit from being equal." 191 | else: 192 | message = "The side-bearings are %d units from being equal." % diff 193 | data = dict(left=left, right=right, width=glyph.width, message=message) 194 | if 0 < diff <= 5: 195 | return data 196 | return None 197 | 198 | registry.registerTest( 199 | identifier="metricsSymmetry", 200 | level="metrics", 201 | title="Symmetry", 202 | description="The side-bearings are almost equal.", 203 | testFunction=testMetricsSymmetry, 204 | defconClass=defcon.Glyph, 205 | destructiveNotifications=["Glyph.WidthChanged", "Glyph.ContoursChanged", "Glyph.ComponentsChanged"] 206 | ) 207 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/point.py: -------------------------------------------------------------------------------- 1 | from fontTools.misc import bezierTools as ftBezierTools 2 | import defcon 3 | from fontPens.penTools import distance 4 | from . import registry 5 | from .wrappers import * 6 | from .tools import ( 7 | unwrapPoint, 8 | calculateAngle 9 | ) 10 | 11 | # Stray Points 12 | 13 | def testForStrayPoints(contour): 14 | """ 15 | There should be no stray points. 16 | 17 | Data structure: 18 | 19 | (x, y) 20 | """ 21 | contour = wrapContour(contour) 22 | if len(contour) == 1: 23 | pt = contour[0].onCurve 24 | pt = (pt.x, pt.y) 25 | return pt 26 | return None 27 | 28 | registry.registerTest( 29 | identifier="strayPoints", 30 | level="point", 31 | title="Stray Points", 32 | description="One or more stray points are present.", 33 | testFunction=testForStrayPoints, 34 | defconClass=defcon.Contour, 35 | destructiveNotifications=["Contour.PointsChanged"] 36 | ) 37 | 38 | # Unnecessary Points 39 | 40 | def testForUnnecessaryPoints(contour): 41 | """ 42 | Consecutive segments shouldn't have the same angle. 43 | Non-exteme curve points between curve points shouldn't 44 | exist unless they are needed to support the curve. 45 | 46 | Data structure: 47 | 48 | [ 49 | point, 50 | ... 51 | ] 52 | """ 53 | contour = wrapContour(contour) 54 | unnecessaryPoints = _testForUnnecessaryLinePoints(contour) 55 | unnecessaryPoints += _testForUnnecessaryCurvePoints(contour) 56 | return unnecessaryPoints 57 | 58 | def _testForUnnecessaryLinePoints(contour): 59 | unnecessaryPoints = [] 60 | for segmentIndex, segment in enumerate(contour): 61 | if segment.type == "line": 62 | prevSegment = contour[segmentIndex - 1] 63 | nextSegment = contour[(segmentIndex + 1) % len(contour)] 64 | if nextSegment.type == "line": 65 | thisAngle = calculateAngle(prevSegment.onCurve, segment.onCurve) 66 | nextAngle = calculateAngle(segment.onCurve, nextSegment.onCurve) 67 | if thisAngle == nextAngle: 68 | unnecessaryPoints.append(unwrapPoint(segment.onCurve)) 69 | return unnecessaryPoints 70 | 71 | def _testForUnnecessaryCurvePoints(contour): 72 | # Art School Graduate Implementation of Fréchet Distance 73 | # ------------------------------------------------------ 74 | # aka "a probably poor understanding of Fréchet Distance with a 75 | # clumsy implementation, but, hey, we're not doing rocket science." 76 | # 77 | # 1. find the relative T for the first segment in the before. 78 | # 2. split the after segment into two segments. 79 | # 3. divide all for segments into flattened subsegments. 80 | # 4. determine the maximum distance between corresponding points 81 | # in the corresponding subsegments. 82 | # 5. if the distance exceeds the "leash" length, the point 83 | # is necessary. 84 | tolerance = 0.035 85 | unnecessaryPoints = [] 86 | bPoints = list(contour.bPoints) 87 | if len(bPoints) < 3: 88 | return unnecessaryPoints 89 | for i, bPoint in enumerate(bPoints): 90 | if bPoint.type == "curve": 91 | inX, inY = bPoint.bcpIn 92 | outX, outY = bPoint.bcpOut 93 | if all((inX != outX, inX != 0, outX != 0, inY != outY, inY != 0, outY != 0)): 94 | afterContour = contour.copy() 95 | afterContour.removeBPoint(afterContour.bPoints[i], preserveCurve=True) 96 | afterBPoints = afterContour.bPoints 97 | # calculate before length 98 | start = i - 1 99 | middle = i 100 | end = i + 1 101 | if start == -1: 102 | start = len(bPoints) - 1 103 | if end == len(bPoints): 104 | end = 0 105 | start = bPoints[start] 106 | middle = bPoints[middle] 107 | end = bPoints[end] 108 | beforeSegment1 = ( 109 | start.anchor, 110 | _makeBCPAbsolute(start.anchor, start.bcpOut), 111 | _makeBCPAbsolute(middle.anchor, middle.bcpIn), 112 | middle.anchor 113 | ) 114 | beforeSegment2 = ( 115 | middle.anchor, 116 | _makeBCPAbsolute(middle.anchor, middle.bcpOut), 117 | _makeBCPAbsolute(end.anchor, end.bcpIn), 118 | end.anchor 119 | ) 120 | beforeSegment1Length = abs(ftBezierTools.approximateCubicArcLength(*beforeSegment1)) 121 | beforeSegment2Length = abs(ftBezierTools.approximateCubicArcLength(*beforeSegment2)) 122 | beforeLength = beforeSegment1Length + beforeSegment2Length 123 | # calculate after length 124 | start = i - 1 125 | end = i 126 | if start == -1: 127 | start = len(afterBPoints) - 1 128 | if end == len(afterBPoints): 129 | end = 0 130 | start = afterBPoints[start] 131 | end = afterBPoints[end] 132 | afterSegment = ( 133 | start.anchor, 134 | _makeBCPAbsolute(start.anchor, start.bcpOut), 135 | _makeBCPAbsolute(end.anchor, end.bcpIn), 136 | end.anchor 137 | ) 138 | midT = beforeSegment1Length / beforeLength 139 | afterSegment1, afterSegment2 = ftBezierTools.splitCubicAtT(*afterSegment, midT) 140 | subSegmentCount = 10 141 | beforeSegment1Points = _splitSegmentByCount(*beforeSegment1, subSegmentCount=subSegmentCount) 142 | beforeSegment2Points = _splitSegmentByCount(*beforeSegment2, subSegmentCount=subSegmentCount) 143 | afterSegment1Points = _splitSegmentByCount(*afterSegment1, subSegmentCount=subSegmentCount) 144 | afterSegment2Points = _splitSegmentByCount(*afterSegment2, subSegmentCount=subSegmentCount) 145 | beforePoints = beforeSegment1Points + beforeSegment2Points[1:] 146 | afterPoints = afterSegment1Points + afterSegment2Points[1:] 147 | leashLength = beforeLength * tolerance 148 | isUnnecessary = True 149 | for i, b in enumerate(beforePoints): 150 | a = afterPoints[i] 151 | d = abs(distance(a, b)) 152 | if d > leashLength: 153 | isUnnecessary = False 154 | break 155 | if isUnnecessary: 156 | unnecessaryPoints.append(bPoint.anchor) 157 | return unnecessaryPoints 158 | 159 | def _makeBCPAbsolute(anchor, bcp): 160 | x1, y1 = anchor 161 | x2, y2 = bcp 162 | return (x1 + x2, y1 + y2) 163 | 164 | def _splitSegmentByCount(pt1, pt2, pt3, pt4, subSegmentCount=10): 165 | ts = [i / subSegmentCount for i in range(subSegmentCount + 1)] 166 | splits = ftBezierTools.splitCubicAtT(pt1, pt2, pt3, pt4, *ts) 167 | anchors = [] 168 | for segment in splits: 169 | anchors.append(segment[-1]) 170 | return anchors 171 | 172 | registry.registerTest( 173 | identifier="unnecessaryPoints", 174 | level="point", 175 | title="Unnecessary Points", 176 | description="One or more unnecessary points are present.", 177 | testFunction=testForUnnecessaryPoints, 178 | defconClass=defcon.Contour, 179 | destructiveNotifications=["Contour.PointsChanged"] 180 | ) 181 | 182 | # Overlapping Points 183 | 184 | def testForOverlappingPoints(contour): 185 | """ 186 | Consecutive points should not overlap. 187 | 188 | Data structure: 189 | 190 | [ 191 | point, 192 | ... 193 | ] 194 | """ 195 | contour = wrapContour(contour) 196 | overlappingPoints = [] 197 | if len(contour) > 1: 198 | prev = unwrapPoint(contour[-1].onCurve) 199 | for segment in contour: 200 | point = unwrapPoint(segment.onCurve) 201 | if point == prev: 202 | overlappingPoints.append(point) 203 | prev = point 204 | return overlappingPoints 205 | 206 | registry.registerTest( 207 | identifier="overlappingPoints", 208 | level="point", 209 | title="Overlapping Points", 210 | description="Two or more points are overlapping.", 211 | testFunction=testForOverlappingPoints, 212 | defconClass=defcon.Contour, 213 | destructiveNotifications=["Contour.PointsChanged"] 214 | ) 215 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/registry.py: -------------------------------------------------------------------------------- 1 | import defcon 2 | 3 | testRegistry = {} 4 | 5 | fallbackDestructiveNotifications = { 6 | defcon.Glyph : ["Glyph.Changed"], 7 | defcon.Contour : ["Contour.Changed"] 8 | } 9 | 10 | def registerTest( 11 | identifier=None, 12 | level=None, 13 | title=None, 14 | description=None, 15 | testFunction=None, 16 | defconClass=None, 17 | destructiveNotifications=None 18 | ): 19 | representationName = "GlyphNanny." + identifier 20 | if destructiveNotifications is None: 21 | destructiveNotifications = fallbackDestructiveNotifications.get(defconClass, None) 22 | defcon.registerRepresentationFactory( 23 | cls=defconClass, 24 | name=representationName, 25 | factory=testFunction, 26 | destructiveNotifications=destructiveNotifications 27 | ) 28 | testRegistry[identifier] = dict( 29 | level=level, 30 | description=description, 31 | title=title, 32 | representationName=representationName 33 | ) 34 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/segment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Segment level tests. 3 | """ 4 | 5 | from fontTools.misc import bezierTools as ftBezierTools 6 | import defcon 7 | from .tools import ( 8 | roundPoint, 9 | unwrapPoint, 10 | calculateAngle, 11 | calculateAngleOffset, 12 | calculateLineLineIntersection, 13 | calculateLineCurveIntersection, 14 | calculateLineLength, 15 | calculateLineThroughPoint 16 | ) 17 | from . import registry 18 | from .wrappers import * 19 | 20 | # Straight Lines 21 | 22 | def testForAngleNearMiss(contour): 23 | """ 24 | Lines shouldn't be just shy of vertical or horizontal. 25 | 26 | Data structure: 27 | 28 | set( 29 | (pt1, pt2), 30 | ... 31 | ) 32 | 33 | """ 34 | contour = wrapContour(contour) 35 | segments = contour.segments 36 | prev = unwrapPoint(segments[-1].onCurve) 37 | slightlyOffLines = set() 38 | for segment in segments: 39 | point = unwrapPoint(segment.onCurve) 40 | if segment[-1].type == "line": 41 | x = abs(prev[0] - point[0]) 42 | y = abs(prev[1] - point[1]) 43 | if x > 0 and x <= 5 and prev[1] != point[1]: 44 | slightlyOffLines.add((prev, point)) 45 | if y > 0 and y <= 5 and prev[0] != point[0]: 46 | slightlyOffLines.add((prev, point)) 47 | prev = point 48 | return slightlyOffLines 49 | 50 | registry.registerTest( 51 | identifier="angleNearMiss", 52 | level="segment", 53 | title="Angle Near Miss", 54 | description="One or more lines are nearly at important angles.", 55 | testFunction=testForAngleNearMiss, 56 | defconClass=defcon.Contour, 57 | destructiveNotifications=["Contour.PointsChanged"] 58 | ) 59 | 60 | # Segments Near Vertical Metrics 61 | 62 | def testForSegmentsNearVerticalMetrics(contour): 63 | """ 64 | Points shouldn't be just off a vertical metric or blue zone. 65 | 66 | Data structure: 67 | 68 | { 69 | vertical metric y value : set(pt, ...), 70 | ... 71 | } 72 | 73 | """ 74 | font = wrapFont(contour.font) 75 | glyph = wrapGlyph(contour.glyph) 76 | contour = wrapContour(contour) 77 | threshold = 5 78 | # gather the blues into top and bottom groups 79 | topZones = _makeZonePairs(font.info.postscriptBlueValues) 80 | bottomZones = _makeZonePairs(font.info.postscriptOtherBlues) 81 | if topZones: 82 | t = topZones[0] 83 | if t[0] <= 0 and t[1] == 0: 84 | bottomZones.append(topZones.pop(0)) 85 | # insert vertical metrics into the zones 86 | topMetrics = [getattr(font.info, attr) for attr in "xHeight capHeight ascender".split(" ") if getattr(font.info, attr) is not None] 87 | bottomMetrics = [getattr(font.info, attr) for attr in "descender".split(" ") if getattr(font.info, attr) is not None] + [0] 88 | for value in topMetrics: 89 | found = False 90 | for b, t in topZones: 91 | if b <= value and t >= value: 92 | found = True 93 | break 94 | if not found: 95 | topZones.append((value, value)) 96 | for value in bottomMetrics: 97 | found = False 98 | for b, t in bottomZones: 99 | if b <= value and t >= value: 100 | found = True 101 | break 102 | if not found: 103 | bottomZones.append((value, value)) 104 | # find points 105 | found = {} 106 | if len(contour) >= 3: 107 | for segmentIndex, segment in enumerate(contour): 108 | prev = segmentIndex - 1 109 | next = segmentIndex + 1 110 | if next == len(contour): 111 | next = 0 112 | prevSegment = contour[prev] 113 | nextSegment = contour[next] 114 | pt = (segment.onCurve.x, segment.onCurve.y) 115 | prevPt = (prevSegment.onCurve.x, prevSegment.onCurve.y) 116 | nextPt = (nextSegment.onCurve.x, nextSegment.onCurve.y) 117 | pY = prevPt[1] 118 | x, y = pt 119 | nY = nextPt[1] 120 | # top point 121 | if y >= pY and y >= nY: 122 | for b, t in topZones: 123 | test = None 124 | # point is above zone 125 | if y > t and abs(t - y) <= threshold: 126 | test = t 127 | # point is below zone 128 | elif y < b and abs(b - y) <= threshold: 129 | test = b 130 | if test is not None: 131 | if contour.pointInside((x, y - 1)): 132 | if test not in found: 133 | found[test] = set() 134 | found[test].add((x, y)) 135 | # bottom point 136 | if y <= pY and y <= nY: 137 | for b, t in bottomZones: 138 | test = None 139 | # point is above zone 140 | if y > t and abs(t - y) <= threshold: 141 | test = t 142 | # point is below zone 143 | elif y < b and abs(b - y) <= threshold: 144 | test = b 145 | if test is not None: 146 | if contour.pointInside((x, y + 1)): 147 | if test not in found: 148 | found[test] = set() 149 | found[test].add((x, y)) 150 | return found 151 | 152 | def _makeZonePairs(blues): 153 | blues = list(blues) 154 | pairs = [] 155 | if not len(blues) % 2: 156 | while blues: 157 | bottom = blues.pop(0) 158 | top = blues.pop(0) 159 | pairs.append((bottom, top)) 160 | return pairs 161 | 162 | registry.registerTest( 163 | identifier="pointsNearVerticalMetrics", 164 | level="segment", 165 | title="Near Vertical Metrics", 166 | description="Two or more points are just off a vertical metric.", 167 | testFunction=testForSegmentsNearVerticalMetrics, 168 | defconClass=defcon.Contour, 169 | destructiveNotifications=["Contour.PointsChanged"] 170 | ) 171 | 172 | # Unsmooth Smooths 173 | 174 | def testUnsmoothSmooths(contour): 175 | """ 176 | Smooth segments should have bcps in the right places. 177 | 178 | Data structure: 179 | 180 | [ 181 | (offcurvePoint, point, offcurvePoint), 182 | ... 183 | ] 184 | """ 185 | contour = wrapContour(contour) 186 | unsmoothSmooths = [] 187 | prev = contour[-1] 188 | for segment in contour: 189 | if prev.type == "curve" and segment.type == "curve": 190 | if prev.smooth: 191 | angle1 = calculateAngle(prev.offCurve[1], prev.onCurve, r=0) 192 | angle2 = calculateAngle(prev.onCurve, segment.offCurve[0], r=0) 193 | if angle1 != angle2: 194 | pt1 = unwrapPoint(prev.offCurve[1]) 195 | pt2 = unwrapPoint(prev.onCurve) 196 | pt3 = unwrapPoint(segment.offCurve[0]) 197 | unsmoothSmooths.append((pt1, pt2, pt3)) 198 | prev = segment 199 | return unsmoothSmooths 200 | 201 | registry.registerTest( 202 | identifier="unsmoothSmooths", 203 | level="segment", 204 | title="Unsmooth Smooths", 205 | description="One or more smooth points do not have handles that are properly placed.", 206 | testFunction=testUnsmoothSmooths, 207 | defconClass=defcon.Contour, 208 | destructiveNotifications=["Contour.PointsChanged"] 209 | ) 210 | 211 | # Complex Curves 212 | 213 | def testForComplexCurves(contour): 214 | """ 215 | S curves are suspicious. 216 | 217 | Data structure: 218 | 219 | [ 220 | (onCurve, offCurve, offCurve, onCurve), 221 | ... 222 | ] 223 | """ 224 | contour = wrapContour(contour) 225 | impliedS = [] 226 | prev = unwrapPoint(contour[-1].onCurve) 227 | for segment in contour: 228 | if segment.type == "curve": 229 | pt0 = prev 230 | pt1, pt2 = [unwrapPoint(p) for p in segment.offCurve] 231 | pt3 = unwrapPoint(segment.onCurve) 232 | line1 = (pt0, pt3) 233 | line2 = (pt1, pt2) 234 | if calculateLineLineIntersection(line1, line2): 235 | impliedS.append((prev, pt1, pt2, pt3)) 236 | prev = unwrapPoint(segment.onCurve) 237 | return impliedS 238 | 239 | registry.registerTest( 240 | identifier="complexCurves", 241 | level="segment", 242 | title="Complex Curves", 243 | description="One or more curves is suspiciously complex.", 244 | testFunction=testForComplexCurves, 245 | defconClass=defcon.Contour, 246 | destructiveNotifications=["Contour.PointsChanged"] 247 | ) 248 | 249 | 250 | # Crossed Handles 251 | 252 | def testForCrossedHandles(contour): 253 | """ 254 | Handles shouldn't intersect. 255 | 256 | Data structure: 257 | 258 | [ 259 | { 260 | points : (pt1, pt2, pt3, pt4), 261 | intersection : pt 262 | }, 263 | ... 264 | ] 265 | """ 266 | contour = wrapContour(contour) 267 | crossedHandles = [] 268 | pt0 = unwrapPoint(contour[-1].onCurve) 269 | for segment in contour: 270 | pt3 = unwrapPoint(segment.onCurve) 271 | if segment.type == "curve": 272 | pt1, pt2 = [unwrapPoint(p) for p in segment.offCurve] 273 | # direct intersection 274 | direct = calculateLineLineIntersection((pt0, pt1), (pt2, pt3)) 275 | if direct: 276 | if _crossedHanldeWithNoOtherOptions(direct, pt0, pt1, pt2, pt3): 277 | pass 278 | else: 279 | crossedHandles.append(dict(points=(pt0, pt1, pt2, pt3), intersection=direct)) 280 | # indirect intersection 281 | else: 282 | while 1: 283 | # bcp1 = ray, bcp2 = segment 284 | angle = calculateAngle(pt0, pt1) 285 | if angle in (0, 180.0): 286 | t1 = (pt0[0] + 1000, pt0[1]) 287 | t2 = (pt0[0] - 1000, pt0[1]) 288 | else: 289 | yOffset = calculateAngleOffset(angle, 1000) 290 | t1 = (pt0[0] + 1000, pt0[1] + yOffset) 291 | t2 = (pt0[0] - 1000, pt0[1] - yOffset) 292 | indirect = calculateLineLineIntersection((t1, t2), (pt2, pt3)) 293 | if indirect: 294 | if _crossedHanldeWithNoOtherOptions(indirect, pt0, pt1, pt2, pt3): 295 | pass 296 | else: 297 | crossedHandles.append(dict(points=(pt0, indirect, pt2, pt3), intersection=indirect)) 298 | break 299 | # bcp1 = segment, bcp2 = ray 300 | angle = calculateAngle(pt3, pt2) 301 | if angle in (90.0, 270.0): 302 | t1 = (pt3[0], pt3[1] + 1000) 303 | t2 = (pt3[0], pt3[1] - 1000) 304 | else: 305 | yOffset = calculateAngleOffset(angle, 1000) 306 | t1 = (pt3[0] + 1000, pt3[1] + yOffset) 307 | t2 = (pt3[0] - 1000, pt3[1] - yOffset) 308 | indirect = calculateLineLineIntersection((t1, t2), (pt0, pt1)) 309 | if indirect: 310 | if _crossedHanldeWithNoOtherOptions(indirect, pt0, pt1, pt2, pt3): 311 | pass 312 | else: 313 | crossedHandles.append(dict(points=(pt0, pt1, indirect, pt3), intersection=indirect)) 314 | break 315 | break 316 | pt0 = pt3 317 | return crossedHandles 318 | 319 | def _crossedHanldeWithNoOtherOptions(hit, pt0, pt1, pt2, pt3): 320 | hitWidth = max((abs(hit[0] - pt0[0]), abs(hit[0] - pt3[0]))) 321 | hitHeight = max((abs(hit[1] - pt0[1]), abs(hit[1] - pt3[1]))) 322 | w = abs(pt0[0] - pt3[0]) 323 | h = abs(pt0[1] - pt3[1]) 324 | bw = max((abs(pt0[0] - pt1[0]), abs(pt3[0] - pt2[0]))) 325 | bh = max((abs(pt0[1] - pt1[1]), abs(pt3[1] - pt2[1]))) 326 | if w == 1 and bw == 1 and not bh > h: 327 | return True 328 | elif h == 1 and bh == 1 and not bw > w: 329 | return True 330 | return False 331 | 332 | registry.registerTest( 333 | identifier="crossedHandles", 334 | level="segment", 335 | title="Crossed Handles", 336 | description="One or more curves contain crossed handles.", 337 | testFunction=testForCrossedHandles, 338 | defconClass=defcon.Contour, 339 | destructiveNotifications=["Contour.PointsChanged"] 340 | ) 341 | 342 | 343 | # Unnecessary Handles 344 | 345 | def testForUnnecessaryHandles(contour): 346 | """ 347 | Handles shouldn't be used if they aren't doing anything. 348 | 349 | Data structure: 350 | 351 | [ 352 | (pt1, pt2), 353 | ... 354 | ] 355 | """ 356 | contour = wrapContour(contour) 357 | unnecessaryHandles = [] 358 | prevPoint = contour[-1].onCurve 359 | for segment in contour: 360 | if segment.type == "curve": 361 | pt0 = prevPoint 362 | pt1, pt2 = segment.offCurve 363 | pt3 = segment.onCurve 364 | lineAngle = calculateAngle(pt0, pt3, 0) 365 | bcpAngle1 = bcpAngle2 = None 366 | if (pt0.x, pt0.y) != (pt1.x, pt1.y): 367 | bcpAngle1 = calculateAngle(pt0, pt1, 0) 368 | if (pt2.x, pt2.y) != (pt3.x, pt3.y): 369 | bcpAngle2 = calculateAngle(pt2, pt3, 0) 370 | if bcpAngle1 == lineAngle and bcpAngle2 == lineAngle: 371 | unnecessaryHandles.append((unwrapPoint(pt1), unwrapPoint(pt2))) 372 | prevPoint = segment.onCurve 373 | return unnecessaryHandles 374 | 375 | registry.registerTest( 376 | identifier="unnecessaryHandles", 377 | level="segment", 378 | title="Unnecessary Handles", 379 | description="One or more curves has unnecessary handles.", 380 | testFunction=testForUnnecessaryHandles, 381 | defconClass=defcon.Contour, 382 | destructiveNotifications=["Contour.PointsChanged"] 383 | ) 384 | 385 | 386 | # Uneven Handles 387 | 388 | def testForUnevenHandles(contour): 389 | """ 390 | Handles should share the workload as evenly as possible. 391 | 392 | Data structure: 393 | 394 | [ 395 | (off1, off2, off1Shape, off2Shape), 396 | ... 397 | ] 398 | 399 | """ 400 | contour = wrapContour(contour) 401 | unevenHandles = [] 402 | prevPoint = contour[-1].onCurve 403 | for segment in contour: 404 | if segment.type == "curve": 405 | # create rays perpendicular to the 406 | # angle between the on and off 407 | # through the on 408 | on1 = unwrapPoint(prevPoint) 409 | off1, off2 = [unwrapPoint(pt) for pt in segment.offCurve] 410 | on2 = unwrapPoint(segment.onCurve) 411 | curve = (on1, off1, off2, on2) 412 | off1Angle = calculateAngle(on1, off1) - 90 413 | on1Ray = calculateLineThroughPoint(on1, off1Angle) 414 | off2Angle = calculateAngle(off2, on2) - 90 415 | on2Ray = calculateLineThroughPoint(on2, off2Angle) 416 | # find the intersection of the rays 417 | rayIntersection = calculateLineLineIntersection(on1Ray, on2Ray) 418 | if rayIntersection is not None: 419 | # draw a line between the off curves and the intersection 420 | # and find out where these lines intersect the curve 421 | off1Intersection = calculateLineCurveIntersection((off1, rayIntersection), curve) 422 | off2Intersection = calculateLineCurveIntersection((off2, rayIntersection), curve) 423 | if off1Intersection is not None and off2Intersection is not None: 424 | if off1Intersection.points and off2Intersection.points: 425 | off1IntersectionPoint = (off1Intersection.points[0].x, off1Intersection.points[0].y) 426 | off2IntersectionPoint = (off2Intersection.points[0].x, off2Intersection.points[0].y) 427 | # assemble the off curves and their intersections into lines 428 | off1Line = (off1, off1IntersectionPoint) 429 | off2Line = (off2, off2IntersectionPoint) 430 | # measure and compare these 431 | # if they are not both very short calculate the ratio 432 | length1, length2 = sorted((calculateLineLength(*off1Line), calculateLineLength(*off2Line))) 433 | if length1 >= 3 and length2 >= 3: 434 | ratio = length2 / float(length1) 435 | # if outside acceptable range, flag 436 | if ratio > 1.5: 437 | off1Shape = _getUnevenHandleShape(on1, off1, off2, on2, off1Intersection, on1, off1IntersectionPoint, off1) 438 | off2Shape = _getUnevenHandleShape(on1, off1, off2, on2, off2Intersection, off2IntersectionPoint, on2, off2) 439 | unevenHandles.append((off1, off2, off1Shape, off2Shape)) 440 | prevPoint = segment.onCurve 441 | return unevenHandles 442 | 443 | def _getUnevenHandleShape(pt0, pt1, pt2, pt3, intersection, start, end, off): 444 | splitSegments = ftBezierTools.splitCubicAtT(pt0, pt1, pt2, pt3, *intersection.t) 445 | curves = [] 446 | for segment in splitSegments: 447 | if roundPoint(segment[0]) != roundPoint(start) and not curves: 448 | continue 449 | curves.append(segment[1:]) 450 | if roundPoint(segment[-1]) == roundPoint(end): 451 | break 452 | return curves + [off, start] 453 | 454 | registry.registerTest( 455 | identifier="unevenHandles", 456 | level="segment", 457 | title="Uneven Handles", 458 | description="One or more curves has uneven handles.", 459 | testFunction=testForUnevenHandles, 460 | defconClass=defcon.Contour, 461 | destructiveNotifications=["Contour.PointsChanged"] 462 | ) -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/tools.py: -------------------------------------------------------------------------------- 1 | import math 2 | from fontTools.misc.arrayTools import calcBounds 3 | from lib.tools import bezierTools as rfBezierTools 4 | 5 | # ----------- 6 | # Conversions 7 | # ----------- 8 | 9 | def roundPoint(pt): 10 | return round(pt[0]), round(pt[1]) 11 | 12 | def unwrapPoint(pt): 13 | return pt.x, pt.y 14 | 15 | def convertBoundsToRect(bounds): 16 | if bounds is None: 17 | return (0, 0, 0, 0) 18 | xMin, yMin, xMax, yMax = bounds 19 | x = xMin 20 | y = yMin 21 | w = xMax - xMin 22 | h = yMax - yMin 23 | return (x, y, w, h) 24 | 25 | def getOnCurves(contour): 26 | points = set() 27 | for segment in contour: 28 | pt = segment.onCurve 29 | points.add((pt.x, pt.y)) 30 | return points 31 | 32 | # ------------ 33 | # Calculations 34 | # ------------ 35 | 36 | def calculateMidpoint(*points): 37 | if len(points) != 2: 38 | xMin, yMin, xMax, yMax = calcBounds(points) 39 | points = ( 40 | (xMin, yMin), 41 | (xMax, yMax) 42 | ) 43 | pt1, pt2 = points 44 | x1, y1 = pt1 45 | x2, y2 = pt2 46 | x = (x1 + x2) / 2 47 | y = (y1 + y2) / 2 48 | return (x, y) 49 | 50 | def calculateAngle(point1, point2, r=None): 51 | if not isinstance(point1, tuple): 52 | point1 = unwrapPoint(point1) 53 | if not isinstance(point2, tuple): 54 | point2 = unwrapPoint(point2) 55 | width = point2[0] - point1[0] 56 | height = point2[1] - point1[1] 57 | angle = round(math.atan2(height, width) * 180 / math.pi, 3) 58 | if r is not None: 59 | angle = round(angle, r) 60 | return angle 61 | 62 | def calculateLineLineIntersection(a1a2, b1b2): 63 | # adapted from: http://www.kevlindev.com/gui/math/intersection/Intersection.js 64 | a1, a2 = a1a2 65 | b1, b2 = b1b2 66 | ua_t = (b2[0] - b1[0]) * (a1[1] - b1[1]) - (b2[1] - b1[1]) * (a1[0] - b1[0]) 67 | ub_t = (a2[0] - a1[0]) * (a1[1] - b1[1]) - (a2[1] - a1[1]) * (a1[0] - b1[0]) 68 | u_b = (b2[1] - b1[1]) * (a2[0] - a1[0]) - (b2[0] - b1[0]) * (a2[1] - a1[1]) 69 | if u_b != 0: 70 | ua = ua_t / float(u_b) 71 | ub = ub_t / float(u_b) 72 | if 0 <= ua and ua <= 1 and 0 <= ub and ub <= 1: 73 | return a1[0] + ua * (a2[0] - a1[0]), a1[1] + ua * (a2[1] - a1[1]) 74 | else: 75 | return None 76 | else: 77 | return None 78 | 79 | def calculateLineCurveIntersection(line, curve): 80 | points = curve + line 81 | intersection = rfBezierTools.intersectCubicLine(*points) 82 | return intersection 83 | 84 | def calculateAngleOffset(angle, distance): 85 | A = 90 86 | B = angle 87 | C = 180 - (A + B) 88 | if C == 0: 89 | return 0 90 | c = distance 91 | A = math.radians(A) 92 | B = math.radians(B) 93 | C = math.radians(C) 94 | b = (c * math.sin(B)) / math.sin(C) 95 | return round(b, 5) 96 | 97 | def calculateLineLength(pt1, pt2): 98 | return math.hypot(pt1[0] - pt2[0], pt1[1] - pt2[1]) 99 | 100 | def calculateAreaOfTriangle(pt1, pt2, pt3): 101 | a = calculateLineLength(pt1, pt2) 102 | b = calculateLineLength(pt2, pt3) 103 | c = calculateLineLength(pt3, pt1) 104 | s = (a + b + c) / 2.0 105 | area = math.sqrt(s * (s - a) * (s - b) * (s - c)) 106 | return area 107 | 108 | def calculateLineThroughPoint(pt, angle): 109 | angle = math.radians(angle) 110 | length = 100000 111 | x1 = math.cos(angle) * -length + pt[0] 112 | y1 = math.sin(angle) * -length + pt[1] 113 | x2 = math.cos(angle) * length + pt[0] 114 | y2 = math.sin(angle) * length + pt[1] 115 | return (x1, y1), (x2, y2) 116 | -------------------------------------------------------------------------------- /source/code/glyphNanny/tests/wrappers.py: -------------------------------------------------------------------------------- 1 | """ 2 | These functions convert the incoming 3 | objects to fontParts objects if necessary. 4 | """ 5 | 6 | __all__ = ( 7 | "wrapFont", 8 | "wrapGlyph", 9 | "wrapContour" 10 | ) 11 | 12 | import defcon 13 | from fontParts.world import dispatcher 14 | 15 | RFont = dispatcher["RFont"] 16 | RGlyph = dispatcher["RGlyph"] 17 | RContour = dispatcher["RContour"] 18 | 19 | def wrapFont(font): 20 | if isinstance(font, defcon.Font): 21 | return RFont(font) 22 | return font 23 | 24 | def wrapGlyph(glyph): 25 | if isinstance(glyph, defcon.Glyph): 26 | return RGlyph(glyph) 27 | return glyph 28 | 29 | def wrapContour(contour): 30 | if isinstance(contour, defcon.Contour): 31 | return RContour(contour) 32 | return contour -------------------------------------------------------------------------------- /source/code/launch.py: -------------------------------------------------------------------------------- 1 | import glyphNanny.defaults 2 | import glyphNanny.editorLayers -------------------------------------------------------------------------------- /source/code/menu_showFontTester.py: -------------------------------------------------------------------------------- 1 | from mojo.roboFont import OpenWindow 2 | from glyphNanny.fontTestWindow import GlyphNannyFontTestWindow 3 | 4 | OpenWindow(GlyphNannyFontTestWindow) -------------------------------------------------------------------------------- /source/code/menu_showPrefs.py: -------------------------------------------------------------------------------- 1 | from mojo.roboFont import OpenWindow 2 | from glyphNanny.defaultsWindow import GlyphNannyDefaultsWindow 3 | 4 | OpenWindow(GlyphNannyDefaultsWindow) -------------------------------------------------------------------------------- /source/code/menu_toggleObserverVisibility.py: -------------------------------------------------------------------------------- 1 | from mojo.events import postEvent 2 | from glyphNanny import defaults 3 | 4 | state = defaults.getDisplayLiveReport() 5 | defaults.setDisplayLiveReport(not state) 6 | postEvent( 7 | defaults.defaultKeyStub + ".defaultsChanged" 8 | ) -------------------------------------------------------------------------------- /test.ufo/fontinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ascender 6 | 750 7 | capHeight 8 | 750 9 | descender 10 | -250 11 | guidelines 12 | 13 | postscriptBlueValues 14 | 15 | postscriptFamilyBlues 16 | 17 | postscriptFamilyOtherBlues 18 | 19 | postscriptOtherBlues 20 | 21 | postscriptStemSnapH 22 | 23 | 40 24 | 25 | postscriptStemSnapV 26 | 27 | 40 28 | 250 29 | 30 | unitsPerEm 31 | 1000 32 | xHeight 33 | 500 34 | 35 | 36 | -------------------------------------------------------------------------------- /test.ufo/glyphs.background/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test.ufo/glyphs.background/layerinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | color 6 | 0,0,1,0.4 7 | lib 8 | 9 | org.unifiedfontobject.normalizer.imageReferences 10 | 11 | org.unifiedfontobject.normalizer.modTimes 12 | version: 0.3.1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test.ufo/glyphs/A_.glif: -------------------------------------------------------------------------------- 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 | This test case is for evaluating uneven handles. 63 | 64 | 65 | -------------------------------------------------------------------------------- /test.ufo/glyphs/A_grave.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | This test case is for duplicated components 11 | 12 | 13 | -------------------------------------------------------------------------------- /test.ufo/glyphs/B_.glif: -------------------------------------------------------------------------------- 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 | This test case is used for evaluating the stem finder. 105 | 106 | 107 | -------------------------------------------------------------------------------- /test.ufo/glyphs/C_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | This test case is for evaluating asymmetric curves. 24 | 25 | 26 | -------------------------------------------------------------------------------- /test.ufo/glyphs/D_.glif: -------------------------------------------------------------------------------- 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 | This test case is for testing crossed bcps. 64 | 65 | 66 | -------------------------------------------------------------------------------- /test.ufo/glyphs/E_.alt1.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test.ufo/glyphs/E_.alt2.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test.ufo/glyphs/E_.alt3.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test.ufo/glyphs/E_.glif: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /test.ufo/glyphs/F_.glif: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test.ufo/glyphs/contents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A 6 | A_.glif 7 | Agrave 8 | A_grave.glif 9 | B 10 | B_.glif 11 | C 12 | C_.glif 13 | D 14 | D_.glif 15 | E 16 | E_.glif 17 | E.alt1 18 | E_.alt1.glif 19 | E.alt2 20 | E_.alt2.glif 21 | E.alt3 22 | E_.alt3.glif 23 | F 24 | F_.glif 25 | 26 | 27 | -------------------------------------------------------------------------------- /test.ufo/glyphs/layerinfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | color 6 | 1,0.75,0,0.7 7 | lib 8 | 9 | org.unifiedfontobject.normalizer.imageReferences 10 | 11 | org.unifiedfontobject.normalizer.modTimes 12 | version: 0.3.1 13 | 1610987097.0 A_.glif 14 | 1610987097.0 A_grave.glif 15 | 1610987097.0 B_.glif 16 | 1610987097.0 C_.glif 17 | 1610987097.0 D_.glif 18 | 1610987097.0 E_.alt1.glif 19 | 1610987097.0 E_.alt2.glif 20 | 1610987097.0 E_.alt3.glif 21 | 1611008077.2 E_.glif 22 | 1610987097.0 F_.glif 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test.ufo/layercontents.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | foreground 7 | glyphs 8 | 9 | 10 | background 11 | glyphs.background 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test.ufo/metainfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | creator 6 | com.github.fonttools.ufoLib 7 | formatVersion 8 | 3 9 | 10 | 11 | --------------------------------------------------------------------------------