├── .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 | 
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 |
--------------------------------------------------------------------------------