├── .gitignore ├── .DS_Store ├── Interpolation ├── MakeNodeFirst.py ├── CountOnCurvePoints.py └── CompatibilityHelper.py ├── Metrics ├── AverageWidth.py ├── SetSpacingGroups.py ├── ChangeWidthCentered.py └── FindMetrics.py ├── Paths ├── DeleteAllPaths.py ├── KeepLargestPath.py ├── DeleteLargestPath.py ├── DeleteSmallestPath.py ├── RandomlyMovePoints.py ├── SimplifyShape.py ├── CreateDropShadow.py ├── CreateSignPainterDropShadow.py ├── CreateCastShadow.py ├── DeleteXPath.py └── CreateHandtooledShadow.py ├── Guides └── LocalGuidelines.py ├── Components ├── ReverseComponentPathDirection.py ├── ResetAllComponents.py ├── MirrorComponentsAcrossMasters.py ├── AddOrReplaceComponent.py └── AddCornerComponent.py ├── README.md ├── Glyph Names └── SwapGlyphNames.py ├── Font Info └── SetAxisLocation.py ├── LICENSE └── Anchors └── MirrorAnchorAcrossMasters.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylewaynebenson/Glyphs-Scripts/HEAD/.DS_Store -------------------------------------------------------------------------------- /Interpolation/MakeNodeFirst.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Make Node First 2 | #Created by Kyle Wayne Benson 3 | #Created this script so that I could assign a keyboard shortcut to this right-click function 4 | # -*- coding: utf-8 -*- 5 | sel = Layer.selection 6 | if len(sel) == 1 and type(sel[0]) == GSNode: 7 | sel[0].makeNodeFirst() 8 | -------------------------------------------------------------------------------- /Interpolation/CountOnCurvePoints.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Count On Curve Points 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Count and compare on curve points between layers 6 | """ 7 | import vanilla.dialogs as vd 8 | import GlyphsApp 9 | 10 | def countMyNodes( thisLayer): 11 | nodeTotal = 0 12 | for thisPath in thisLayer.paths: 13 | for thisNode in thisPath.nodes: 14 | if (thisNode.type == GSCURVE) or (thisNode.type == LINE): 15 | nodeTotal += 1 16 | return nodeTotal 17 | 18 | try: 19 | font = Glyphs.font 20 | # get active layer 21 | layer = font.selectedLayers[0] 22 | # get glyph of this layer 23 | glyph = layer.parent 24 | 25 | # access all layers of this glyph 26 | 27 | Glyphs.showMacroWindow() 28 | message_text = "Oncurve points\n\n" 29 | for layer in glyph.layers: 30 | print(layer.name, ": ", countMyNodes( layer)) 31 | message_text += layer.name + ": " + str(countMyNodes(layer)) + "\n" 32 | vd.message(message_text) 33 | 34 | except Exception as e: 35 | # print error 36 | Glyphs.showMacroWindow() 37 | print("CountOnCurvePoints Error: %s" % e) 38 | -------------------------------------------------------------------------------- /Metrics/AverageWidth.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Average Width 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Figure out the average width of selected glyphs in a layer 6 | """ 7 | 8 | import vanilla.dialogs as vd 9 | import GlyphsApp 10 | 11 | 12 | def averageWidth(): 13 | Glyphs.clearLog() 14 | try: 15 | averageWidth = 0 16 | count = 0 17 | alreadyCheckedGlyphs = [] 18 | msg_txt = "" 19 | for layer in Glyphs.font.selectedLayers: 20 | thisGlyph = layer.parent 21 | 22 | if thisGlyph.name is None: continue 23 | if thisGlyph.name == "None": continue 24 | if thisGlyph.name in alreadyCheckedGlyphs: continue # this will omitt glyphs.width that was already checked 25 | 26 | averageWidth += layer.width 27 | count += 1 28 | print("\t", thisGlyph.name) 29 | msg_txt += "'" + str(thisGlyph.name) + "' " + "width: " + str(layer.width) + "\n" 30 | print("\t" "Width =>", layer.width) 31 | alreadyCheckedGlyphs.append(thisGlyph.name) 32 | 33 | averageWidth = averageWidth/count 34 | print("Average Width =>", averageWidth) 35 | msg_txt += "\n\nAverage Width: %s" % averageWidth 36 | vd.message(msg_txt) 37 | 38 | except Exception as e: 39 | # print error 40 | Glyphs.showMacroWindow() 41 | print("Change Width Centered Error: %s" % e) 42 | 43 | averageWidth() 44 | -------------------------------------------------------------------------------- /Metrics/SetSpacingGroups.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Set Spacing Groups 2 | # -*- coding: utf-8 -*- 3 | # Created by Kyle Wayne Benson December 10, 2017 4 | __doc__=""" 5 | Set Spacing Groups to spacing.extension if .extension is added 6 | """ 7 | 8 | import GlyphsApp 9 | 10 | Font = Glyphs.font 11 | FontMaster = Font.selectedFontMaster 12 | selectedLayers = Font.selectedLayers 13 | selectedLayer = selectedLayers[0] 14 | try: 15 | # until v2.1: 16 | selection = selectedLayer.selection() 17 | except: 18 | # since v2.2: 19 | selection = selectedLayer.selection 20 | 21 | def glyphExists(glyphName): 22 | return glyphName in Font.glyphs 23 | 24 | Glyphs.showMacroWindow() 25 | for thisLayer in selectedLayers: 26 | thisGlyph = thisLayer.parent 27 | try: 28 | thisGlyph.beginUndo() 29 | extension = thisGlyph.name.rsplit('.', 2)[1] 30 | leftGuideName = "_space." + extension[:3] 31 | rightGuideName = "_space." + extension[-3:] 32 | if "=_space." not in str(thisGlyph.leftMetricsKey): 33 | if (leftGuideName != thisGlyph.name): 34 | if glyphExists(leftGuideName): 35 | thisGlyph.color = 7 # change color dark blue 36 | thisGlyph.leftMetricsKey = leftGuideName 37 | else: 38 | print( "!\t" + thisGlyph.name + "\t has _space formula LSB") 39 | if "=_space." not in str(thisGlyph.rightMetricsKey): 40 | if (rightGuideName != thisGlyph.name): 41 | if glyphExists(rightGuideName): 42 | thisGlyph.color = 8 # change color purple 43 | thisGlyph.rightMetricsKey = rightGuideName 44 | if (glyphExists(leftGuideName) & glyphExists(rightGuideName)): 45 | thisGlyph.color = 9 # change color magenta 46 | else: 47 | print( "!\t" + thisGlyph.name + "\t has _space formula RSB") 48 | thisGlyph.endUndo() 49 | except: 50 | print( "!\t" + thisGlyph.name + "\t is not special") 51 | -------------------------------------------------------------------------------- /Paths/DeleteAllPaths.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Delete All Paths 2 | #Description: Deletes all paths in selected glyphs. 3 | #Created by Kyle Wayne Benson 4 | # -*- coding: utf-8 -*- 5 | __doc__=""" 6 | Finds and deletes all paths 7 | """ 8 | from AppKit import NSMutableIndexSet 9 | import GlyphsApp 10 | thisFont = Glyphs.font # frontmost font 11 | thisFontMaster = thisFont.selectedFontMaster # active master 12 | listOfSelectedLayers = thisFont.selectedLayers # active layers of selected glyphs 13 | 14 | def deleteSmallestPath( thisLayer ): 15 | indexesOfPathsToBeRemoved = [] 16 | # layerarea = [] 17 | # for thisPath in thisLayer.paths: 18 | # layerarea.append(thisPath.area()) 19 | if Glyphs.versionNumber >= 3: 20 | # Glyphs 3 code 21 | for index, shape in enumerate(thisLayer.shapes): 22 | if shape.shapeType == GSShapeTypePath: 23 | indexesOfPathsToBeRemoved.append(index) 24 | try: 25 | indexes = NSMutableIndexSet.alloc().init() 26 | for i in indexesOfPathsToBeRemoved: 27 | 28 | indexes.addIndex_(i) 29 | thisLayer.removeShapesAtIndexes_(indexes) 30 | except Exception as e: 31 | print(e) 32 | 33 | else: 34 | # Glyphs 2 code 35 | numberOfPaths = len(thisLayer.paths) 36 | for thisPathNumber in range( numberOfPaths ): 37 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 38 | 39 | if indexesOfPathsToBeRemoved: 40 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved ) ): 41 | thisLayer.removePathAtIndex_( thatIndex ) 42 | 43 | thisFont.disableUpdateInterface() # suppresses UI updates in Font View 44 | 45 | for thisLayer in listOfSelectedLayers: 46 | thisGlyph = thisLayer.parent 47 | thisGlyph.beginUndo() # begin undo grouping 48 | deleteSmallestPath( thisLayer ) 49 | thisGlyph.endUndo() # end undo grouping 50 | 51 | thisFont.enableUpdateInterface() # re-enables UI updates in Font View -------------------------------------------------------------------------------- /Paths/KeepLargestPath.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Keep Largest Path 2 | #Created by Kyle Wayne Benson 3 | #Description: Deletes all paths in selected glyphs except for the largest path. 4 | # -*- coding: utf-8 -*- 5 | __doc__=""" 6 | deletes everything except for the largest path 7 | """ 8 | 9 | from AppKit import NSMutableIndexSet 10 | import GlyphsApp 11 | thisFont = Glyphs.font # frontmost font 12 | thisFontMaster = thisFont.selectedFontMaster # active master 13 | listOfSelectedLayers = thisFont.selectedLayers # active layers of selected glyphs 14 | 15 | def deleteLargestPath( thisLayer ): 16 | layerarea = [] 17 | for thisPath in thisLayer.paths: 18 | layerarea.append(thisPath.area()) 19 | if Glyphs.versionNumber >= 3: 20 | # Glyphs 3 code 21 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 22 | 23 | for i, thisPath in enumerate(thisLayer.shapes) : 24 | if thisPath.area() != max(layerarea): 25 | pathsToBeRemoved.addIndex_( i ) 26 | 27 | 28 | thisLayer.removeShapesAtIndexes_( pathsToBeRemoved ) 29 | else: 30 | # Glyphs 2 code 31 | indexesOfPathsToBeRemoved = [] 32 | 33 | numberOfPaths = len(thisLayer.paths) 34 | for thisPathNumber in range( numberOfPaths ): 35 | if thisPathNumber < (numberOfPaths - 1): 36 | thisPath = thisLayer.paths[thisPathNumber] 37 | if thisPath.area() != max(layerarea): 38 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 39 | 40 | if indexesOfPathsToBeRemoved: 41 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved )): 42 | thisLayer.removePathAtIndex_( thatIndex ) 43 | 44 | thisFont.disableUpdateInterface() # suppresses UI updates in Font View 45 | 46 | for thisLayer in listOfSelectedLayers: 47 | thisGlyph = thisLayer.parent 48 | thisGlyph.beginUndo() # begin undo grouping 49 | deleteLargestPath( thisLayer ) 50 | thisGlyph.endUndo() # end undo grouping 51 | 52 | thisFont.enableUpdateInterface() # re-enables UI updates in Font View -------------------------------------------------------------------------------- /Paths/DeleteLargestPath.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Delete Largest Path 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Finds and deletes the largest path. I created this for deleting the outline after using my cast shadow script so I could have a fill shape. 6 | """ 7 | from AppKit import NSMutableIndexSet 8 | import GlyphsApp 9 | thisFont = Glyphs.font # frontmost font 10 | thisFontMaster = thisFont.selectedFontMaster # active master 11 | listOfSelectedLayers = thisFont.selectedLayers # active layers of selected glyphs 12 | 13 | def deleteLargestPath( thisLayer ): 14 | layerarea = [] 15 | for thisPath in thisLayer.paths: 16 | layerarea.append(thisPath.area()) 17 | if Glyphs.versionNumber >= 3: 18 | # Glyphs 3 code 19 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 20 | 21 | for i, thisPath in enumerate(thisLayer.shapes) : 22 | if thisPath.area() == max(layerarea): 23 | pathsToBeRemoved.addIndex_( i ) 24 | 25 | 26 | thisLayer.removeShapesAtIndexes_( pathsToBeRemoved ) 27 | else: 28 | # Glyphs 2 code 29 | indexesOfPathsToBeRemoved = [] 30 | 31 | numberOfPaths = len(thisLayer.paths) 32 | for thisPathNumber in range( numberOfPaths ): 33 | if thisPathNumber < (numberOfPaths - 1): 34 | thisPath = thisLayer.paths[thisPathNumber] 35 | if thisPath.area() == max(layerarea): 36 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 37 | 38 | if indexesOfPathsToBeRemoved: 39 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved )): 40 | thisLayer.removePathAtIndex_( thatIndex ) 41 | 42 | thisFont.disableUpdateInterface() # suppresses UI updates in Font View 43 | 44 | for thisLayer in listOfSelectedLayers: 45 | thisGlyph = thisLayer.parent 46 | thisGlyph.beginUndo() # begin undo grouping 47 | deleteLargestPath( thisLayer ) 48 | thisGlyph.endUndo() # end undo grouping 49 | 50 | thisFont.enableUpdateInterface() # re-enables UI updates in Font View -------------------------------------------------------------------------------- /Paths/DeleteSmallestPath.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Delete Smallest Path 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Finds and deletes the smallest path. I created this for deleting the outline after using my cast shadow script so I could have a fill shape. 6 | """ 7 | 8 | from AppKit import NSMutableIndexSet 9 | import GlyphsApp 10 | thisFont = Glyphs.font # frontmost font 11 | thisFontMaster = thisFont.selectedFontMaster # active master 12 | listOfSelectedLayers = thisFont.selectedLayers # active layers of selected glyphs 13 | 14 | def deleteLargestPath( thisLayer ): 15 | layerarea = [] 16 | for thisPath in thisLayer.paths: 17 | layerarea.append(thisPath.area()) 18 | if Glyphs.versionNumber >= 3: 19 | # Glyphs 3 code 20 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 21 | 22 | for i, thisPath in enumerate(thisLayer.shapes) : 23 | if thisPath.area() == min(layerarea): 24 | pathsToBeRemoved.addIndex_( i ) 25 | 26 | 27 | thisLayer.removeShapesAtIndexes_( pathsToBeRemoved ) 28 | else: 29 | # Glyphs 2 code 30 | indexesOfPathsToBeRemoved = [] 31 | 32 | numberOfPaths = len(thisLayer.paths) 33 | for thisPathNumber in range( numberOfPaths ): 34 | if thisPathNumber < (numberOfPaths - 1): 35 | thisPath = thisLayer.paths[thisPathNumber] 36 | if thisPath.area() == min(layerarea): 37 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 38 | 39 | if indexesOfPathsToBeRemoved: 40 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved )): 41 | thisLayer.removePathAtIndex_( thatIndex ) 42 | 43 | thisFont.disableUpdateInterface() # suppresses UI updates in Font View 44 | 45 | for thisLayer in listOfSelectedLayers: 46 | thisGlyph = thisLayer.parent 47 | thisGlyph.beginUndo() # begin undo grouping 48 | deleteLargestPath( thisLayer ) 49 | thisGlyph.endUndo() # end undo grouping 50 | 51 | thisFont.enableUpdateInterface() # re-enables UI updates in Font View -------------------------------------------------------------------------------- /Guides/LocalGuidelines.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Add Local Guidelines 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Adds guidelines accross font based on guides found in various guide.extension glyphs 5 | """ 6 | 7 | import GlyphsApp 8 | 9 | Font = Glyphs.font 10 | FontMaster = Font.selectedFontMaster 11 | selectedLayers = Font.selectedLayers 12 | selectedLayer = selectedLayers[0] 13 | try: 14 | # until v2.1: 15 | selection = selectedLayer.selection() 16 | except: 17 | # since v2.2: 18 | selection = selectedLayer.selection 19 | 20 | 21 | def addAndSelectGuideline( thisLayer, originPoint, angle ): 22 | """Adds a guideline in thisLayer at originPoint, at angle.""" 23 | try: 24 | myGuideline = GSGuideLine() 25 | myGuideline.position = originPoint 26 | myGuideline.angle = angle 27 | thisLayer.addGuideLine_( myGuideline ) 28 | thisLayer.clearSelection() 29 | thisLayer.addSelection_( myGuideline ) 30 | return True 31 | except Exception as e: 32 | print e 33 | return False 34 | 35 | def bringBackGuidelines( thisGlyph ): 36 | """Pulls the Guidline info from a glyph""" 37 | # try: 38 | # myGuideline = GSGuideLine() 39 | # myGuideline.position = originPoint 40 | # myGuideline.angle = angle 41 | # thisLayer.addGuideLine_( myGuideline ) 42 | # thisLayer.clearSelection() 43 | # thisLayer.addSelection_( myGuideline ) 44 | # return True 45 | # except Exception as e: 46 | # print e 47 | # return False 48 | 49 | for thisLayer in selectedLayers: 50 | thisGlyph = thisLayer.parent 51 | if thisGlyph.name.find("guide."): 52 | # delete guidelines: 53 | thisGlyph.beginUndo() 54 | thisLayer.guideLines = [] 55 | thisGlyph.endUndo() 56 | guideName = thisGlyph.name.rsplit('.', 1)[1] 57 | guideLineOriginGlyph = "guide." + guideName 58 | print guideLineOriginGlyph 59 | # guidelineOrigin = NSPoint( centerX, centerY ) 60 | 61 | guidelineAngle = 0.0 62 | 63 | # thisGlyph.beginUndo() 64 | # if not addAndSelectGuideline( thisLayer, guidelineOrigin, guidelineAngle ): 65 | # print "Error: Could not add guideline." 66 | # thisGlyph.endUndo() 67 | -------------------------------------------------------------------------------- /Components/ReverseComponentPathDirection.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Reverse Component Path Direction 2 | # Description: Reverses the path direction of a selected component. 3 | # -*- coding: utf-8 -*- 4 | 5 | import vanilla 6 | import GlyphsApp 7 | 8 | class ReverseComponentPathDirection(object): 9 | def __init__(self): 10 | # Window dimensions 11 | width = 300 12 | height = 150 13 | 14 | # Create window 15 | self.w = vanilla.Window((width, height), "Reverse Component Path Direction") 16 | 17 | # Checkboxes 18 | self.w.applyAllMasters = vanilla.CheckBox((10, 20, -10, 25), "Apply on all masters") 19 | self.w.closeAfterRun = vanilla.CheckBox((10, 50, -10, 25), "Close popup after running") 20 | self.w.closeAfterRun.set(True) # Default checked 21 | 22 | # Run button 23 | self.w.runButton = vanilla.Button((10, 80, -10, 30), "Reverse Path Direction", self.reversePathDirection) 24 | 25 | # Open window 26 | self.w.open() 27 | 28 | def reversePathDirection(self, sender): 29 | font = Glyphs.font 30 | selectedLayers = font.selectedLayers 31 | 32 | for layer in selectedLayers: 33 | component = next((c for c in layer.components if c.selected), None) 34 | if component: 35 | # Toggle the orientation property (-1 or 1) 36 | component.attributes['reversePaths'] = False if component.attributes['reversePaths'] == True else True 37 | 38 | # Apply to all masters if checkbox is checked 39 | if self.w.applyAllMasters.get(): 40 | for masterLayer in font.glyphs[layer.parent.name].layers: 41 | for masterComponent in masterLayer.components: 42 | if masterComponent.name == component.name: 43 | masterComponent.attributes['reversePaths'] = False if masterComponent.attributes['reversePaths'] == True else True 44 | 45 | # Close window if checkbox is checked 46 | if self.w.closeAfterRun.get(): 47 | self.w.close() 48 | 49 | ReverseComponentPathDirection() 50 | -------------------------------------------------------------------------------- /Metrics/ChangeWidthCentered.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Change Width Centered 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Kind of like a multiplexer, but more boring. Uniformly changes width, but keeps character centered. 6 | """ 7 | 8 | import vanilla 9 | import GlyphsApp 10 | 11 | class ChangeWidthCentered( object ): 12 | def __init__( self ): 13 | # Window 'self.w': 14 | windowWidth = 250 15 | windowHeight = 130 16 | windowWidthResize = 300 # user can resize width by this value 17 | windowHeightResize = 0 # user can resize height by this value 18 | self.w = vanilla.Window( 19 | ( windowWidth, windowHeight ), # default window size 20 | "Change Width Centered", # window title 21 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 22 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 23 | autosaveName = "com.kylewaynebenson.ChangeWidthCentered.mainwindow" # stores last window position and size 24 | ) 25 | 26 | # UI elements: 27 | self.w.text_1 = vanilla.TextBox( (15, 10, -15, 30), "Change new width to:", sizeStyle='small' ) 28 | 29 | self.w.newWidth = vanilla.EditText( ( 15, 40-1, 50, 21), "750", sizeStyle='regular' ) 30 | 31 | self.w.checkBox = vanilla.CheckBox((-120, 40, 0, 20), "All layers", value=False) 32 | 33 | # Run Button: 34 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Change Width", sizeStyle='regular', callback=self.changeWidth ) 35 | self.w.setDefaultButton( self.w.runButton ) 36 | 37 | self.w.open() 38 | self.w.makeKey() 39 | 40 | 41 | def changeWidth( self, sender ): 42 | NewWidth = float(self.w.newWidth.get()) 43 | AllLayers = self.w.checkBox.get() 44 | Glyphs.clearLog() 45 | Glyphs.showMacroWindow() 46 | try: 47 | for layer in Glyphs.font.selectedLayers: 48 | thisGlyph = layer.parent 49 | print("\n" + thisGlyph.name) 50 | allLayers = len(thisGlyph.layers) 51 | count = 0 52 | if AllLayers == True: 53 | for thisLayer in thisGlyph.layers: 54 | AddToSides = (NewWidth - thisLayer.width) / 2 55 | print("\t%s" % thisLayer.name) 56 | print("\t\tCurrent Width => %s" % thisLayer.width) 57 | print("\t\tAdded to Sides => %s" % AddToSides) 58 | thisLayer.LSB = thisLayer.LSB + AddToSides 59 | thisLayer.RSB = thisLayer.RSB + AddToSides 60 | print("\t\tNew Width => %s" % thisLayer.width) 61 | else: 62 | AddToSides = (NewWidth - layer.width) / 2 63 | print("\t%s" % layer.name) 64 | print("\t\tCurrent Width => %s" % layer.width) 65 | print("\t\tAdded to Sides => %s" % AddToSides) 66 | layer.LSB = layer.LSB + AddToSides 67 | layer.RSB = layer.RSB + AddToSides 68 | print("\t\tNew Width => %s" % layer.width) 69 | if count == allLayers: 70 | thisGlyph.color = 6 71 | except Exception as e: 72 | # print error 73 | Glyphs.showMacroWindow() 74 | print("Change Width Centered Error: %s" % e) 75 | 76 | ChangeWidthCentered() 77 | -------------------------------------------------------------------------------- /Components/ResetAllComponents.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Reset Component Scales to 100% 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Resets the scale of all components in the selected glyph to 100% with options for all masters and automatic alignment. 5 | """ 6 | 7 | import GlyphsApp 8 | from vanilla import * 9 | 10 | class ResetComponentScalesDialog(object): 11 | 12 | def __init__(self): 13 | self.w = FloatingWindow((300, 150), "Reset Component Scales") 14 | self.w.allMasters = CheckBox((10, 10, -10, 20), "Across all masters", value=True) 15 | self.w.autoAlign = CheckBox((10, 40, -10, 20), "Enable automatic alignment", value=True) 16 | self.w.closeAfter = CheckBox((10, 70, -10, 20), "Close popup after running", value=True) 17 | self.w.resetButton = Button((10, 100, -10, 20), "Reset Scales", callback=self.resetScales) 18 | self.w.center() 19 | self.w.open() 20 | 21 | def resetScales(self, sender): 22 | font = Glyphs.font 23 | if not font: 24 | print("No font open") 25 | return 26 | 27 | selectedLayers = font.selectedLayers 28 | if not selectedLayers: 29 | print("No glyph selected") 30 | return 31 | 32 | allMasters = self.w.allMasters.get() 33 | autoAlign = self.w.autoAlign.get() 34 | 35 | for layer in selectedLayers: 36 | glyph = layer.parent 37 | print(f"Processing glyph: {glyph.name}") 38 | 39 | if allMasters: 40 | layers = [glyph.layers[m.id] for m in font.masters] 41 | else: 42 | layers = [layer] 43 | 44 | for l in layers: 45 | print(f" Processing layer: {l.name}") 46 | for component in l.components: 47 | initial_scale = component.scale 48 | initial_alignment = component.automaticAlignment 49 | 50 | if component.scale != (1, 1): 51 | print(f" Resetting component {component.componentName}") 52 | print(f" Initial scale: {initial_scale}") 53 | component.scale = (1, 1) 54 | print(f" New scale: {component.scale}") 55 | 56 | if autoAlign: 57 | print(f" Initial automatic alignment: {initial_alignment}") 58 | component.automaticAlignment = True 59 | print(f" New automatic alignment: {component.automaticAlignment}") 60 | 61 | if component.scale != (1, 1) or (autoAlign and not component.automaticAlignment): 62 | print(f" Warning: Failed to update component {component.componentName}") 63 | 64 | glyph.updateGlyphInfo() 65 | 66 | font.enableUpdateInterface() 67 | print("Component scales reset complete") 68 | 69 | if self.w.closeAfter.get(): 70 | self.w.close() 71 | 72 | ResetComponentScalesDialog() -------------------------------------------------------------------------------- /Paths/RandomlyMovePoints.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Randomly Move Points 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Randomly move the x and y of all selected points. 6 | """ 7 | 8 | import vanilla 9 | from vanilla import * 10 | import GlyphsApp 11 | import random 12 | 13 | class RandomlyMove( object ): 14 | def __init__( self ): 15 | # Window 'self.w': 16 | windowWidth = 250 17 | windowHeight = 190 18 | windowWidthResize = 300 # user can resize width by this value 19 | windowHeightResize = 0 # user can resize height by this value 20 | self.w = vanilla.Window( 21 | ( windowWidth, windowHeight ), # default window size 22 | "Randomly Move Points", # window title 23 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 24 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 25 | autosaveName = "com.kylewaynebenson.RandomlyMove.mainwindow" # stores last window position and size 26 | ) 27 | 28 | # UI elements: 29 | YOffset = 10 30 | 31 | self.w.text_1 = vanilla.TextBox( (15, YOffset, -15, 30), "Randomly move all points by:", sizeStyle='small' ) 32 | 33 | LineHeight = 25 34 | YOffset += LineHeight 35 | 36 | self.w.text_2 = vanilla.TextBox( ( 15, YOffset, 80, 20), "Range", sizeStyle='regular' ) 37 | self.w.moveRange = vanilla.EditText( ( 65, YOffset, 40, 21), "3", sizeStyle='regular' ) 38 | 39 | LineHeight = 30 40 | YOffset += LineHeight 41 | 42 | self.w.text_3 = vanilla.TextBox( ( 15, YOffset, 80, 20), "Grid", sizeStyle='regular' ) 43 | self.w.moveGrid = vanilla.EditText( ( 65, YOffset, 40, 21), "2", sizeStyle='regular' ) 44 | 45 | LineHeight = 30 46 | YOffset += LineHeight 47 | 48 | self.w.checkBox = CheckBox((15, YOffset, 0, 20), "Only move OCPs", value=False) 49 | 50 | # Run Button: 51 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Shake", sizeStyle='regular', callback=self.RamdonlyMovePoints ) 52 | self.w.setDefaultButton( self.w.runButton ) 53 | 54 | # Open window and focus on it: 55 | self.w.open() 56 | self.w.makeKey() 57 | 58 | 59 | def RamdonlyMovePoints( self, sender ): 60 | Font = Glyphs.font 61 | selectedLayers = Font.selectedLayers 62 | moveRange = float(self.w.moveRange.get()) 63 | moveGrid = abs(float(self.w.moveGrid.get()) / 2) 64 | if moveGrid == 0: 65 | moveGrid = 1 66 | try: 67 | for thisLayer in selectedLayers: 68 | thisLayer.parent.beginUndo() 69 | for thisPath in thisLayer.paths: 70 | for thisNode in thisPath.nodes: 71 | if (self.w.checkBox.get() == True): 72 | if (thisNode.type == GSCURVE) or (thisNode.type == LINE): 73 | thisNode.x += (random.randint(-moveRange,moveRange) * moveGrid) 74 | thisNode.y += (random.randint(-moveRange,moveRange) * moveGrid) 75 | else: 76 | thisNode.x += (random.randint(-moveRange,moveRange) * moveGrid) 77 | thisNode.y += (random.randint(-moveRange,moveRange) * moveGrid) 78 | 79 | thisPath.checkConnections() 80 | thisLayer.parent.endUndo() 81 | except Exception as e: 82 | # print error 83 | Glyphs.showMacroWindow() 84 | print("Move Points Error: %s" % e) 85 | 86 | RandomlyMove() 87 | -------------------------------------------------------------------------------- /Components/MirrorComponentsAcrossMasters.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Mirror Components Across Masters 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Mirrors the components of the active layer to all other masters, updating any discrepancies. 5 | """ 6 | 7 | import GlyphsApp 8 | from vanilla import * 9 | 10 | class MirrorComponentsAcrossMastersDialog(object): 11 | 12 | def __init__(self): 13 | self.w = FloatingWindow((300, 130), "Mirror Components Across Masters") 14 | self.w.infoText = TextBox((10, 10, -10, 44), "This script will update components in all masters to match the active layer.") 15 | self.w.closeAfter = CheckBox((10, 60, -10, 20), "Close popup after running", value=True) 16 | self.w.mirrorButton = Button((10, 90, -10, 20), "Mirror Components", callback=self.mirrorComponents) 17 | self.w.center() 18 | self.w.open() 19 | 20 | def mirrorComponents(self, sender): 21 | font = Glyphs.font 22 | if not font: 23 | print("No font open") 24 | return 25 | 26 | selectedLayers = font.selectedLayers 27 | if not selectedLayers: 28 | print("No glyph selected") 29 | return 30 | 31 | for activeLayer in selectedLayers: 32 | glyph = activeLayer.parent 33 | print(f"Processing glyph: {glyph.name}") 34 | print(f"Active layer: {activeLayer.name}") 35 | 36 | # Get the components of the active layer 37 | activeComponents = [shape for shape in activeLayer.shapes if isinstance(shape, GSComponent)] 38 | print(f"Active layer has {len(activeComponents)} components:") 39 | for i, comp in enumerate(activeComponents): 40 | print(f" Component {i+1}: {comp.componentName} at position {comp.position}") 41 | 42 | for master in font.masters: 43 | if master.id == activeLayer.master.id: 44 | print(f"Skipping active master: {master.name}") 45 | continue # Skip the active master 46 | 47 | layer = glyph.layers[master.id] 48 | print(f"Processing master: {master.name}") 49 | 50 | # Log existing components 51 | existingComponents = [shape for shape in layer.shapes if isinstance(shape, GSComponent)] 52 | print(f" Before: Layer has {len(existingComponents)} components") 53 | 54 | # Remove all existing shapes (including components) 55 | layer.shapes = [] 56 | print(" Removed all existing shapes") 57 | 58 | # Copy components from active layer 59 | for comp in activeComponents: 60 | newComp = comp.copy() 61 | layer.shapes.append(newComp) 62 | print(f" Added component: {newComp.componentName} at position {newComp.position}") 63 | 64 | print(f" After: Layer now has {len(layer.shapes)} shapes") 65 | 66 | glyph.updateGlyphInfo() 67 | 68 | font.enableUpdateInterface() 69 | print("Mirroring components complete") 70 | 71 | if self.w.closeAfter.get(): 72 | self.w.close() 73 | 74 | MirrorComponentsAcrossMastersDialog() -------------------------------------------------------------------------------- /Paths/SimplifyShape.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Simplify Shape 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Reduce amount of nodes. Best for grungy and textured vectors. 6 | """ 7 | 8 | import vanilla 9 | import GlyphsApp 10 | 11 | class SimplifyShape( object ): 12 | def __init__( self ): 13 | # Window 'self.w': 14 | windowWidth = 250 15 | windowHeight = 140 16 | windowWidthResize = 300 # user can resize width by this value 17 | windowHeightResize = 0 # user can resize height by this value 18 | self.w = vanilla.Window( 19 | ( windowWidth, windowHeight ), # default window size 20 | "Simplify Shape", # window title 21 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 22 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 23 | autosaveName = "com.kylewaynebenson.SimplifyShape.mainwindow" # stores last window position and size 24 | ) 25 | 26 | # UI elements: 27 | YOffset = 10 28 | 29 | self.w.text_1 = vanilla.TextBox( (15, YOffset, -15, 30), "Simplify all paths by 1 out of every", sizeStyle='small' ) 30 | 31 | LineHeight = 25 32 | YOffset += LineHeight 33 | 34 | self.w.nodeCount = vanilla.EditText( ( 15, YOffset, 40, 21), "3", sizeStyle='regular' ) 35 | self.w.text_2 = vanilla.TextBox( ( 65, YOffset, 80, 20), "nodes", sizeStyle='regular' ) 36 | 37 | # Run Button: 38 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Simplify", sizeStyle='regular', callback=self.SimplifyShapeMain ) 39 | self.w.setDefaultButton( self.w.runButton ) 40 | 41 | # Open window and focus on it: 42 | self.w.open() 43 | self.w.makeKey() 44 | 45 | 46 | def deletePoints( self, thisLayer, nodeCount ): 47 | for thisPath in thisLayer.paths: 48 | counter = 0 49 | pathlength = len(thisPath) 50 | for i in range(pathlength)[::-1]: 51 | thisNode = thisPath.nodes[i] 52 | if (thisNode.type == GSCURVE) or (thisNode.type == LINE): 53 | counter += 1 54 | if counter == nodeCount: 55 | thisPath.removeNodeCheckKeepShape_( thisNode ) 56 | counter = 0 57 | 58 | def deleteOverlappingPoints( self, thisLayer): 59 | for i in range(len(thisLayer.paths))[::-1]: 60 | thisPath = thisLayer.paths[i] 61 | for a, b in zip(thisPath.nodes, thisPath.nodes[1:]): 62 | if (int(a.x) == int(b.x)) & (int(a.y) == int(b.y)): 63 | thisPath.removeNodeCheckKeepShape_(b) 64 | 65 | def countMyNodes( self, thisLayer): 66 | nodeTotal = 0 67 | for thisPath in thisLayer.paths: 68 | for thisNode in thisPath.nodes: 69 | if (thisNode.type == GSCURVE) or (thisNode.type == LINE): 70 | nodeTotal += 1 71 | return nodeTotal 72 | 73 | def SimplifyShapeMain( self, sender ): 74 | Font = Glyphs.font 75 | selectedLayers = Font.selectedLayers 76 | nodeCount = float(self.w.nodeCount.get()) 77 | 78 | try: 79 | for thisLayer in selectedLayers: 80 | 81 | thisLayer.parent.beginUndo() # wrapper for undo function 82 | 83 | print(thisLayer.name) 84 | print("On curve node count:", self.countMyNodes( thisLayer)) 85 | 86 | self.deletePoints( thisLayer, nodeCount ) 87 | self.deleteOverlappingPoints( thisLayer ) 88 | 89 | print("New on curve node count:", self.countMyNodes( thisLayer)) 90 | 91 | thisLayer.parent.endUndo() # wrapper for undo function 92 | 93 | except Exception as e: 94 | Glyphs.showMacroWindow() 95 | print("Simplify Shape Error: %s" % e) 96 | 97 | SimplifyShape() 98 | -------------------------------------------------------------------------------- /Paths/CreateDropShadow.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Create Drop Shadow 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Replace each selected glyphs with a drop shadow 6 | """ 7 | 8 | import vanilla 9 | from vanilla import * 10 | import GlyphsApp 11 | 12 | class DropShadow( object ): 13 | def __init__( self ): 14 | # Window 'self.w': 15 | windowWidth = 250 16 | windowHeight = 180 17 | windowWidthResize = 300 # user can resize width by this value 18 | windowHeightResize = 0 # user can resize height by this value 19 | self.w = vanilla.Window( 20 | ( windowWidth, windowHeight ), # default window size 21 | "Create Drop Shadow", # window title 22 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 23 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 24 | autosaveName = "com.kylewaynebenson.DropShadow.mainwindow" # stores last window position and size 25 | ) 26 | 27 | # UI elements: 28 | YOffset = 10 29 | 30 | self.w.text_1 = vanilla.TextBox( (15, YOffset, -15, 30), "Create drop shadow that is this many units removed from original drawing:", sizeStyle='small' ) 31 | 32 | LineHeight = 40 33 | YOffset += LineHeight 34 | 35 | self.w.text_2 = vanilla.TextBox( ( 15, YOffset, 20, 20), "X:", sizeStyle='regular' ) 36 | self.w.xAxis = vanilla.EditText( ( 40, YOffset, 50, 21), "-20", sizeStyle='regular' ) 37 | self.w.text_3 = vanilla.TextBox( (-95, YOffset, 20, 20), "Y:", sizeStyle='regular' ) 38 | self.w.yAxis = vanilla.EditText( (-20-50, YOffset, -20, 21), "-20", sizeStyle='regular' ) 39 | 40 | LineHeight = 30 41 | YOffset += LineHeight 42 | 43 | self.w.text_4 = vanilla.TextBox( (15, YOffset, 42, 20), "Offset:", sizeStyle='regular' ) 44 | self.w.offset = vanilla.EditText( (62, YOffset, 50, 21), "0", sizeStyle='regular' ) 45 | self.w.checkBox = CheckBox((-110, YOffset, 0, 20), "Keep letters", value=False) 46 | 47 | YOffset += LineHeight 48 | 49 | # Run Button: 50 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Create Shadows", sizeStyle='regular', callback=self.DropShadowMain ) 51 | self.w.setDefaultButton( self.w.runButton ) 52 | 53 | # Open window and focus on it: 54 | self.w.open() 55 | self.w.makeKey() 56 | 57 | 58 | def DropShadowMain( self, sender ): 59 | self.offsetCurveFilter = NSClassFromString("GlyphsFilterOffsetCurve") 60 | pathOp = GSPathOperator.alloc().init() 61 | Font = Glyphs.font 62 | selectedLayers = Font.selectedLayers 63 | xAxis = float(self.w.xAxis.get()) 64 | yAxis = float(self.w.yAxis.get()) 65 | offsetX = float(self.w.offset.get()) 66 | offsetY = float(self.w.offset.get()) 67 | glyphsChanged = [] 68 | try: 69 | 70 | for thisLayer in selectedLayers: 71 | 72 | glyphsChanged.append( thisLayer.parent.name ) 73 | thisLayer.parent.beginUndo() # wrapper for undo function 74 | 75 | thisLayer.correctPathDirection() # 76 | thisLayer.correctPathDirection() # 77 | thisLayer.correctPathDirection() # 78 | 79 | shadowPathList = NSMutableArray.array() 80 | prePathList = NSMutableArray.array() 81 | 82 | #save original outline 83 | for thisPath in thisLayer.paths: 84 | newPath = GSPath() 85 | for n in thisPath.nodes: 86 | newNode = GSNode() 87 | setX = n.x + xAxis 88 | setY = n.y + yAxis 89 | newNode.type = n.type 90 | newNode.setPosition_((setX, setY)) 91 | newPath.addNode_( newNode ) 92 | newPath.closed = thisPath.closed 93 | shadowPathList.append( newPath ) 94 | prePathList.append( thisPath ) 95 | 96 | self.offsetCurveFilter.offsetLayer_offsetX_offsetY_makeStroke_position_error_shadow_( thisLayer, offsetX, offsetY, False, 0.5, None, None ) 97 | 98 | for thisPath in thisLayer.paths: 99 | shadowPathList.append( thisPath ) 100 | 101 | pathOp.removeOverlapPaths_error_( shadowPathList, None) 102 | 103 | #reverse shadow 104 | for thisPath in thisLayer.paths: 105 | thisPath.reverse() 106 | 107 | #punch out the old drawing 108 | for shadowPath in shadowPathList: 109 | thisLayer.addPath_( shadowPath ) 110 | 111 | thisLayer.removeOverlap() 112 | 113 | if (self.w.checkBox.get() == True): 114 | for prePath in prePathList: 115 | thisLayer.addPath_( prePath ) 116 | 117 | 118 | thisLayer.correctPathDirection() 119 | thisLayer.parent.endUndo() # wrapper for undo function 120 | 121 | print "Created drop shadow for these glyphs:", glyphsChanged 122 | 123 | except Exception, e: 124 | # print error 125 | Glyphs.showMacroWindow() 126 | print "Create Drop Shadow Error: %s" % e 127 | 128 | DropShadow() 129 | -------------------------------------------------------------------------------- /Components/AddOrReplaceComponent.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Add or Replace with Component 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Replaces selected path or component with a new component in Glyphs 3. 5 | Options for automatic alignment, applying on all masters, and searching through all available glyphs. 6 | """ 7 | 8 | import GlyphsApp 9 | from vanilla import * 10 | 11 | class AddReplaceComponentDialog(object): 12 | 13 | def __init__(self): 14 | # Get all available glyphs 15 | self.all_glyphs = sorted([g.name for g in Glyphs.font.glyphs]) 16 | 17 | # Set up the dialog 18 | self.w = FloatingWindow((300, 400), "Add or Replace with Component") 19 | self.w.search = SearchBox((10, 10, -10, 20), callback=self.search_callback) 20 | self.w.componentList = List((10, 40, -10, -90), self.all_glyphs, selectionCallback=self.on_selection) 21 | self.w.autoAlign = CheckBox((10, -80, -10, 20), "Enable automatic alignment", value=True) 22 | self.w.allMasters = CheckBox((10, -60, -10, 20), "Apply on all masters", value=True) 23 | self.w.closeAfter = CheckBox((10, -40, -10, 20), "Close popup after running", value=True) 24 | self.w.applyButton = Button((10, -30, -10, 20), "Apply", callback=self.apply_component) 25 | self.w.center() 26 | self.w.open() 27 | 28 | self.selected_component = None 29 | 30 | def search_callback(self, sender): 31 | search_text = sender.get().strip() 32 | if search_text.endswith(" "): 33 | # Exact match if the search ends with a space 34 | exact_matches = [g for g in self.all_glyphs if g.lower() == search_text.lower().strip()] 35 | partial_matches = [g for g in self.all_glyphs if g.lower().startswith(search_text.lower().strip()) and g not in exact_matches] 36 | filtered_glyphs = exact_matches + partial_matches 37 | else: 38 | # Prioritize glyphs that start with the search text, then include partial matches 39 | starts_with = [g for g in self.all_glyphs if g.lower().startswith(search_text.lower())] 40 | contains = [g for g in self.all_glyphs if search_text.lower() in g.lower() and g not in starts_with] 41 | filtered_glyphs = starts_with + contains 42 | self.w.componentList.set(filtered_glyphs) 43 | 44 | def on_selection(self, sender): 45 | selection = sender.getSelection() 46 | if selection: 47 | self.selected_component = sender.get()[selection[0]] 48 | else: 49 | self.selected_component = None 50 | 51 | def get_selected_shape_index(self, layer): 52 | for i, shape in enumerate(layer.shapes): 53 | if shape.selected: 54 | return i 55 | return -1 56 | 57 | def apply_component(self, sender): 58 | if not self.selected_component: 59 | print("No component selected") 60 | return 61 | 62 | font = Glyphs.font 63 | if not font: 64 | print("No font open") 65 | return 66 | 67 | glyph = font.selectedLayers[0].parent 68 | if not glyph: 69 | print("No glyph selected") 70 | return 71 | 72 | active_layer = font.selectedLayers[0] 73 | selected_shape_index = self.get_selected_shape_index(active_layer) 74 | 75 | if selected_shape_index == -1: 76 | print("No shape selected") 77 | return 78 | 79 | all_masters = self.w.allMasters.get() 80 | auto_align = self.w.autoAlign.get() 81 | 82 | layers_to_process = glyph.layers if all_masters else [active_layer] 83 | 84 | for layer in layers_to_process: 85 | if selected_shape_index < len(layer.shapes): 86 | shape_to_replace = layer.shapes[selected_shape_index] 87 | 88 | # Get the bounds of the shape to replace 89 | bounds = shape_to_replace.bounds 90 | 91 | # Remove the shape 92 | del layer.shapes[selected_shape_index] 93 | 94 | # Add new component 95 | new_component = GSComponent(self.selected_component) 96 | layer.shapes.append(new_component) 97 | 98 | if auto_align: 99 | new_component.automaticAlignment = True 100 | else: 101 | # Center the new component where the old shape was 102 | new_bounds = new_component.bounds 103 | new_component.position = ( 104 | bounds.origin.x + (bounds.size.width - new_bounds.size.width) / 2, 105 | bounds.origin.y + (bounds.size.height - new_bounds.size.height) / 2 106 | ) 107 | 108 | print(f"Replaced shape with component '{self.selected_component}' in layer {layer.name}") 109 | else: 110 | print(f"No corresponding shape found in layer {layer.name}") 111 | 112 | if self.w.closeAfter.get(): 113 | self.w.close() 114 | 115 | AddReplaceComponentDialog() 116 | -------------------------------------------------------------------------------- /Interpolation/CompatibilityHelper.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Compatibility Helper 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Analyzes the current glyph for compatibility issues and reports them in the Macro Panel. 5 | Checks for: 6 | - Masters with different path counts than others 7 | - Masters missing anchors that a majority have 8 | - Intermediate layers with duplicate names 9 | """ 10 | 11 | from GlyphsApp import Glyphs 12 | 13 | def check_path_counts(glyph): 14 | """Check if all masters have the same number of paths.""" 15 | issues = [] 16 | 17 | # Get path counts for each master layer 18 | master_path_counts = {} 19 | for layer in glyph.layers: 20 | if layer.isMasterLayer or layer.isSpecialLayer: 21 | master_name = layer.name if layer.name else layer.associatedMasterId 22 | path_count = len(layer.paths) 23 | master_path_counts[master_name] = path_count 24 | 25 | if not master_path_counts: 26 | return issues 27 | 28 | # Find the most common path count 29 | path_counts = list(master_path_counts.values()) 30 | most_common_count = max(set(path_counts), key=path_counts.count) 31 | 32 | # Report masters that differ 33 | for master_name, count in master_path_counts.items(): 34 | if count != most_common_count: 35 | issues.append(f" ⚠️ Master '{master_name}' has {count} path(s), expected {most_common_count}") 36 | 37 | return issues 38 | 39 | def check_anchors(glyph): 40 | """Check if all masters have the same anchors.""" 41 | issues = [] 42 | 43 | # Collect anchor names for each master layer 44 | master_anchors = {} 45 | for layer in glyph.layers: 46 | if layer.isMasterLayer or layer.isSpecialLayer: 47 | master_name = layer.name if layer.name else layer.associatedMasterId 48 | anchor_names = set([anchor.name for anchor in layer.anchors]) 49 | master_anchors[master_name] = anchor_names 50 | 51 | if not master_anchors: 52 | return issues 53 | 54 | # Find all unique anchor names across masters 55 | all_anchor_names = set() 56 | for anchor_set in master_anchors.values(): 57 | all_anchor_names.update(anchor_set) 58 | 59 | if not all_anchor_names: 60 | return issues 61 | 62 | # For each anchor, check if majority of masters have it 63 | num_masters = len(master_anchors) 64 | majority_threshold = num_masters / 2.0 65 | 66 | for anchor_name in all_anchor_names: 67 | masters_with_anchor = [master for master, anchors in master_anchors.items() if anchor_name in anchors] 68 | masters_without_anchor = [master for master, anchors in master_anchors.items() if anchor_name not in anchors] 69 | 70 | # If majority have it but some don't, report the ones missing it 71 | if len(masters_with_anchor) > majority_threshold and masters_without_anchor: 72 | for master_name in masters_without_anchor: 73 | issues.append(f" ⚠️ Master '{master_name}' is missing anchor '{anchor_name}' (present in {len(masters_with_anchor)}/{num_masters} masters)") 74 | 75 | return issues 76 | 77 | def check_duplicate_intermediate_names(glyph): 78 | """Check for intermediate layers with duplicate names.""" 79 | issues = [] 80 | 81 | # Collect intermediate layer names 82 | intermediate_layers = {} 83 | for layer in glyph.layers: 84 | # Intermediate layers are neither master nor special layers 85 | if not layer.isMasterLayer and not layer.isSpecialLayer: 86 | layer_name = layer.name 87 | if layer_name: 88 | if layer_name not in intermediate_layers: 89 | intermediate_layers[layer_name] = [] 90 | intermediate_layers[layer_name].append(layer) 91 | 92 | # Report duplicates 93 | for layer_name, layers in intermediate_layers.items(): 94 | if len(layers) > 1: 95 | issues.append(f" ⚠️ Duplicate intermediate layer name '{layer_name}' found {len(layers)} times") 96 | 97 | return issues 98 | 99 | def main(): 100 | font = Glyphs.font 101 | 102 | if not font: 103 | print("⛔️ No font open") 104 | return 105 | 106 | # Get current glyph 107 | current_tab = font.currentTab 108 | if not current_tab: 109 | print("⛔️ No tab open") 110 | return 111 | 112 | layers = current_tab.layers 113 | if not layers: 114 | print("⛔️ No glyph selected") 115 | return 116 | 117 | glyph = layers[0].parent 118 | 119 | print(f"\n{'='*60}") 120 | print(f"Compatibility Check: {glyph.name}") 121 | print(f"{'='*60}\n") 122 | 123 | # Run all checks 124 | path_issues = check_path_counts(glyph) 125 | anchor_issues = check_anchors(glyph) 126 | duplicate_issues = check_duplicate_intermediate_names(glyph) 127 | 128 | # Report results 129 | has_issues = False 130 | 131 | if path_issues: 132 | has_issues = True 133 | print("PATH COUNT ISSUES:") 134 | for issue in path_issues: 135 | print(issue) 136 | print() 137 | 138 | if anchor_issues: 139 | has_issues = True 140 | print("ANCHOR ISSUES:") 141 | for issue in anchor_issues: 142 | print(issue) 143 | print() 144 | 145 | if duplicate_issues: 146 | has_issues = True 147 | print("DUPLICATE INTERMEDIATE LAYER NAMES:") 148 | for issue in duplicate_issues: 149 | print(issue) 150 | print() 151 | 152 | if not has_issues: 153 | print("✅ No compatibility issues found!") 154 | else: 155 | print(f"{'='*60}") 156 | print(f"Found {len(path_issues) + len(anchor_issues) + len(duplicate_issues)} issue(s)") 157 | print(f"{'='*60}") 158 | 159 | print() 160 | 161 | if __name__ == "__main__": 162 | main() 163 | -------------------------------------------------------------------------------- /Paths/CreateSignPainterDropShadow.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Create Sign Painter Drop Shadow 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Replace each selected glyphs with a sign painter style drop shadow 6 | """ 7 | 8 | import vanilla 9 | from vanilla import * 10 | import GlyphsApp 11 | 12 | class DropShadow( object ): 13 | def __init__( self ): 14 | # Window 'self.w': 15 | windowWidth = 250 16 | windowHeight = 200 17 | windowWidthResize = 300 # user can resize width by this value 18 | windowHeightResize = 0 # user can resize height by this value 19 | self.w = vanilla.Window( 20 | ( windowWidth, windowHeight ), # default window size 21 | "Create Drop Shadow", # window title 22 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 23 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 24 | autosaveName = "com.kylewaynebenson.DropShadow.mainwindow" # stores last window position and size 25 | ) 26 | 27 | # UI elements: 28 | YOffset = 10 29 | 30 | self.w.text_1 = vanilla.TextBox( (15, YOffset, -15, 30), "Only use this baby on simple scripts or sans.", sizeStyle='small' ) 31 | 32 | LineHeight = 40 33 | YOffset += LineHeight 34 | boxPos = 95 35 | 36 | self.w.text_2 = vanilla.TextBox( ( 15, YOffset, 20, 20), "X:", sizeStyle='regular' ) 37 | self.w.xAxis = vanilla.EditText( ( boxPos, YOffset, 40, 21), "-45", sizeStyle='regular' ) 38 | self.w.text_3 = vanilla.TextBox( (-95, YOffset, 20, 20), "Y:", sizeStyle='regular' ) 39 | self.w.yAxis = vanilla.EditText( (-20-50, YOffset, -20, 21), "-45", sizeStyle='regular' ) 40 | 41 | LineHeight = 30 42 | YOffset += LineHeight 43 | 44 | self.w.text_4 = vanilla.TextBox( (15, YOffset, 80, 20), "Offset:", sizeStyle='regular' ) 45 | self.w.offset = vanilla.EditText( (boxPos, YOffset, 40, 21), "8", sizeStyle='regular' ) 46 | self.w.checkBox = CheckBox((-110, YOffset, 0, 20), "Keep letters", value=False) 47 | 48 | YOffset += LineHeight 49 | 50 | self.w.text_5 = vanilla.TextBox( (15, YOffset, 80, 20), "Goopiness:", sizeStyle='regular' ) 51 | self.w.goopy = vanilla.EditText( (boxPos, YOffset, 40, 21), "16", sizeStyle='regular' ) 52 | 53 | YOffset += LineHeight 54 | 55 | # Run Button: 56 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Create Shadows", sizeStyle='regular', callback=self.DropShadowMain ) 57 | self.w.setDefaultButton( self.w.runButton ) 58 | 59 | # Open window and focus on it: 60 | self.w.open() 61 | self.w.makeKey() 62 | 63 | def roundCorner( self, thisLayer, radius ): 64 | thisFilterClass = NSClassFromString("GlyphsFilterRoundCorner") 65 | thisFilterClass.roundLayer_radius_checkSelection_visualCorrect_grid_(thisLayer, radius, FALSE, TRUE, TRUE) 66 | 67 | def removeTinyPaths( self, thisLayer, size ): 68 | print size 69 | indexesOfPathsToBeRemoved = [] 70 | numberOfPaths = len(thisLayer.paths) 71 | numberOfDeletedPaths = 0 72 | 73 | for thisPathNumber in range( numberOfPaths ): 74 | thisPath = thisLayer.paths[thisPathNumber] 75 | if round(thisPath.area()) <= abs(2.0*size): 76 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 77 | 78 | if indexesOfPathsToBeRemoved: 79 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved )): 80 | thisLayer.removePathAtIndex_( thatIndex ) 81 | numberOfDeletedPaths += 1 82 | 83 | print "You deleted ", numberOfDeletedPaths, " paths" 84 | 85 | def DropShadowMain( self, sender ): 86 | self.offsetCurveFilter = NSClassFromString("GlyphsFilterOffsetCurve") 87 | pathOp = GSPathOperator.alloc().init() 88 | Font = Glyphs.font 89 | selectedLayers = Font.selectedLayers 90 | xAxis = float(self.w.xAxis.get()) 91 | yAxis = float(self.w.yAxis.get()) 92 | offsetX = float(self.w.offset.get()) 93 | offsetY = float(self.w.offset.get()) 94 | goopy = float(self.w.goopy.get()) 95 | glyphsChanged = [] 96 | try: 97 | 98 | for thisLayer in selectedLayers: 99 | 100 | glyphsChanged.append( thisLayer.parent.name ) 101 | thisLayer.parent.beginUndo() # wrapper for undo function 102 | 103 | thisLayer.correctPathDirection() # 104 | thisLayer.correctPathDirection() # 105 | thisLayer.correctPathDirection() # 106 | 107 | shadowPathList = NSMutableArray.array() 108 | prePathList = NSMutableArray.array() 109 | 110 | #save original outline 111 | for thisPath in thisLayer.paths: 112 | newPath = GSPath() 113 | for n in thisPath.nodes: 114 | newNode = GSNode() 115 | setX = n.x + xAxis 116 | setY = n.y + yAxis 117 | newNode.type = n.type 118 | newNode.setPosition_((setX, setY)) 119 | newPath.addNode_( newNode ) 120 | newPath.closed = thisPath.closed 121 | shadowPathList.append( newPath ) 122 | prePathList.append( thisPath ) 123 | 124 | self.offsetCurveFilter.offsetLayer_offsetX_offsetY_makeStroke_position_error_shadow_( thisLayer, round(offsetX*1.5), round(offsetY*1.5), False, 0.5, None, None ) 125 | 126 | for thisPath in thisLayer.paths: 127 | shadowPathList.append( thisPath ) 128 | 129 | pathOp.removeOverlapPaths_error_( shadowPathList, None) 130 | 131 | #reverse shadow 132 | for thisPath in thisLayer.paths: 133 | thisPath.reverse() 134 | 135 | #punch out the old drawing 136 | for shadowPath in shadowPathList: 137 | thisLayer.addPath_( shadowPath ) 138 | 139 | thisLayer.removeOverlap() 140 | self.removeTinyPaths( thisLayer , max(xAxis,yAxis) ) 141 | thisLayer.removeOverlap() 142 | self.roundCorner( thisLayer , goopy ) 143 | self.offsetCurveFilter.offsetLayer_offsetX_offsetY_makeStroke_position_error_shadow_( thisLayer, round(goopy/2), round(goopy/2), False, 0.5, None, None ) 144 | self.removeTinyPaths( thisLayer , max(xAxis,yAxis)*10 ) 145 | self.roundCorner( thisLayer , goopy*2 ) 146 | 147 | if (self.w.checkBox.get() == True): 148 | for prePath in prePathList: 149 | thisLayer.addPath_( prePath ) 150 | 151 | 152 | thisLayer.correctPathDirection() 153 | thisLayer.parent.endUndo() # wrapper for undo function 154 | 155 | print "Created drop shadow for these glyphs:", glyphsChanged 156 | 157 | except Exception, e: 158 | # print error 159 | Glyphs.showMacroWindow() 160 | print "Create Drop Shadow Error: %s" % e 161 | 162 | DropShadow() 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glyphs Scripts 2 | Python scripts for use in Glyphs.app. 3 | 4 | ## About Scripts 5 | | Folder | Script Name | Description | 6 | |------------------|---------------------------------|-------------| 7 | | Anchors | Mirror Anchors Across Masters | Takes a selected anchor and places it in corresponding positions across all masters based on positioning relative to zones and sides. | 8 | | Components | Add Corner Component | Adds a selected corner component to the selected node(s) with the option to apply on all compatible masters (if masters are compatible). If multiple nodes are selected, it first applies the "sharpen corner" function. | 9 | | Components | Add Or Replace Components | Replaces selected path or component with a new component in Glyphs 3. Options for automatic alignment, applying on all masters, and searching through all available glyphs. | 10 | | Components | Mirror Components Across Masters| Mirrors the components of the active layer to all other masters, updating any discrepancies. | 11 | | Components | Reset All Components | Resets the scale of all components in the selected glyph to 100% with options for all masters and automatic alignment. | 12 | | Components | Reverse Component Path Direction | Reverses the path direction of a selected component. | 13 | | Guides | Local Guidelines | Adds guidelines accross font based on guides found in various guide.extension glyphs | 14 | | Interpolation | Make Node First | Created this script so that I could assign a keyboard shortcut to this right-click function | 15 | | Interpolation | Count on Curve Points | This counts all on curve points for each master or layer of a selected glyph. Made to help figure out interpolation issues on complex drawings. | 16 | | Metrics | Average Width | Adds up and averages the width of all the masters/layers for a selected glyph. I made it to help me figure out a good starting point width for tabular figures. | 17 | | Metrics | Change Width Centered | Kind of like a multiplexer, but more boring. Uniformly changes width, but keeps character centered. | 18 | | Metrics | Find Metrics | Find metrics with specific characteristics and open in tab | 19 | | Metrics | Set Spacing Groups | Set Spacing Groups to spacing.extension if .extension is added | 20 | | Paths | Create Cast Shadow | This creates a shadow as if the letter is a 3d object. You should probably not run this on more than 50 characters, as the process takes a little time. | 21 | | Paths | Create Drop Shadow | Specify the size and direction of your drop shadow, with option to keep the letter, or just leave the shadow (handy if you want to create a font file of just shadows). | 22 | | Paths | Create Sign Painter Drop Shadow | This functions like Create Drop Shadow does, only it tries to blob things out a little bit, like it was painted instead of digitally generated. Definitely finagle with the settings before you give up on it. It requires fine tuning. | 23 | | Paths | Delete All Paths | Deletes all paths in selected glyphs. | 24 | | Paths | Delete Largest Path | Deletes the largest path in the selected glyph. | 25 | | Paths | Keep Largest Path | Keeps only the largest path in the selected glyph, deleting the rest. | 26 | | Paths | Delete Smallest Path | Deletes the smallest path in the selected glyph. | 27 | | Paths | Keep Largest Path | Deletes all paths in selected glyphs except for the largest path. | 28 | | Paths | Randomly Move Points | Jumbles the points within a certain specified amount. Lets you choose to only have OCP get jumbled. This is really only useful for making ugly things on purpose. | 29 | | Paths | Simplify Shape | This script reduces nodes at a ratio of your choosing. Best when used on grungy, messy, thousand+ node vectors. | 30 | 31 | 32 | # Usage 33 | 34 | Go to Glyphs.app: `window` > `Plugin Manager` > `Scripts`, then search for Kyle Wayne Benson. 35 | 36 | # Other installation 37 | Put the scripts into the *Scripts* folder which appears when you choose *Open Scripts Folder* from the *Scripts* menu. 38 | 39 | For some scripts, you will also need to install Tal Leming's *Vanilla*. Here's how. 40 | 41 | In **Glyphs 2.0 or later**, go to *Glyphs > Preferences > Addons > Modules* and click the *Install Modules* button. You are done and can skip the rest of these installation instructions. 42 | 43 | For **Glyphs 1.x**, open Terminal and copy and paste the following lines and hit return. You can copy all of them at once. Notes: the second line (`curl`) may take a while, the `sudo` line will prompt you for your password (type it and press Return, you will *not* see bullets): 44 | 45 | cd ~/Library/ 46 | curl http://download.robofab.com/RoboFab_599_plusAllDependencies.zip > robofab.zip 47 | unzip -o robofab.zip -d Python_Installs 48 | rm robofab.zip 49 | cd Python_Installs/Vanilla/ 50 | sudo python2.6 setup.py install 51 | 52 | 53 | And you are done. The installation should be effective immediately, but in case it does not work right away, you may want to restart your Mac or log out and back in again. 54 | 55 | While we're at it, we can also install Robofab, DialogKit, and FontTools. You do not need those for my scripts though: 56 | 57 | cd ../Robofab/ 58 | sudo python2.6 setup.py install 59 | cd ../DialogKit/ 60 | sudo python2.6 install.py 61 | cd ../FontTools/ 62 | sudo python2.6 setup.py install 63 | 64 | 65 | ## Credits 66 | All my code borrows heavily from existing [mekkablue](https://github.com/mekkablue/), and definitely 100% couldn't exist without his prolific amounts of open source code. Praise be to him. 67 | 68 | # License 69 | 70 | Copyright 2017 Kyle Wayne Benson (@kylewaynebenson) 71 | Some code samples by Rainer Erich Scheichelbauer (@mekkablue), Georg Seifert (@schriftgestalt) and Tal Leming (@typesupply). 72 | 73 | Licensed under the Apache License, Version 2.0 (the "License"); 74 | you may not use the software provided here except in compliance with the License. 75 | You may obtain a copy of the License at 76 | 77 | http://www.apache.org/licenses/LICENSE-2.0 78 | 79 | See the License file included in this repository for further details. 80 | -------------------------------------------------------------------------------- /Components/AddCornerComponent.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Add Corner Component 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Adds a selected corner component to the selected node(s) in Glyphs 3. 5 | If multiple nodes are selected, it first applies the "sharpen corner" function. 6 | Option to apply on all compatible masters. 7 | """ 8 | 9 | import GlyphsApp 10 | from GlyphsApp import CORNER 11 | from vanilla import * 12 | 13 | class AddCornerComponentDialog(object): 14 | 15 | def __init__(self): 16 | # Get available corner components 17 | self.corner_components = [g.name for g in Glyphs.font.glyphs if g.name.startswith("_corner.")] 18 | 19 | # Set up the dialog 20 | self.w = FloatingWindow((300, 320), "Add Corner Component") 21 | self.w.componentList = List((10, 10, -10, -90), self.corner_components, selectionCallback=self.on_selection) 22 | self.w.allMasters = CheckBox((10, -80, -10, 20), "Apply on all masters", value=True) 23 | self.w.closeAfter = CheckBox((10, -60, -10, 20), "Close popup after running", value=True) 24 | self.w.applyButton = Button((10, -30, -10, 20), "Apply", callback=self.apply_corner) 25 | self.w.center() 26 | self.w.open() 27 | 28 | self.selected_component = None 29 | 30 | def on_selection(self, sender): 31 | selection = sender.getSelection() 32 | if selection: 33 | self.selected_component = self.corner_components[selection[0]] 34 | else: 35 | self.selected_component = None 36 | 37 | def get_path_index(self, layer, path): 38 | for index, p in enumerate(layer.paths): 39 | if p == path: 40 | return index 41 | return -1 42 | 43 | def get_corresponding_nodes(self, glyph, active_layer, selected_nodes): 44 | corresponding_nodes = {} 45 | for layer in glyph.layers: 46 | if layer == active_layer: 47 | corresponding_nodes[layer.layerId] = selected_nodes 48 | continue 49 | 50 | layer_nodes = [] 51 | for active_node in selected_nodes: 52 | active_path = active_node.parent 53 | active_path_index = self.get_path_index(active_layer, active_path) 54 | active_node_index = active_node.index 55 | 56 | if active_path_index != -1 and active_path_index < len(layer.paths): 57 | corresponding_path = layer.paths[active_path_index] 58 | if active_node_index < len(corresponding_path.nodes): 59 | layer_nodes.append(corresponding_path.nodes[active_node_index]) 60 | 61 | if layer_nodes: 62 | corresponding_nodes[layer.layerId] = layer_nodes 63 | 64 | return corresponding_nodes 65 | 66 | def apply_corner(self, sender): 67 | if not self.selected_component: 68 | print("No corner component selected") 69 | return 70 | 71 | font = Glyphs.font 72 | if not font: 73 | print("No font open") 74 | return 75 | 76 | glyph = font.selectedLayers[0].parent 77 | if not glyph: 78 | print("No glyph selected") 79 | return 80 | 81 | active_layer = font.selectedLayers[0] 82 | selected_nodes = [node for path in active_layer.paths for node in path.nodes if node.selected] 83 | 84 | if not selected_nodes: 85 | print("No nodes selected") 86 | return 87 | 88 | all_masters = self.w.allMasters.get() 89 | 90 | if all_masters: 91 | corresponding_nodes = self.get_corresponding_nodes(glyph, active_layer, selected_nodes) 92 | else: 93 | corresponding_nodes = {active_layer.layerId: selected_nodes} 94 | 95 | # Apply sharpening first if multiple nodes are selected 96 | if len(selected_nodes) > 1: 97 | for layer_id, nodes in corresponding_nodes.items(): 98 | layer = glyph.layers[layer_id] 99 | layer.parent.beginUndo() 100 | currentController = Glyphs.font.parent.windowController() 101 | if currentController: 102 | tool = currentController.toolEventHandler() 103 | if tool.__class__.__name__ == "GlyphsToolSelectNormal": 104 | path = nodes[0].parent # Get the path of the first selected node 105 | startIdx = nodes[0].index # Get the index of the first selected node 106 | endIdx = nodes[-1].index # Get the index of the last selected node 107 | tool._makeCorner_firstNodeIndex_endNodeIndex_(path, startIdx, endIdx) 108 | else: 109 | print(f"Current tool is not the normal selection tool in layer {layer.name}") 110 | else: 111 | print(f"No current controller found for layer {layer.name}") 112 | layer.parent.endUndo() 113 | 114 | # Update corresponding_nodes with the sharpened node 115 | active_sharpened_node = [node for path in active_layer.paths for node in path.nodes if node.selected] 116 | if active_sharpened_node: 117 | corresponding_nodes = self.get_corresponding_nodes(glyph, active_layer, active_sharpened_node) 118 | else: 119 | print("Warning: No node remained selected after sharpening in the active layer") 120 | return 121 | # Add the corner component if there is one node selected per master 122 | 123 | for layer_id, nodes in corresponding_nodes.items(): 124 | layer = glyph.layers[layer_id] 125 | if len(nodes) == 1: 126 | for node in nodes: 127 | new_corner = GSHint() 128 | new_corner.type = CORNER 129 | new_corner.name = self.selected_component 130 | new_corner.originNode = node 131 | layer.hints.append(new_corner) 132 | else: 133 | print(f"Warning: sharpen corner failed, sharpen corners manually then try again.") 134 | 135 | print(f"Added corner component '{self.selected_component}' to {len(nodes)} node(s) in layer {layer.name}") 136 | 137 | if self.w.closeAfter.get(): 138 | self.w.close() 139 | 140 | AddCornerComponentDialog() -------------------------------------------------------------------------------- /Glyph Names/SwapGlyphNames.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Swap Glyph Names 2 | # -*- coding: utf-8 -*- 3 | from __future__ import division, print_function, unicode_literals 4 | __doc__ = """ 5 | Takes a list of glyphname1=glyphname2 pairs and swaps glyph names in the font accordingly. When you input "A=A.ss12", A becomes A.ss12 and A.ss12 becomes A. Optionally updates component references. 6 | """ 7 | 8 | import vanilla 9 | import uuid 10 | from AppKit import NSFont 11 | from GlyphsApp import Glyphs 12 | 13 | 14 | class SwapGlyphNames: 15 | def __init__(self): 16 | # Default preferences 17 | self.preferences = { 18 | "renameList": "A=A.ss01", 19 | "allFonts": False, 20 | "updateComponents": False, 21 | } 22 | # Window 'self.w': 23 | windowWidth = 250 24 | windowHeight = 200 25 | windowWidthResize = 800 # user can resize width by this value 26 | windowHeightResize = 800 # user can resize height by this value 27 | self.w = vanilla.FloatingWindow( 28 | (windowWidth, windowHeight), # default window size 29 | "Swap Glyph Names", # window title 30 | minSize=(windowWidth, windowHeight), # minimum size (for resizing) 31 | maxSize=(windowWidth + windowWidthResize, windowHeight + windowHeightResize), # maximum size (for resizing) 32 | ) 33 | 34 | # UI elements: 35 | self.w.text_1 = vanilla.TextBox((10, 12 + 2, -10, 14), "Add lines like glyph1=glyph2 to swap:", sizeStyle='small') 36 | self.w.renameList = vanilla.TextEditor((1, 40, -1, -70), self.preferences["renameList"], callback=self.savePreferences) 37 | self.w.renameList.getNSTextView().setFont_(NSFont.userFixedPitchFontOfSize_(-1.0)) 38 | self.w.renameList.getNSTextView().turnOffLigatures_(1) 39 | self.w.renameList.getNSTextView().useStandardLigatures_(0) 40 | self.w.renameList.selectAll() 41 | 42 | self.w.updateComponents = vanilla.CheckBox((10, -65, 250, 20), "Keep composite glyphs unchanged (maintain old design)", value=self.preferences["updateComponents"], callback=self.savePreferences, sizeStyle="small") 43 | self.w.allFonts = vanilla.CheckBox((10, -40, 100, 20), "⚠️ ALL Fonts", value=self.preferences["allFonts"], callback=self.savePreferences, sizeStyle="small") 44 | 45 | # Run Button: 46 | self.w.runButton = vanilla.Button((-100, -35, -15, -15), "Swap", callback=self.SwapGlyphNamesMain) 47 | 48 | # Open window and focus on it: 49 | self.w.open() 50 | self.w.makeKey() 51 | 52 | def savePreferences(self, sender=None): 53 | """Save current UI state to preferences""" 54 | self.preferences["renameList"] = self.w.renameList.get() 55 | self.preferences["allFonts"] = self.w.allFonts.get() 56 | self.preferences["updateComponents"] = self.w.updateComponents.get() 57 | 58 | def SwapGlyphNamesMain(self, sender): 59 | try: 60 | # clear macro window log: 61 | Glyphs.clearLog() 62 | 63 | # update settings to the latest user input: 64 | self.savePreferences() 65 | 66 | if self.preferences["allFonts"]: 67 | theseFonts = Glyphs.fonts 68 | else: 69 | theseFonts = [Glyphs.font, ] 70 | 71 | for thisFont in theseFonts: 72 | print(f"Processing font: {thisFont.familyName}") 73 | 74 | # Collect all swap pairs first 75 | swapPairs = [] 76 | for thisLine in self.preferences["renameList"].splitlines(): 77 | if thisLine.strip() and "=" in thisLine: 78 | parts = thisLine.split("=") 79 | if len(parts) == 2: 80 | glyphName1 = parts[0].strip() 81 | glyphName2 = parts[1].strip() 82 | if glyphName1 and glyphName2: 83 | swapPairs.append((glyphName1, glyphName2)) 84 | 85 | # Perform the swaps 86 | for glyphName1, glyphName2 in swapPairs: 87 | glyph1 = thisFont.glyphs[glyphName1] 88 | glyph2 = thisFont.glyphs[glyphName2] 89 | 90 | if glyph1 and glyph2: 91 | print(f"Swapping: {glyphName1} ↔ {glyphName2}") 92 | 93 | # Create unique temporary name to avoid conflicts 94 | tempName = f"__temp_swap_{uuid.uuid4().hex[:8]}" 95 | 96 | # Perform three-way swap 97 | glyph1.name = tempName 98 | glyph2.name = glyphName1 99 | glyph1.name = glyphName2 100 | 101 | # Swap export status 102 | glyph1Export = glyph1.export 103 | glyph1.export = glyph2.export 104 | glyph2.export = glyph1Export 105 | 106 | elif glyph1 and not glyph2: 107 | print(f"Renaming: {glyphName1} → {glyphName2} (target doesn't exist)") 108 | glyph1.name = glyphName2 109 | elif not glyph1 and glyph2: 110 | print(f"Renaming: {glyphName2} → {glyphName1} (source doesn't exist)") 111 | glyph2.name = glyphName1 112 | else: 113 | print(f"Warning: Neither {glyphName1} nor {glyphName2} found in font.") 114 | 115 | # Update components if requested 116 | if self.preferences["updateComponents"]: 117 | print("Updating component references to maintain old designs...") 118 | self.updateComponentReferences(thisFont, swapPairs) 119 | else: 120 | print("Component references unchanged - composite glyphs will use new designs automatically") 121 | 122 | print("Swap operation completed!") 123 | 124 | self.w.close() # delete if you want window to stay open 125 | except Exception as e: 126 | # brings macro window to front and reports error: 127 | Glyphs.showMacroWindow() 128 | print(f"Swap Glyph Names Error: {e}") 129 | import traceback 130 | print(traceback.format_exc()) 131 | 132 | def updateComponentReferences(self, font, swapPairs): 133 | """Update component references to maintain old designs after glyph name swap""" 134 | # Create a mapping of old names to new names 135 | nameMapping = {} 136 | for name1, name2 in swapPairs: 137 | nameMapping[name1] = name2 138 | nameMapping[name2] = name1 139 | 140 | # Go through all glyphs and update component references 141 | # This ensures composite glyphs keep their original appearance 142 | componentsUpdated = 0 143 | for glyph in font.glyphs: 144 | for layer in glyph.layers: 145 | for component in layer.components: 146 | oldComponentName = component.componentName 147 | if oldComponentName in nameMapping: 148 | newComponentName = nameMapping[oldComponentName] 149 | component.componentName = newComponentName 150 | print(f" Updated component in {glyph.name}: {oldComponentName} → {newComponentName}") 151 | componentsUpdated += 1 152 | 153 | if componentsUpdated > 0: 154 | print(f" Total components updated: {componentsUpdated}") 155 | else: 156 | print(" No components needed updating") 157 | 158 | 159 | SwapGlyphNames() -------------------------------------------------------------------------------- /Paths/CreateCastShadow.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Create Cast Shadow 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Replace each selected glyphs with a cast shadow 6 | """ 7 | 8 | import vanilla 9 | import GlyphsApp 10 | 11 | class CastShadow( object ): 12 | def __init__( self ): 13 | # Window 'self.w': 14 | windowWidth = 250 15 | windowHeight = 180 16 | windowWidthResize = 300 # user can resize width by this value 17 | windowHeightResize = 0 # user can resize height by this value 18 | self.w = vanilla.Window( 19 | ( windowWidth, windowHeight ), # default window size 20 | "Create Cast Shadow", # window title 21 | minSize = ( windowWidth, windowHeight ), # minimum size (for resizing) 22 | maxSize = ( windowWidth + windowWidthResize, windowHeight + windowHeightResize ), # maximum size (for resizing) 23 | autosaveName = "com.kylewaynebenson.CastShadow.mainwindow" # stores last window position and size 24 | ) 25 | 26 | # UI elements: 27 | self.w.text_1 = vanilla.TextBox( (15, 10, -15, 30), "Create Cast shadow that is this many units removed from original drawing:", sizeStyle='small' ) 28 | 29 | self.w.text_2 = vanilla.TextBox( ( 15, 60, 20, 20), "X:", sizeStyle='regular' ) 30 | self.w.xAxis = vanilla.EditText( ( 40, 60-1, 50, 21), "-20", sizeStyle='regular' ) 31 | self.w.text_3 = vanilla.TextBox( (-95, 60, 20, 20), "Y:", sizeStyle='regular' ) 32 | self.w.yAxis = vanilla.EditText( (-20-50, 60-1, -20, 21), "-20", sizeStyle='regular' ) 33 | 34 | self.w.text_4 = vanilla.TextBox( (15, 90, 52, 20), "Stroke:", sizeStyle='regular' ) 35 | self.w.offset = vanilla.EditText( (68, 90-1, 50, 21), "4", sizeStyle='regular' ) 36 | 37 | self.w.text_5 = vanilla.TextBox( (-110, 90, 52, 20), "Gap:", sizeStyle='regular' ) 38 | self.w.gap = vanilla.EditText( (-70, 90-1, 50, 21), "0", sizeStyle='regular' ) 39 | 40 | 41 | # Run Button: 42 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Create Shadows", sizeStyle='regular', callback=self.CastShadowMain ) 43 | self.w.setDefaultButton( self.w.runButton ) 44 | 45 | # Open window and focus on it: 46 | self.w.open() 47 | self.w.makeKey() 48 | 49 | 50 | def deleteStrayPoints( self, thisLayer): 51 | for i in range(len(thisLayer.paths))[::-1]: 52 | thisPath = thisLayer.paths[i] 53 | for a, b in zip(thisPath.nodes, thisPath.nodes[1:]): 54 | if (int(a.x) == int(b.x)) & ((abs(int(a.y) - int(b.y)) == 1) | (abs(int(b.y) - int(a.y)) == 1)): 55 | thisPath.removeNodeCheckKeepShape_(b) 56 | for a, b in zip(thisPath.nodes, thisPath.nodes[1:]): 57 | if (a.type == b.type): 58 | if (int(a.x) == int(b.x)) & (int(a.y) == int(b.y)): 59 | thisPath.removeNodeCheckKeepShape_(b) 60 | #This section modified from Mekkablue thread https://forum.glyphsapp.com/t/suggestion-reduce-points-command/3490/2 61 | def cleanUpPath( self, thisLayer, threshold ): 62 | if threshold > 12: 63 | threshold = 12 64 | for t in range(3): 65 | for i in range(len(thisLayer.paths))[::-1]: 66 | thisPath = thisLayer.paths[i] 67 | pathlength = len(thisPath) 68 | for i in range(pathlength)[::-1]: 69 | n = thisPath.nodes[i] 70 | previousNode = None 71 | if i > 0: 72 | previousNode = thisPath.nodes[i-1] 73 | if previousNode.type == 65 and i > 2: 74 | previousNode = thisPath.nodes[i-3] 75 | 76 | nextNode = None 77 | if i < pathlength: 78 | nextNode = thisPath.nodes[i+1] 79 | if nextNode.type == 65 and i < pathlength-2: 80 | nextNode = thisPath.nodes[(i+3)%pathlength] 81 | 82 | if nextNode and previousNode: 83 | nextDistance = distance(nextNode.position, n.position) 84 | previousDistance = distance(previousNode.position, n.position) 85 | nextDistanceIsSmall = previousDistance < threshold 86 | previousDistanceIsSmall = nextDistance < threshold 87 | 88 | if (nextDistanceIsSmall and previousDistanceIsSmall): 89 | thisPath.removeNodeCheckKeepShape_( n ) 90 | 91 | 92 | def CastShadowMain( self, sender ): 93 | pathOp = GSPathOperator.alloc().init() 94 | self.offsetCurveFilter = NSClassFromString("GlyphsFilterOffsetCurve") 95 | Font = Glyphs.font 96 | selectedLayers = Font.selectedLayers 97 | xAxis = float(self.w.xAxis.get()) 98 | yAxis = float(self.w.yAxis.get()) 99 | offsetX = float(self.w.offset.get()) 100 | offsetY = float(self.w.offset.get()) 101 | shadowLen = int(max(abs(yAxis), abs(xAxis))) 102 | distance = int(self.w.gap.get()) 103 | glyphsChanged = [] 104 | try: 105 | 106 | for thisLayer in selectedLayers: 107 | 108 | glyphsChanged.append( thisLayer.parent.name ) 109 | thisLayer.parent.beginUndo() # wrapper for undo function 110 | 111 | thisLayer.decomposeComponents() # decompose components 112 | thisLayer.correctPathDirection() # double counter (B and 8) get missed here? 113 | thisLayer.correctPathDirection() # single counters (D O) get bad now too 114 | 115 | addPathList = NSMutableArray.array() 116 | prePathList = NSMutableArray.array() 117 | 118 | #save original outline 119 | for thisPath in thisLayer.paths: 120 | prePathList.append( thisPath ) 121 | 122 | for thisPath in thisLayer.paths: 123 | count = 1 + distance 124 | for i in range(shadowLen): 125 | newPath = GSPath() 126 | #each layer of the cast shadow 127 | for n in thisPath.nodes: 128 | newNode = GSNode() 129 | # print "new guy \n" 130 | # print thisNode.x, thisNode.y 131 | if xAxis < 0: 132 | setX = n.x - count 133 | if yAxis < 0: 134 | setY = n.y - count 135 | if xAxis > 0: 136 | setX = n.x + count 137 | if yAxis > 0: 138 | setY = n.y + count 139 | newNode.type = n.type 140 | newNode.setPosition_((setX, setY)) 141 | newPath.addNode_( newNode ) 142 | #add shadow stack duplicate array 143 | newPath.closed = thisPath.closed 144 | addPathList.append( newPath ) 145 | count += 1 146 | 147 | #merge shadow into path 148 | for thisPath in addPathList: 149 | thisLayer.addPath_( thisPath ) 150 | 151 | thisLayer.removeOverlap() 152 | self.deleteStrayPoints( thisLayer ) 153 | self.cleanUpPath( thisLayer, shadowLen ) 154 | 155 | #offset shadow 156 | if (offsetX != 0) & (offsetY != 0): #setting stroke thickness 157 | self.offsetCurveFilter.offsetLayer_offsetX_offsetY_makeStroke_position_error_shadow_( thisLayer, offsetX, offsetY, False, 0.5, None, None ) 158 | 159 | self.deleteStrayPoints( thisLayer ) 160 | 161 | #reverse shadow 162 | for thisPath in thisLayer.paths: 163 | thisPath.reverse() 164 | 165 | #punch out the old drawing 166 | pathOp.removeOverlapPaths_error_( prePathList, None) 167 | for thisPath in prePathList: 168 | thisLayer.addPath_( thisPath ) 169 | 170 | thisLayer.correctPathDirection() 171 | thisLayer.parent.endUndo() # wrapper for undo function 172 | 173 | print "Created cast shadow for these glyphs:", glyphsChanged 174 | 175 | 176 | except Exception, e: 177 | # print error 178 | Glyphs.showMacroWindow() 179 | print "Create Cast Shadow Error: %s" % e 180 | 181 | CastShadow() -------------------------------------------------------------------------------- /Font Info/SetAxisLocation.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Set Axis Location 2 | # -*- coding: utf-8 -*- 3 | __doc__ = """ 4 | Sets the axis location custom parameter in all active instances. 5 | Optionally includes inactive instances and can use weight/width classes instead of axis coordinates. 6 | """ 7 | 8 | import vanilla 9 | from GlyphsApp import Glyphs 10 | 11 | class SetAxisLocationDialog: 12 | """Dialog for setting axis location parameters""" 13 | 14 | def __init__(self): 15 | # Default preferences 16 | self.preferences = { 17 | "includeInactive": False, 18 | "setWidthByClass": False, 19 | "setWeightByClass": False, 20 | } 21 | 22 | # Window setup 23 | self.w = vanilla.Window((320, 200), "Set Axis Location", minSize=(320, 200)) 24 | 25 | # Checkboxes 26 | y = 20 27 | self.w.includeInactive = vanilla.CheckBox((20, y, 280, 20), "Include inactive instances", 28 | value=self.preferences["includeInactive"], 29 | callback=self.savePreferences) 30 | 31 | y += 30 32 | self.w.setWidthByClass = vanilla.CheckBox((20, y, 280, 20), "Set width using width class", 33 | value=self.preferences["setWidthByClass"], 34 | callback=self.savePreferences) 35 | 36 | y += 30 37 | self.w.setWeightByClass = vanilla.CheckBox((20, y, 280, 20), "Set weight using weight class", 38 | value=self.preferences["setWeightByClass"], 39 | callback=self.savePreferences) 40 | 41 | # Info text 42 | y += 40 43 | self.w.infoText = vanilla.TextBox((20, y, 280, 60), 44 | "If weight/width class options are unchecked, " 45 | "the script will use the axis coordinates set in each instance.", 46 | sizeStyle="small") 47 | 48 | # Buttons 49 | y += 70 50 | self.w.cancelButton = vanilla.Button((20, y, 80, 20), "Cancel", callback=self.cancelCallback) 51 | self.w.runButton = vanilla.Button((220, y, 80, 20), "Set Locations", callback=self.runCallback) 52 | self.w.setDefaultButton(self.w.runButton) 53 | 54 | # Open window and focus on it 55 | self.w.open() 56 | self.w.makeKey() 57 | 58 | def savePreferences(self, sender=None): 59 | """Save current UI state to preferences""" 60 | self.preferences["includeInactive"] = self.w.includeInactive.get() 61 | self.preferences["setWidthByClass"] = self.w.setWidthByClass.get() 62 | self.preferences["setWeightByClass"] = self.w.setWeightByClass.get() 63 | 64 | def cancelCallback(self, sender): 65 | """Handle Cancel button click""" 66 | self.w.close() 67 | 68 | def runCallback(self, sender): 69 | """Handle Run button click""" 70 | # Update preferences 71 | self.savePreferences() 72 | 73 | # Run the main function 74 | set_axis_locations(self.preferences) 75 | 76 | # Close dialog 77 | self.w.close() 78 | 79 | def set_axis_locations(options): 80 | """Set axis location custom parameters based on options""" 81 | 82 | # Check if we have a font open 83 | font = Glyphs.font 84 | if not font: 85 | print("No font open") 86 | return 87 | 88 | if not font.instances: 89 | print("No instances found in font") 90 | return 91 | 92 | print(f"Setting axis locations with options: {options}") 93 | 94 | # Get instances to process 95 | if options["includeInactive"]: 96 | instances_to_process = font.instances 97 | print(f"Processing all {len(font.instances)} instances (including inactive)") 98 | else: 99 | instances_to_process = [instance for instance in font.instances if instance.active] 100 | print(f"Processing {len(instances_to_process)} active instances") 101 | 102 | if not instances_to_process: 103 | print("No instances to process") 104 | return 105 | 106 | # Process each instance 107 | for instance in instances_to_process: 108 | axis_locations = [] 109 | 110 | # Process each axis in the font 111 | for i, axis in enumerate(font.axes): 112 | axis_name = axis.name 113 | axis_tag = axis.axisTag 114 | 115 | # Determine the value to use for this axis 116 | if axis_tag == "wght" and options["setWeightByClass"]: 117 | # Use weight class 118 | weight_value = get_weight_from_class(instance.weightClass) 119 | axis_locations.append(create_axis_location_entry(axis_name, weight_value)) 120 | print(f" {instance.name}: {axis_name} = {weight_value} (from weight class {instance.weightClass})") 121 | 122 | elif axis_tag == "wdth" and options["setWidthByClass"]: 123 | # Use width class 124 | width_value = get_width_from_class(instance.widthClass) 125 | axis_locations.append(create_axis_location_entry(axis_name, width_value)) 126 | print(f" {instance.name}: {axis_name} = {width_value} (from width class {instance.widthClass})") 127 | 128 | else: 129 | # Use axis coordinates from instance 130 | try: 131 | coord_value = instance.coordinateForAxisIndex_(i) 132 | axis_locations.append(create_axis_location_entry(axis_name, coord_value)) 133 | print(f" {instance.name}: {axis_name} = {coord_value} (from axis coordinates)") 134 | except Exception as e: 135 | print(f" Error getting axis value for {instance.name}, {axis_name}: {e}") 136 | 137 | # Set the axis location parameter 138 | if axis_locations: 139 | instance.customParameters["Axis Location"] = tuple(axis_locations) 140 | print(f" Set Axis Location for {instance.name}") 141 | else: 142 | print(f" No axis location set for {instance.name} (no valid values found)") 143 | 144 | print("Axis location setting completed!") 145 | 146 | def create_axis_location_entry(axis_name, location_value): 147 | """Create an axis location entry dictionary""" 148 | from Foundation import NSDictionary 149 | return NSDictionary.alloc().initWithObjects_forKeys_((axis_name, location_value), ("Axis", "Location")) 150 | 151 | def get_weight_from_class(weight_class): 152 | """Convert weight class to weight axis value""" 153 | # Standard weight class to weight axis mapping 154 | weight_mapping = { 155 | 100: 100, # Thin 156 | 200: 200, # Extra Light 157 | 300: 300, # Light 158 | 400: 400, # Regular 159 | 500: 500, # Medium 160 | 600: 600, # Semi Bold 161 | 700: 700, # Bold 162 | 800: 800, # Extra Bold 163 | 900: 900, # Black 164 | } 165 | 166 | return weight_mapping.get(weight_class, weight_class) 167 | 168 | def get_width_from_class(width_class): 169 | """Convert width class to width axis value""" 170 | # Standard width class to width axis percentage mapping 171 | width_mapping = { 172 | 1: 50, # Ultra Condensed 173 | 2: 62.5, # Extra Condensed 174 | 3: 75, # Condensed 175 | 4: 87.5, # Semi Condensed 176 | 5: 100, # Normal 177 | 6: 112.5, # Semi Expanded 178 | 7: 125, # Expanded 179 | 8: 150, # Extra Expanded 180 | 9: 200, # Ultra Expanded 181 | } 182 | 183 | return width_mapping.get(width_class, 100) 184 | 185 | # Run the script 186 | if __name__ == "__main__": 187 | # Check if we have a font open 188 | font = Glyphs.font 189 | if not font: 190 | print("No font open") 191 | else: 192 | # Show the dialog 193 | dialog = SetAxisLocationDialog() -------------------------------------------------------------------------------- /Metrics/FindMetrics.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Find Metrics 2 | #Description: Find metrics with specific characteristics and open in tab 3 | 4 | import vanilla 5 | from GlyphsApp import Glyphs, GSGlyph, GSFont 6 | 7 | class FindMetricsDialog: 8 | def __init__(self): 9 | # Window dimensions 10 | self.w = vanilla.FloatingWindow((300, 270), "Find Metrics") 11 | 12 | # UI elements 13 | y = 10 14 | self.w.smallerThanLabel = vanilla.TextBox((10, y, 120, 20), "Smaller than:") 15 | self.w.smallerThanValue = vanilla.EditText((130, y, 60, 20), "0") 16 | self.w.smallerThanButton = vanilla.Button((200, y, 90, 20), "Find", callback=self.findSmallerThan) 17 | y += 30 18 | # checkbox to select right side 19 | self.w.smallerRightSide = vanilla.CheckBox((130, y, 280, 20), "Right side") 20 | # checkbox to select left side 21 | self.w.smallerLeftSide = vanilla.CheckBox((10, y, 280, 20), "Left side") 22 | 23 | # dividing line 24 | y += 30 25 | self.w.dividingLine = vanilla.HorizontalLine((10, y, 280, 1)) 26 | 27 | y += 10 28 | self.w.largerThanLabel = vanilla.TextBox((10, y, 120, 20), "Larger than:") 29 | self.w.largerThanValue = vanilla.EditText((130, y, 60, 20), "0") 30 | self.w.largerThanButton = vanilla.Button((200, y, 90, 20), "Find", callback=self.findLargerThan) 31 | y += 30 32 | # checkbox to select right side 33 | self.w.largerRightSide = vanilla.CheckBox((130, y, 280, 20), "Right side") 34 | # checkbox to select left side 35 | self.w.largerLeftSide = vanilla.CheckBox((10, y, 280, 20), "Left side") 36 | 37 | # dividing line 38 | y += 30 39 | self.w.dividingLine2 = vanilla.HorizontalLine((10, y, 280, 1)) 40 | 41 | y += 10 42 | self.w.selectedGlyphsOnly = vanilla.CheckBox((10, y, 280, 20), "Selected glyphs only") 43 | 44 | # glyphs made only of components 45 | y+= 25 46 | self.w.onlyComponents = vanilla.CheckBox((10, y, 280, 20), "Search composite glyphs too") 47 | 48 | y += 35 49 | self.w.statusText = vanilla.TextBox((10, y, 280, 20), "") 50 | 51 | self.w.open() 52 | 53 | def getScope(self): 54 | """Determine which glyphs to check based on checkbox selections""" 55 | font = Glyphs.font 56 | selectedOnly = self.w.selectedGlyphsOnly.get() 57 | includeComponents = self.w.onlyComponents.get() 58 | 59 | if selectedOnly: 60 | glyphs = [layer.parent for layer in font.selectedLayers] 61 | else: 62 | glyphs = font.glyphs 63 | 64 | # Filter out composite glyphs if checkbox is not checked 65 | if not includeComponents: 66 | filtered_glyphs = [] 67 | for glyph in glyphs: 68 | # Check if any layer has paths (not just components) 69 | has_paths = False 70 | for layer in glyph.layers: 71 | if len(layer.paths) > 0: 72 | has_paths = True 73 | break 74 | 75 | # Include glyph if it has paths in any layer 76 | if has_paths: 77 | filtered_glyphs.append(glyph) 78 | glyphs = filtered_glyphs 79 | 80 | return font, glyphs 81 | 82 | def openInTab(self, glyphNames): 83 | """Open the given glyph names in a new tab""" 84 | glyphNames = list(set(glyphNames)) # Remove duplicates 85 | 86 | if not glyphNames: 87 | self.w.statusText.set("No glyphs match the criteria") 88 | return 89 | 90 | font = Glyphs.font 91 | 92 | # Sort the glyphs in a logical order 93 | sorted_names = [] 94 | glyph_data = [] 95 | 96 | # First, collect data about each glyph for sorting 97 | for name in glyphNames: 98 | glyph = font.glyphs[name] 99 | if glyph: 100 | # Get the unicode value if available, otherwise use None 101 | unicode_val = glyph.unicode 102 | category = glyph.category 103 | subcategory = glyph.subCategory 104 | # Store as tuple for sorting 105 | glyph_data.append((name, unicode_val, category, subcategory)) 106 | 107 | # Define a sort key function - similar to Glyphs' category order 108 | def sort_key(item): 109 | name, unicode_val, category, subcategory = item 110 | 111 | # Primary sort: by category (using numeric values to represent category priority) 112 | category_order = { 113 | "Letter": 1, 114 | "Number": 2, 115 | "Punctuation": 3, 116 | "Symbol": 4, 117 | "Mark": 5, 118 | None: 99 119 | } 120 | 121 | # Secondary sort: by subcategory 122 | subcategory_order = { 123 | "Uppercase": 1, 124 | "Lowercase": 2, 125 | "Smallcaps": 3, 126 | "Decimal Digit": 1, 127 | None: 99 128 | } 129 | 130 | # If we have Unicode, use it for tertiary sorting 131 | unicode_sort = int(unicode_val, 16) if unicode_val else 0xFFFFFF 132 | 133 | return ( 134 | category_order.get(category, 99), 135 | subcategory_order.get(subcategory, 99), 136 | unicode_sort, 137 | name # Finally sort by name for consistent ordering 138 | ) 139 | 140 | # Sort the glyph data 141 | glyph_data.sort(key=sort_key) 142 | 143 | # Extract the sorted names 144 | sorted_names = [item[0] for item in glyph_data] 145 | 146 | # Create a new tab with sorted glyphs 147 | newTab = font.newTab() 148 | formatted_names = [f"/{name}" for name in sorted_names] 149 | newTab.text = "".join(formatted_names) 150 | 151 | self.w.statusText.set(f"Found {len(glyphNames)} glyphs") 152 | 153 | def findSmallerThan(self, sender): 154 | """Find glyphs with metrics smaller than the specified value""" 155 | try: 156 | value = int(self.w.smallerThanValue.get()) 157 | font, glyphs = self.getScope() 158 | 159 | # Get side selections 160 | checkLeftSide = self.w.smallerLeftSide.get() 161 | checkRightSide = self.w.smallerRightSide.get() 162 | 163 | # If neither is selected, check both sides (default behavior) 164 | if not checkLeftSide and not checkRightSide: 165 | checkLeftSide = True 166 | checkRightSide = True 167 | 168 | matchingGlyphs = [] 169 | 170 | # Only check the current master 171 | master = font.selectedFontMaster 172 | 173 | for glyph in glyphs: 174 | layer = glyph.layers[master.id] 175 | # Only check the selected sides 176 | if (checkLeftSide and layer.LSB < value) or (checkRightSide and layer.RSB < value): 177 | matchingGlyphs.append(glyph.name) 178 | 179 | self.openInTab(matchingGlyphs) 180 | 181 | except ValueError: 182 | self.w.statusText.set("Please enter a valid number") 183 | 184 | def findLargerThan(self, sender): 185 | """Find glyphs with metrics larger than the specified value""" 186 | try: 187 | value = int(self.w.largerThanValue.get()) 188 | font, glyphs = self.getScope() 189 | 190 | # Get side selections 191 | checkLeftSide = self.w.largerLeftSide.get() 192 | checkRightSide = self.w.largerRightSide.get() 193 | 194 | # If neither is selected, check both sides (default behavior) 195 | if not checkLeftSide and not checkRightSide: 196 | checkLeftSide = True 197 | checkRightSide = True 198 | 199 | matchingGlyphs = [] 200 | 201 | # Only check the current master 202 | master = font.selectedFontMaster 203 | 204 | for glyph in glyphs: 205 | layer = glyph.layers[master.id] 206 | # Only check the selected sides 207 | if (checkLeftSide and layer.LSB > value) or (checkRightSide and layer.RSB > value): 208 | matchingGlyphs.append(glyph.name) 209 | 210 | self.openInTab(matchingGlyphs) 211 | 212 | except ValueError: 213 | self.w.statusText.set("Please enter a valid number") 214 | 215 | 216 | # Run the script 217 | FindMetricsDialog() 218 | 219 | -------------------------------------------------------------------------------- /Paths/DeleteXPath.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Delete X Path 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Finds and deletes a specific path, with the option to delete across all masters. 6 | """ 7 | 8 | from AppKit import NSMutableIndexSet 9 | import GlyphsApp 10 | from vanilla import Window, PopUpButton, CheckBox, Button, TextBox 11 | 12 | class DeletePathDialog: 13 | """Dialog for delete path settings""" 14 | 15 | def __init__(self): 16 | # Window setup 17 | self.w = Window((300, 180), "Delete Path Settings", minSize=(300, 180)) 18 | 19 | # Path selection dropdown 20 | y = 20 21 | self.w.pathLabel = TextBox((20, y, 100, 20), "Which path:") 22 | self.w.pathPopup = PopUpButton((130, y, 150, 20), [ 23 | "Selected", "Smallest", "Largest", "First", "Last", "Second", "Third", "Fourth", "Fifth", "Sixth" 24 | ]) 25 | self.w.pathPopup.set(0) # Default to Selected 26 | 27 | # Delete across all masters checkbox 28 | y += 30 29 | self.w.allMasters = CheckBox((20, y, 260, 20), "Delete across all masters") 30 | self.w.allMasters.set(False) 31 | 32 | # Close after running checkbox 33 | y += 30 34 | self.w.closeAfterRunning = CheckBox((20, y, 200, 20), "Close after running") 35 | self.w.closeAfterRunning.set(True) 36 | 37 | # Buttons 38 | y += 40 39 | self.w.cancelButton = Button((20, y, 80, 20), "Cancel", callback=self.cancelCallback) 40 | self.w.okButton = Button((200, y, 80, 20), "OK", callback=self.okCallback) 41 | self.w.setDefaultButton(self.w.okButton) 42 | 43 | # Result storage 44 | self.result = None 45 | 46 | def okCallback(self, sender): 47 | """Handle OK button click""" 48 | path_options = ["selected", "smallest", "largest", "first", "last", "second", "third", "fourth", "fifth", "sixth"] 49 | 50 | self.result = { 51 | 'path_type': path_options[self.w.pathPopup.get()], 52 | 'all_masters': self.w.allMasters.get(), 53 | 'close_after_running': self.w.closeAfterRunning.get() 54 | } 55 | 56 | if self.result['close_after_running']: 57 | self.w.close() 58 | 59 | # Run the delete function with the selected options 60 | delete_path_with_options(self.result) 61 | 62 | def cancelCallback(self, sender): 63 | """Handle Cancel button click""" 64 | self.w.close() 65 | 66 | def show(self): 67 | """Show the dialog""" 68 | self.w.open() 69 | thisFont = Glyphs.font # frontmost font 70 | thisFontMaster = thisFont.selectedFontMaster # active master 71 | listOfSelectedLayers = thisFont.selectedLayers # active layers of selected glyphs 72 | 73 | def delete_path_with_options(options): 74 | """Delete paths with user-specified options""" 75 | 76 | # Check if we have a font open 77 | font = Glyphs.font 78 | if not font: 79 | print("No font open") 80 | return 81 | 82 | # Check if we have glyphs selected 83 | if not font.selectedLayers: 84 | print("No glyphs selected") 85 | return 86 | 87 | print(f"Deleting {options['path_type']} path(s)") 88 | print(f"Options: {options}") 89 | 90 | font.disableUpdateInterface() # suppresses UI updates in Font View 91 | 92 | for layer in font.selectedLayers: 93 | glyph = layer.parent 94 | glyph.beginUndo() # begin undo grouping 95 | 96 | # For "selected" mode, get the selected path index from the current layer 97 | selected_path_index = None 98 | if options['path_type'] == 'selected': 99 | selected_path_index = get_selected_path_index(layer) 100 | if selected_path_index is None: 101 | print(f"No path selected in {glyph.name}") 102 | glyph.endUndo() 103 | continue 104 | 105 | if options['all_masters']: 106 | # Delete from all masters 107 | for master in font.masters: 108 | target_layer = glyph.layers[master.id] 109 | if target_layer and target_layer.paths: 110 | if options['path_type'] == 'selected': 111 | # Use the same path index across all masters 112 | delete_path_by_index(target_layer, selected_path_index) 113 | else: 114 | delete_specific_path(target_layer, options['path_type']) 115 | else: 116 | # Delete only from current layer 117 | if layer.paths: 118 | if options['path_type'] == 'selected': 119 | delete_path_by_index(layer, selected_path_index) 120 | else: 121 | delete_specific_path(layer, options['path_type']) 122 | 123 | glyph.endUndo() # end undo grouping 124 | 125 | font.enableUpdateInterface() # re-enables UI updates in Font View 126 | 127 | def get_selected_path_index(layer): 128 | """Get the index of the currently selected path""" 129 | if not layer.paths: 130 | return None 131 | 132 | # Check if there's a selection 133 | if not layer.selection: 134 | return None 135 | 136 | # Find which path contains the selected nodes 137 | for i, path in enumerate(layer.paths): 138 | for node in path.nodes: 139 | if node in layer.selection: 140 | return i 141 | 142 | return None 143 | 144 | def delete_path_by_index(layer, path_index): 145 | """Delete a path at a specific index (from layer.paths)""" 146 | if not layer.paths or path_index is None: 147 | return 148 | 149 | if path_index < 0 or path_index >= len(layer.paths): 150 | print(f"Path index {path_index} out of range (layer has {len(layer.paths)} paths)") 151 | return 152 | 153 | if Glyphs.versionNumber >= 3: 154 | # Glyphs 3 code - need to find the shape index that corresponds to this path index 155 | target_path = layer.paths[path_index] 156 | shape_index = None 157 | 158 | for i, shape in enumerate(layer.shapes): 159 | if hasattr(shape, 'nodes') and shape == target_path: 160 | shape_index = i 161 | break 162 | 163 | if shape_index is not None: 164 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 165 | pathsToBeRemoved.addIndex_(shape_index) 166 | layer.removeShapesAtIndexes_(pathsToBeRemoved) 167 | else: 168 | print(f"Could not find shape index for path {path_index}") 169 | else: 170 | # Glyphs 2 code 171 | layer.removePathAtIndex_(path_index) 172 | 173 | def delete_specific_path(layer, path_type): 174 | """Delete a specific path based on the criteria""" 175 | 176 | if not layer.paths: 177 | print(f"No paths to delete in layer") 178 | return 179 | 180 | path_to_delete_index = get_path_index_to_delete(layer, path_type) 181 | 182 | if path_to_delete_index is None: 183 | print(f"Could not find {path_type} path to delete") 184 | return 185 | 186 | if Glyphs.versionNumber >= 3: 187 | # Glyphs 3 code 188 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 189 | pathsToBeRemoved.addIndex_(path_to_delete_index) 190 | layer.removeShapesAtIndexes_(pathsToBeRemoved) 191 | else: 192 | # Glyphs 2 code 193 | layer.removePathAtIndex_(path_to_delete_index) 194 | 195 | def get_path_index_to_delete(layer, path_type): 196 | """Get the index of the path to delete based on criteria""" 197 | 198 | if not layer.paths: 199 | return None 200 | 201 | num_paths = len(layer.paths) 202 | 203 | if path_type == "smallest": 204 | areas = [path.area() for path in layer.paths] 205 | min_area = min(areas) 206 | return areas.index(min_area) 207 | 208 | elif path_type == "largest": 209 | areas = [path.area() for path in layer.paths] 210 | max_area = max(areas) 211 | return areas.index(max_area) 212 | 213 | elif path_type == "first": 214 | return 0 215 | 216 | elif path_type == "last": 217 | return num_paths - 1 218 | 219 | elif path_type == "second": 220 | return 1 if num_paths > 1 else None 221 | 222 | elif path_type == "third": 223 | return 2 if num_paths > 2 else None 224 | 225 | elif path_type == "fourth": 226 | return 3 if num_paths > 3 else None 227 | 228 | elif path_type == "fifth": 229 | return 4 if num_paths > 4 else None 230 | 231 | elif path_type == "sixth": 232 | return 5 if num_paths > 5 else None 233 | 234 | return None 235 | 236 | def deleteLargestPath( thisLayer ): 237 | """Original function - kept for backward compatibility""" 238 | layerarea = [] 239 | for thisPath in thisLayer.paths: 240 | layerarea.append(thisPath.area()) 241 | if Glyphs.versionNumber >= 3: 242 | # Glyphs 3 code 243 | pathsToBeRemoved = NSMutableIndexSet.alloc().init() 244 | 245 | for i, thisPath in enumerate(thisLayer.shapes) : 246 | if thisPath.area() == min(layerarea): 247 | pathsToBeRemoved.addIndex_( i ) 248 | 249 | thisLayer.removeShapesAtIndexes_( pathsToBeRemoved ) 250 | else: 251 | # Glyphs 2 code 252 | indexesOfPathsToBeRemoved = [] 253 | 254 | numberOfPaths = len(thisLayer.paths) 255 | for thisPathNumber in range( numberOfPaths ): 256 | if thisPathNumber < (numberOfPaths - 1): 257 | thisPath = thisLayer.paths[thisPathNumber] 258 | if thisPath.area() == min(layerarea): 259 | indexesOfPathsToBeRemoved.append( thisPathNumber ) 260 | 261 | if indexesOfPathsToBeRemoved: 262 | for thatIndex in reversed( sorted( indexesOfPathsToBeRemoved )): 263 | thisLayer.removePathAtIndex_( thatIndex ) 264 | 265 | # Run the script 266 | if __name__ == "__main__": 267 | # Check if we have a font open and glyphs selected first 268 | font = Glyphs.font 269 | if not font: 270 | print("No font open") 271 | elif not font.selectedLayers: 272 | print("No glyphs selected") 273 | else: 274 | # Show the dialog 275 | dialog = DeletePathDialog() 276 | dialog.show() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /Paths/CreateHandtooledShadow.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: Create Handtool 2 | #Created by Kyle Wayne Benson 3 | # -*- coding: utf-8 -*- 4 | __doc__=""" 5 | Create a handtool effect (inner shadow + border) for selected glyphs 6 | """ 7 | 8 | import vanilla 9 | from vanilla import * 10 | import GlyphsApp 11 | from GlyphsApp import Glyphs, GSLayer, GSPath, GSNode 12 | 13 | class HandtoolEffect(object): 14 | def __init__(self): 15 | # Window 'self.w': 16 | windowWidth = 280 17 | windowHeight = 270 18 | windowWidthResize = 100 19 | windowHeightResize = 0 20 | self.w = vanilla.Window( 21 | (windowWidth, windowHeight), 22 | "Create Handtool Effect", 23 | minSize=(windowWidth, windowHeight), 24 | maxSize=(windowWidth + windowWidthResize, windowHeight + windowHeightResize), 25 | autosaveName="com.kylewaynebenson.HandtoolEffect.mainwindow" 26 | ) 27 | 28 | # UI elements: 29 | YOffset = 10 30 | 31 | self.w.text_title = vanilla.TextBox((15, YOffset, -15, 30), 32 | "Thin stroke is a measure of the most narrow width of the existing glyph design:", sizeStyle='small') 33 | 34 | YOffset += 35 35 | 36 | # Thinnest part of letter 37 | self.w.text_thinnest = vanilla.TextBox((15, YOffset, 100, 20), "Thin stroke:", sizeStyle='regular') 38 | self.w.thinnestPart = vanilla.EditText((180, YOffset, 60, 21), "5", sizeStyle='regular') 39 | 40 | YOffset += 30 41 | 42 | # Inner shadow settings 43 | self.w.text_export = vanilla.TextBox((15, YOffset, -15, 20), 44 | "These relate to the final handtooled look:", sizeStyle='small') 45 | 46 | YOffset += 25 47 | 48 | # Border size 49 | self.w.text_border = vanilla.TextBox((15, YOffset, 80, 20), "Border size:", sizeStyle='regular') 50 | self.w.borderSize = vanilla.EditText((180, YOffset, 60, 21), "10", sizeStyle='regular') 51 | 52 | YOffset += 35 53 | 54 | self.w.text_shadow = vanilla.TextBox((15, YOffset, 80, 20), "Shadow:", sizeStyle='regular') 55 | self.w.text_shadowX = vanilla.TextBox((100, YOffset, 20, 20), "X:", sizeStyle='regular') 56 | self.w.shadowX = vanilla.EditText((125, YOffset, 40, 21), "-40", sizeStyle='regular') 57 | 58 | self.w.text_shadowY = vanilla.TextBox((170, YOffset, 20, 20), "Y:", sizeStyle='regular') 59 | self.w.shadowY = vanilla.EditText((195, YOffset, 40, 21), "0", sizeStyle='regular') 60 | 61 | YOffset += 30 62 | 63 | # Checkboxes 64 | self.w.allMasters = vanilla.CheckBox((15, YOffset, 200, 20), "Apply to all masters", value=False) 65 | 66 | YOffset += 25 67 | 68 | # Run Button: 69 | self.w.runButton = vanilla.Button((-130, -20-15, -15, -15), "Create Handtool", 70 | sizeStyle='regular', callback=self.HandtoolMain) 71 | self.w.setDefaultButton(self.w.runButton) 72 | 73 | # Open window and focus on it: 74 | self.w.open() 75 | self.w.makeKey() 76 | 77 | def applyOffsetToPaths(self, paths, offsetValue): 78 | """Apply offset to paths and return new paths""" 79 | import objc 80 | OffsetCurveFilter = objc.lookUpClass("GlyphsFilterOffsetCurve") 81 | 82 | newPaths = [] 83 | for path in paths: 84 | # offsetPath returns a list of new paths 85 | result = OffsetCurveFilter.offsetPath_offsetX_offsetY_makeStroke_position_objects_capStyleStart_capStyleEnd_( 86 | path, 87 | offsetValue, 88 | offsetValue, 89 | False, # makeStroke = False for pure offset 90 | 0.5, # position 91 | False, # objects 92 | 0, # capStyleStart 93 | 0 # capStyleEnd 94 | ) 95 | if result: 96 | for newPath in result: 97 | newPaths.append(newPath) 98 | 99 | return newPaths 100 | 101 | def HandtoolMain(self, sender): 102 | try: 103 | Font = Glyphs.font 104 | 105 | if not Font: 106 | Glyphs.showNotification("Handtool Effect", "No font open") 107 | return 108 | 109 | # Get parameters 110 | try: 111 | borderSize = float(self.w.borderSize.get()) 112 | shadowX = float(self.w.shadowX.get()) 113 | shadowY = float(self.w.shadowY.get()) 114 | thinnestPart = float(self.w.thinnestPart.get()) 115 | except ValueError: 116 | Glyphs.showNotification("Handtool Effect", "Invalid numeric values") 117 | return 118 | 119 | allMasters = self.w.allMasters.get() 120 | 121 | glyphsChanged = [] 122 | 123 | # Determine which masters to process 124 | if allMasters: 125 | masters = Font.masters 126 | else: 127 | masters = [Font.selectedFontMaster] 128 | 129 | selectedGlyphs = set([layer.parent for layer in Font.selectedLayers if layer.parent]) 130 | 131 | if not selectedGlyphs: 132 | Glyphs.showNotification("Handtool Effect", "No glyphs selected") 133 | return 134 | 135 | for glyph in selectedGlyphs: 136 | glyphsChanged.append(glyph.name) 137 | 138 | for master in masters: 139 | srcLayer = glyph.layers[master.id] 140 | 141 | if len(srcLayer.paths) == 0: 142 | continue 143 | 144 | # 1) Start with cleaned outline 145 | baseLayer = srcLayer.copy() 146 | baseLayer.removeOverlap() 147 | baseLayer.correctPathDirection() 148 | 149 | # Store cleaned paths (keep original clean for final outline) 150 | cleanPaths = [p.copy() for p in baseLayer.paths] 151 | 152 | # 2) Create inset version for the shadow boundary 153 | # First, smooth out thin parts using thinnestPart value 154 | print("Smoothing thin parts for inset: offset in by %f, then out by %f" % (thinnestPart, thinnestPart)) 155 | 156 | thinnestPart = thinnestPart / 2.0 157 | # Start with clean paths for smoothing 158 | smoothPaths = self.applyOffsetToPaths(cleanPaths, -thinnestPart) 159 | 160 | # Create temp layer for cleanup 161 | tempLayer = GSLayer() 162 | for p in smoothPaths: 163 | tempLayer.paths.append(p) 164 | tempLayer.removeOverlap() 165 | tempLayer.correctPathDirection() 166 | smoothPaths = [p.copy() for p in tempLayer.paths] 167 | 168 | # Then offset outward by thinnestPart to restore size 169 | smoothPaths = self.applyOffsetToPaths(smoothPaths, thinnestPart) 170 | 171 | # Create temp layer for final cleanup 172 | tempLayer2 = GSLayer() 173 | for p in smoothPaths: 174 | tempLayer2.paths.append(p) 175 | tempLayer2.removeOverlap() 176 | tempLayer2.correctPathDirection() 177 | smoothedPaths = [p.copy() for p in tempLayer2.paths] 178 | 179 | # Now apply the actual border inset to the smoothed paths 180 | insetLayer = GSLayer() 181 | 182 | # Apply offset to the smoothed paths 183 | insetValue = -borderSize 184 | print("Applying inset offset: %f" % insetValue) 185 | 186 | insetPaths = self.applyOffsetToPaths(smoothedPaths, insetValue) 187 | 188 | # Add the offset paths to the layer 189 | for p in insetPaths: 190 | insetLayer.paths.append(p) 191 | 192 | insetLayer.removeOverlap() 193 | insetLayer.correctPathDirection() 194 | # Update insetPaths with the final cleaned paths 195 | insetPaths = [p.copy() for p in insetLayer.paths] 196 | 197 | # 3) Create inner shadow by shifting the original shape 198 | shadowLayer = GSLayer() 199 | for p in cleanPaths: 200 | shadowPath = p.copy() 201 | for node in shadowPath.nodes: 202 | node.position = (node.position.x + shadowX, node.position.y + shadowY) 203 | shadowLayer.paths.append(shadowPath) 204 | 205 | # 4) Crop shadow to the part between original and inset 206 | # Get bounds of all paths to create a big rectangle 207 | margin = 400 208 | allNodes = [] 209 | for p in cleanPaths: 210 | for node in p.nodes: 211 | allNodes.append(node.position) 212 | for p in shadowLayer.paths: 213 | for node in p.nodes: 214 | allNodes.append(node.position) 215 | 216 | if allNodes: 217 | minX = min(n.x for n in allNodes) - margin 218 | maxX = max(n.x for n in allNodes) + margin 219 | minY = min(n.y for n in allNodes) - margin 220 | maxY = max(n.y for n in allNodes) + margin 221 | 222 | # Create big rectangle (clockwise = filled) 223 | bigRect = GSPath() 224 | bigRect.closed = True 225 | bigRect.nodes = [ 226 | GSNode((minX, minY)), 227 | GSNode((maxX, minY)), 228 | GSNode((maxX, maxY)), 229 | GSNode((minX, maxY)) 230 | ] 231 | 232 | # Create mask layer: big rect + reversed inset (inset becomes a hole) 233 | maskLayer = GSLayer() 234 | maskLayer.paths.append(bigRect) 235 | for p in insetPaths: 236 | holePath = p.copy() 237 | holePath.reverse() 238 | maskLayer.paths.append(holePath) 239 | maskLayer.removeOverlap() 240 | maskLayer.correctPathDirection() 241 | 242 | # Intersect shadow with mask 243 | visibleShadowLayer = GSLayer() 244 | 245 | # Add shadow 246 | for p in shadowLayer.paths: 247 | visibleShadowLayer.paths.append(p.copy()) 248 | 249 | # Add mask 250 | for p in maskLayer.paths: 251 | visibleShadowLayer.paths.append(p.copy()) 252 | 253 | visibleShadowLayer.removeOverlap() 254 | 255 | # Now subtract the mask (reversed) to keep only shadow part 256 | for p in maskLayer.paths: 257 | cutPath = p.copy() 258 | cutPath.reverse() 259 | visibleShadowLayer.paths.append(cutPath) 260 | 261 | visibleShadowLayer.removeOverlap() 262 | 263 | innerShadowPaths = [p.copy() for p in visibleShadowLayer.paths] 264 | else: 265 | innerShadowPaths = [] 266 | 267 | # 5) Build final layer: original outline + visible shadow 268 | finalLayer = GSLayer() 269 | # Add inner shadow paths 270 | for p in innerShadowPaths: 271 | finalLayer.paths.append(p.copy()) 272 | 273 | # Add the original clean outline 274 | for p in cleanPaths: 275 | finalLayer.paths.append(p.copy()) 276 | 277 | finalLayer.correctPathDirection() 278 | 279 | # Copy attributes and replace layer 280 | finalLayer.layerId = srcLayer.layerId 281 | finalLayer.associatedMasterId = srcLayer.associatedMasterId 282 | finalLayer.width = srcLayer.width 283 | 284 | glyph.layers[master.id] = finalLayer 285 | 286 | # Report results 287 | if glyphsChanged: 288 | print("Created handtool effect for: %s" % ", ".join(glyphsChanged)) 289 | Glyphs.showNotification("Handtool Effect", "Applied to %d glyph(s)" % len(glyphsChanged)) 290 | 291 | except Exception as e: 292 | # Print error 293 | Glyphs.showMacroWindow() 294 | print("Create Handtool Error: %s" % e) 295 | import traceback 296 | print(traceback.format_exc()) 297 | 298 | # Run the script 299 | HandtoolEffect() -------------------------------------------------------------------------------- /Anchors/MirrorAnchorAcrossMasters.py: -------------------------------------------------------------------------------- 1 | # MenuTitle: Mirror anchor across masters 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Takes a selected anchor and places it in corresponding positions 5 | across all masters based on positioning relative to zones and sides. 6 | """ 7 | 8 | import math 9 | from vanilla import Window, PopUpButton, CheckBox, Button, TextBox 10 | 11 | class MirrorAnchorDialog: 12 | """Dialog for mirror anchor settings""" 13 | 14 | def __init__(self): 15 | # Window setup 16 | self.w = Window((300, 200), "Mirror Anchor Settings", minSize=(300, 200)) 17 | 18 | # Y-axis positioning dropdown 19 | y = 20 20 | self.w.yAxisLabel = TextBox((20, y, 100, 20), "Y-axis position:") 21 | self.w.yAxisPopup = PopUpButton((130, y, 150, 20), [ 22 | "Baseline", "X-height", "Cap-height", "Center", "Ascender", "Descender", "Proportional" 23 | ]) 24 | self.w.yAxisPopup.set(6) # Default to Proportional 25 | 26 | # X-axis positioning dropdown 27 | y += 30 28 | self.w.xAxisLabel = TextBox((20, y, 100, 20), "X-axis position:") 29 | self.w.xAxisPopup = PopUpButton((130, y, 150, 20), [ 30 | "Left", "Center", "Right", "Proportional" 31 | ]) 32 | self.w.xAxisPopup.set(3) # Default to Proportional 33 | 34 | # Maintain relative distance checkbox 35 | y += 30 36 | self.w.maintainDistance = CheckBox((20, y, 260, 20), "Maintain relative distance from reference") 37 | self.w.maintainDistance.set(True) 38 | 39 | # Close after running checkbox 40 | y += 30 41 | self.w.closeAfterRunning = CheckBox((20, y, 200, 20), "Close after running") 42 | self.w.closeAfterRunning.set(True) 43 | 44 | # Buttons 45 | y += 40 46 | self.w.cancelButton = Button((20, y, 80, 20), "Cancel", callback=self.cancelCallback) 47 | self.w.okButton = Button((200, y, 80, 20), "OK", callback=self.okCallback) 48 | self.w.setDefaultButton(self.w.okButton) 49 | 50 | # Result storage 51 | self.result = None 52 | 53 | def okCallback(self, sender): 54 | """Handle OK button click""" 55 | y_options = ["baseline", "x-height", "cap-height", "center", "ascender", "descender", "proportional"] 56 | x_options = ["left", "center", "right", "proportional"] 57 | 58 | self.result = { 59 | 'y_position': y_options[self.w.yAxisPopup.get()], 60 | 'x_position': x_options[self.w.xAxisPopup.get()], 61 | 'maintain_distance': self.w.maintainDistance.get(), 62 | 'close_after_running': self.w.closeAfterRunning.get() 63 | } 64 | 65 | if self.result['close_after_running']: 66 | self.w.close() 67 | 68 | # Run the mirror function with the selected options 69 | mirror_anchor_with_options(self.result) 70 | 71 | def cancelCallback(self, sender): 72 | """Handle Cancel button click""" 73 | self.w.close() 74 | 75 | def show(self): 76 | """Show the dialog""" 77 | self.w.open() 78 | from vanilla import Window, PopUpButton, CheckBox, Button, TextBox 79 | 80 | def get_font_zones(master): 81 | """Get the key vertical zones for a master""" 82 | zones = {} 83 | zones['baseline'] = 0 84 | 85 | # Helper function to safely get a metric 86 | def get_metric(metric_name, default_value): 87 | try: 88 | # Try to get from master first 89 | value = getattr(master, metric_name, None) 90 | if value is not None: 91 | return value 92 | except: 93 | pass 94 | 95 | try: 96 | # Try to get from font's custom parameters 97 | value = master.font.customParameters[metric_name] 98 | if value is not None: 99 | return value 100 | except: 101 | pass 102 | 103 | # Fall back to default 104 | return default_value 105 | 106 | zones['x-height'] = get_metric('xHeight', 500) 107 | zones['cap-height'] = get_metric('capHeight', 700) 108 | zones['ascender'] = get_metric('ascender', 800) 109 | zones['descender'] = get_metric('descender', -200) 110 | 111 | return zones 112 | 113 | def get_glyph_bounds(layer): 114 | """Get the left and right bounds of a glyph layer""" 115 | if layer.bounds: 116 | return layer.bounds.origin.x, layer.bounds.origin.x + layer.bounds.size.width 117 | return 0, layer.width 118 | 119 | def is_near_zone(y_pos, zone_value, tolerance=10): 120 | """Check if a position is within tolerance of a zone""" 121 | return abs(y_pos - zone_value) <= tolerance 122 | 123 | def is_near_side(x_pos, left_bound, right_bound, tolerance=10): 124 | """Check if a position is within tolerance of left or right side""" 125 | return abs(x_pos - left_bound) <= tolerance or abs(x_pos - right_bound) <= tolerance 126 | 127 | def is_near_middle(pos, min_val, max_val, tolerance_percent=5): 128 | """Check if a position is within percentage tolerance of the middle""" 129 | middle = (min_val + max_val) / 2 130 | range_size = max_val - min_val 131 | tolerance = range_size * (tolerance_percent / 100.0) 132 | return abs(pos - middle) <= tolerance 133 | 134 | def find_nearest_node(anchor_pos, layer): 135 | """Find the nearest node to the anchor position""" 136 | if not layer.paths: 137 | return None, None 138 | 139 | min_distance = float('inf') 140 | nearest_node = None 141 | nearest_node_info = None 142 | 143 | for path_index, path in enumerate(layer.paths): 144 | for node_index, node in enumerate(path.nodes): 145 | # Calculate distance from anchor to node 146 | dx = anchor_pos.x - node.x 147 | dy = anchor_pos.y - node.y 148 | distance = math.sqrt(dx * dx + dy * dy) 149 | 150 | if distance < min_distance: 151 | min_distance = distance 152 | nearest_node = node 153 | nearest_node_info = { 154 | 'path_index': path_index, 155 | 'node_index': node_index, 156 | 'distance': distance, 157 | 'offset_x': dx, 158 | 'offset_y': dy, 159 | 'node_x': node.x, 160 | 'node_y': node.y 161 | } 162 | 163 | return nearest_node, nearest_node_info 164 | 165 | def get_corresponding_node(target_layer, node_info): 166 | """Get the corresponding node in the target layer""" 167 | if not target_layer.paths or len(target_layer.paths) <= node_info['path_index']: 168 | return None 169 | 170 | target_path = target_layer.paths[node_info['path_index']] 171 | if len(target_path.nodes) <= node_info['node_index']: 172 | return None 173 | 174 | return target_path.nodes[node_info['node_index']] 175 | 176 | def mirror_anchor_with_options(options): 177 | """Mirror anchor with user-specified options""" 178 | 179 | # Check if we have a font open 180 | font = Glyphs.font 181 | if not font: 182 | print("No font open") 183 | return 184 | 185 | # Check if we have a glyph selected 186 | if not font.selectedLayers: 187 | print("No glyph selected") 188 | return 189 | 190 | current_layer = font.selectedLayers[0] 191 | current_glyph = current_layer.parent 192 | current_master = current_layer.associatedFontMaster 193 | 194 | # Check if we have an anchor selected 195 | selection = current_layer.selection 196 | selected_anchor = None 197 | 198 | for item in selection: 199 | if item.__class__.__name__ == "GSAnchor": 200 | selected_anchor = item 201 | break 202 | 203 | if not selected_anchor: 204 | print("No anchor selected") 205 | return 206 | 207 | anchor_name = selected_anchor.name 208 | anchor_pos = selected_anchor.position 209 | print(f"Mirroring anchor '{anchor_name}' from position ({anchor_pos.x}, {anchor_pos.y})") 210 | print(f"Options: {options}") 211 | 212 | # Get reference measurements from current master 213 | current_zones = get_font_zones(current_master) 214 | current_left, current_right = get_glyph_bounds(current_layer) 215 | 216 | # Calculate reference distance if maintaining relative distance 217 | reference_distance = None 218 | if options['maintain_distance']: 219 | reference_distance = calculate_reference_distance( 220 | anchor_pos, options, current_zones, current_left, current_right, current_layer.width, current_layer 221 | ) 222 | print(f"Reference distance: {reference_distance}") 223 | 224 | # Apply to all other masters 225 | for master in font.masters: 226 | if master == current_master: 227 | continue 228 | 229 | target_layer = current_glyph.layers[master.id] 230 | if not target_layer: 231 | continue 232 | 233 | # Calculate new position based on options 234 | new_pos = calculate_new_position_with_options( 235 | options, master, target_layer, anchor_pos, reference_distance 236 | ) 237 | 238 | # Find or create anchor in target layer 239 | target_anchor = None 240 | for anchor in target_layer.anchors: 241 | if anchor.name == anchor_name: 242 | target_anchor = anchor 243 | break 244 | 245 | if not target_anchor: 246 | target_anchor = GSAnchor() 247 | target_anchor.name = anchor_name 248 | target_layer.anchors.append(target_anchor) 249 | 250 | target_anchor.position = new_pos 251 | print(f"Placed anchor in {master.name} at ({new_pos.x}, {new_pos.y})") 252 | 253 | def calculate_reference_distance(anchor_pos, options, zones, left_bound, right_bound, glyph_width, current_layer): 254 | """Calculate the reference distance from the anchor to the specified reference point""" 255 | 256 | # If both axes are proportional and maintain distance is enabled, use node-based calculation 257 | if options['y_position'] == 'proportional' and options['x_position'] == 'proportional': 258 | nearest_node, node_info = find_nearest_node(anchor_pos, current_layer) 259 | if nearest_node and node_info: 260 | print(f"Using node-based positioning: nearest node at path {node_info['path_index']}, node {node_info['node_index']}") 261 | print(f"Distance from node: {node_info['distance']:.1f} units") 262 | return { 263 | 'type': 'node_based', 264 | 'node_info': node_info, 265 | 'offset_x': node_info['offset_x'], 266 | 'offset_y': node_info['offset_y'] 267 | } 268 | 269 | # Fall back to standard reference point calculation 270 | # Calculate Y distance 271 | y_ref = get_y_reference_position(options['y_position'], zones) 272 | y_distance = anchor_pos.y - y_ref 273 | 274 | # Calculate X distance 275 | x_ref = get_x_reference_position(options['x_position'], left_bound, right_bound, glyph_width) 276 | x_distance = anchor_pos.x - x_ref 277 | 278 | return { 279 | 'type': 'reference_based', 280 | 'x': x_distance, 281 | 'y': y_distance 282 | } 283 | 284 | def get_y_reference_position(y_position, zones): 285 | """Get the Y coordinate for the specified reference position""" 286 | position_map = { 287 | 'baseline': zones['baseline'], 288 | 'x-height': zones['x-height'], 289 | 'cap-height': zones['cap-height'], 290 | 'center': (zones['cap-height'] + zones['baseline']) / 2, 291 | 'ascender': zones['ascender'], 292 | 'descender': zones['descender'] 293 | } 294 | return position_map.get(y_position, zones['baseline']) 295 | 296 | def get_x_reference_position(x_position, left_bound, right_bound, glyph_width): 297 | """Get the X coordinate for the specified reference position""" 298 | if x_position == 'left': 299 | return left_bound 300 | elif x_position == 'right': 301 | return right_bound 302 | elif x_position == 'center': 303 | return (left_bound + right_bound) / 2 304 | else: # proportional - use glyph center 305 | return glyph_width / 2 306 | 307 | def calculate_new_position_with_options(options, target_master, target_layer, original_pos, reference_distance): 308 | """Calculate new anchor position based on user options""" 309 | 310 | target_zones = get_font_zones(target_master) 311 | target_left, target_right = get_glyph_bounds(target_layer) 312 | 313 | if options['maintain_distance'] and reference_distance: 314 | if reference_distance.get('type') == 'node_based': 315 | # Use node-based positioning 316 | node_info = reference_distance['node_info'] 317 | corresponding_node = get_corresponding_node(target_layer, node_info) 318 | 319 | if corresponding_node: 320 | # Position anchor relative to the corresponding node 321 | new_x = corresponding_node.x - reference_distance['offset_x'] 322 | new_y = corresponding_node.y - reference_distance['offset_y'] 323 | print(f" Node-based positioning: using node at ({corresponding_node.x}, {corresponding_node.y})") 324 | else: 325 | print(f" Warning: Could not find corresponding node, falling back to proportional") 326 | # Fall back to proportional positioning 327 | new_x, new_y = calculate_proportional_position(original_pos, target_zones, target_left, target_right) 328 | else: 329 | # Use reference point based positioning 330 | y_ref = get_y_reference_position(options['y_position'], target_zones) 331 | x_ref = get_x_reference_position(options['x_position'], target_left, target_right, target_layer.width) 332 | 333 | new_y = y_ref + reference_distance['y'] 334 | new_x = x_ref + reference_distance['x'] 335 | else: 336 | # Direct positioning without maintaining distance 337 | if options['y_position'] == 'proportional': 338 | # Use proportional Y positioning 339 | ratio = original_pos.y / get_font_zones(target_master)['cap-height'] if get_font_zones(target_master)['cap-height'] != 0 else 0 340 | new_y = target_zones['cap-height'] * ratio 341 | else: 342 | new_y = get_y_reference_position(options['y_position'], target_zones) 343 | 344 | if options['x_position'] == 'proportional': 345 | # Use proportional X positioning 346 | ratio = 0.5 # Default to center for proportional 347 | new_x = target_left + (target_right - target_left) * ratio 348 | else: 349 | new_x = get_x_reference_position(options['x_position'], target_left, target_right, target_layer.width) 350 | 351 | # Create a new point using the correct Glyphs constructor 352 | try: 353 | # Try NSPoint first (available in Glyphs environment) 354 | from Foundation import NSPoint 355 | return NSPoint(new_x, new_y) 356 | except ImportError: 357 | # Fallback: create a simple point object or use tuple 358 | class Point: 359 | def __init__(self, x, y): 360 | self.x = x 361 | self.y = y 362 | return Point(new_x, new_y) 363 | 364 | def calculate_proportional_position(original_pos, target_zones, target_left, target_right): 365 | """Calculate proportional position for fallback scenarios""" 366 | # Simple proportional positioning based on cap-height ratio 367 | y_ratio = original_pos.y / target_zones['cap-height'] if target_zones['cap-height'] != 0 else 0 368 | new_y = target_zones['cap-height'] * y_ratio 369 | 370 | # Center horizontally for X proportional 371 | new_x = (target_left + target_right) / 2 372 | 373 | return new_x, new_y 374 | def mirror_anchor(): 375 | """Main function to mirror the selected anchor across masters (original function)""" 376 | 377 | # Check if we have a font open 378 | font = Glyphs.font 379 | if not font: 380 | print("No font open") 381 | return 382 | 383 | # Check if we have a glyph selected 384 | if not font.selectedLayers: 385 | print("No glyph selected") 386 | return 387 | 388 | current_layer = font.selectedLayers[0] 389 | current_glyph = current_layer.parent 390 | current_master = current_layer.associatedFontMaster 391 | 392 | # Check if we have an anchor selected 393 | selection = current_layer.selection 394 | selected_anchor = None 395 | 396 | for item in selection: 397 | if item.__class__.__name__ == "GSAnchor": 398 | selected_anchor = item 399 | break 400 | 401 | if not selected_anchor: 402 | print("No anchor selected") 403 | return 404 | 405 | anchor_name = selected_anchor.name 406 | anchor_pos = selected_anchor.position 407 | print(f"Mirroring anchor '{anchor_name}' from position ({anchor_pos.x}, {anchor_pos.y})") 408 | 409 | # Get reference measurements from current master 410 | current_zones = get_font_zones(current_master) 411 | current_left, current_right = get_glyph_bounds(current_layer) 412 | current_width = current_right - current_left 413 | 414 | # Analyze anchor position relative to zones and bounds 415 | y_analysis = analyze_y_position(anchor_pos.y, current_zones) 416 | x_analysis = analyze_x_position(anchor_pos.x, current_left, current_right, current_layer.width) 417 | 418 | print(f"Y-axis analysis: {y_analysis}") 419 | print(f"X-axis analysis: {x_analysis}") 420 | 421 | # Apply to all other masters 422 | for master in font.masters: 423 | if master == current_master: 424 | continue 425 | 426 | target_layer = current_glyph.layers[master.id] 427 | if not target_layer: 428 | continue 429 | 430 | # Calculate new position based on analysis 431 | new_pos = calculate_new_position( 432 | x_analysis, y_analysis, 433 | master, target_layer, 434 | anchor_pos 435 | ) 436 | 437 | # Find or create anchor in target layer 438 | target_anchor = None 439 | for anchor in target_layer.anchors: 440 | if anchor.name == anchor_name: 441 | target_anchor = anchor 442 | break 443 | 444 | if not target_anchor: 445 | target_anchor = GSAnchor() 446 | target_anchor.name = anchor_name 447 | target_layer.anchors.append(target_anchor) 448 | 449 | target_anchor.position = new_pos 450 | print(f"Placed anchor in {master.name} at ({new_pos.x}, {new_pos.y})") 451 | 452 | # Check if we have a font open 453 | font = Glyphs.font 454 | if not font: 455 | print("No font open") 456 | return 457 | 458 | # Check if we have a glyph selected 459 | if not font.selectedLayers: 460 | print("No glyph selected") 461 | return 462 | 463 | current_layer = font.selectedLayers[0] 464 | current_glyph = current_layer.parent 465 | current_master = current_layer.associatedFontMaster 466 | 467 | # Check if we have an anchor selected 468 | selection = current_layer.selection 469 | selected_anchor = None 470 | 471 | for item in selection: 472 | if item.__class__.__name__ == "GSAnchor": 473 | selected_anchor = item 474 | break 475 | 476 | if not selected_anchor: 477 | print("No anchor selected") 478 | return 479 | 480 | anchor_name = selected_anchor.name 481 | anchor_pos = selected_anchor.position 482 | print(f"Mirroring anchor '{anchor_name}' from position ({anchor_pos.x}, {anchor_pos.y})") 483 | 484 | # Get reference measurements from current master 485 | current_zones = get_font_zones(current_master) 486 | current_left, current_right = get_glyph_bounds(current_layer) 487 | current_width = current_right - current_left 488 | 489 | # Analyze anchor position relative to zones and bounds 490 | y_analysis = analyze_y_position(anchor_pos.y, current_zones) 491 | x_analysis = analyze_x_position(anchor_pos.x, current_left, current_right, current_layer.width) 492 | 493 | print(f"Y-axis analysis: {y_analysis}") 494 | print(f"X-axis analysis: {x_analysis}") 495 | 496 | # Apply to all other masters 497 | for master in font.masters: 498 | if master == current_master: 499 | continue 500 | 501 | target_layer = current_glyph.layers[master.id] 502 | if not target_layer: 503 | continue 504 | 505 | # Calculate new position based on analysis 506 | new_pos = calculate_new_position( 507 | x_analysis, y_analysis, 508 | master, target_layer, 509 | anchor_pos 510 | ) 511 | 512 | # Find or create anchor in target layer 513 | target_anchor = None 514 | for anchor in target_layer.anchors: 515 | if anchor.name == anchor_name: 516 | target_anchor = anchor 517 | break 518 | 519 | if not target_anchor: 520 | target_anchor = GSAnchor() 521 | target_anchor.name = anchor_name 522 | target_layer.anchors.append(target_anchor) 523 | 524 | target_anchor.position = new_pos 525 | print(f"Placed anchor in {master.name} at ({new_pos.x}, {new_pos.y})") 526 | 527 | def analyze_y_position(y_pos, zones): 528 | """Analyze vertical position relative to font zones""" 529 | analysis = {} 530 | 531 | # Check proximity to each zone 532 | for zone_name, zone_value in zones.items(): 533 | if is_near_zone(y_pos, zone_value): 534 | analysis['type'] = 'zone_relative' 535 | analysis['zone'] = zone_name 536 | analysis['offset'] = y_pos - zone_value 537 | return analysis 538 | 539 | # Check if it's in the middle between major zones 540 | cap_baseline_middle = (zones['cap-height'] + zones['baseline']) / 2 541 | if is_near_middle(y_pos, zones['baseline'], zones['cap-height']): 542 | analysis['type'] = 'middle' 543 | analysis['zone_pair'] = ('baseline', 'cap-height') 544 | return analysis 545 | 546 | xheight_baseline_middle = (zones['x-height'] + zones['baseline']) / 2 547 | if is_near_middle(y_pos, zones['baseline'], zones['x-height']): 548 | analysis['type'] = 'middle' 549 | analysis['zone_pair'] = ('baseline', 'x-height') 550 | return analysis 551 | 552 | # Default: use proportional positioning 553 | analysis['type'] = 'proportional' 554 | analysis['baseline_ratio'] = y_pos / zones['cap-height'] if zones['cap-height'] != 0 else 0 555 | return analysis 556 | 557 | def analyze_x_position(x_pos, left_bound, right_bound, glyph_width): 558 | """Analyze horizontal position relative to glyph bounds""" 559 | analysis = {} 560 | 561 | # Check proximity to sides 562 | if is_near_side(x_pos, left_bound, right_bound): 563 | if abs(x_pos - left_bound) <= abs(x_pos - right_bound): 564 | analysis['type'] = 'side_relative' 565 | analysis['side'] = 'left' 566 | analysis['offset'] = x_pos - left_bound 567 | else: 568 | analysis['type'] = 'side_relative' 569 | analysis['side'] = 'right' 570 | analysis['offset'] = x_pos - right_bound 571 | return analysis 572 | 573 | # Check if it's in the middle horizontally 574 | if is_near_middle(x_pos, left_bound, right_bound): 575 | analysis['type'] = 'middle' 576 | return analysis 577 | 578 | # Check proximity to glyph width boundaries (for spacing) 579 | if is_near_side(x_pos, 0, glyph_width): 580 | if abs(x_pos - 0) <= abs(x_pos - glyph_width): 581 | analysis['type'] = 'width_relative' 582 | analysis['side'] = 'left' 583 | analysis['offset'] = x_pos 584 | else: 585 | analysis['type'] = 'width_relative' 586 | analysis['side'] = 'right' 587 | analysis['offset'] = x_pos - glyph_width 588 | return analysis 589 | 590 | # Default: use proportional positioning 591 | analysis['type'] = 'proportional' 592 | if right_bound != left_bound: 593 | analysis['ratio'] = (x_pos - left_bound) / (right_bound - left_bound) 594 | else: 595 | analysis['ratio'] = 0.5 596 | return analysis 597 | 598 | def calculate_new_position(x_analysis, y_analysis, target_master, target_layer, original_pos): 599 | """Calculate new anchor position based on analysis""" 600 | 601 | # Calculate Y position 602 | target_zones = get_font_zones(target_master) 603 | 604 | if y_analysis['type'] == 'zone_relative': 605 | new_y = target_zones[y_analysis['zone']] + y_analysis['offset'] 606 | elif y_analysis['type'] == 'middle': 607 | zone1, zone2 = y_analysis['zone_pair'] 608 | new_y = (target_zones[zone1] + target_zones[zone2]) / 2 609 | else: # proportional 610 | new_y = target_zones['cap-height'] * y_analysis['baseline_ratio'] 611 | 612 | # Calculate X position 613 | target_left, target_right = get_glyph_bounds(target_layer) 614 | 615 | if x_analysis['type'] == 'side_relative': 616 | if x_analysis['side'] == 'left': 617 | new_x = target_left + x_analysis['offset'] 618 | else: 619 | new_x = target_right + x_analysis['offset'] 620 | elif x_analysis['type'] == 'width_relative': 621 | if x_analysis['side'] == 'left': 622 | new_x = x_analysis['offset'] 623 | else: 624 | new_x = target_layer.width + x_analysis['offset'] 625 | elif x_analysis['type'] == 'middle': 626 | new_x = (target_left + target_right) / 2 627 | else: # proportional 628 | new_x = target_left + (target_right - target_left) * x_analysis['ratio'] 629 | 630 | # Create a new point using the correct Glyphs constructor 631 | try: 632 | # Try NSPoint first (available in Glyphs environment) 633 | from Foundation import NSPoint 634 | return NSPoint(new_x, new_y) 635 | except ImportError: 636 | # Fallback: create a simple point object or use tuple 637 | class Point: 638 | def __init__(self, x, y): 639 | self.x = x 640 | self.y = y 641 | return Point(new_x, new_y) 642 | 643 | # Run the script 644 | if __name__ == "__main__": 645 | # Check if we have a font open and anchor selected first 646 | font = Glyphs.font 647 | if not font: 648 | print("No font open") 649 | elif not font.selectedLayers: 650 | print("No glyph selected") 651 | else: 652 | current_layer = font.selectedLayers[0] 653 | selection = current_layer.selection 654 | selected_anchor = None 655 | 656 | for item in selection: 657 | if item.__class__.__name__ == "GSAnchor": 658 | selected_anchor = item 659 | break 660 | 661 | if not selected_anchor: 662 | print("No anchor selected") 663 | else: 664 | # Show the dialog 665 | dialog = MirrorAnchorDialog() 666 | dialog.show() --------------------------------------------------------------------------------