Scripting Glyphs, part 4

Tutorial
by Rafał Buchner & Rainer Scheichelbauer
en zh

27 February 2023 Published on 24 May 2017

Functions are an efficient way to expand a script, yet keep your oversight and sanity. In this installment, we will use them for some font evil, har har.

This tutorial is part 4 of a series of tutorials and therefore assumes that you have read part 1, part 2 and part 3. This is what we have so far:

#MenuTitle: Glyph Shaker
# -*- coding: utf-8 -*-
__doc__="""
Goes through all selected glyphs and slaps each of their nodes around a bit.
"""

import random
random.seed()

selectedLayers = Glyphs.font.selectedLayers

for thisLayer in selectedLayers:
    for thisPath in thisLayer.paths:
        for thisNode in thisPath.nodes:
            thisNode.x += random.randint( -50, 50 )

So far, so good. I think it is pretty easy to read through it and understand what the code does. It is not that complicated, after all. Still, I want to point you to a few aspects for keeping the oversight in your scripts. First, look at this line:

selectedLayers = Glyphs.font.selectedLayers

This is equivalent to:

currentFont = Glyphs.font
selectedLayers = currentFont.selectedLayers

The first version keeps everything compactly on one line, the second version splits the content into two lines. Performance-wise, there is not really any difference between the two. So either way is fine. However, there are a few arguments for the second solution:

  • Firstly, you may want to reuse currentFont several times at a later stage.
  • Secondly, debugging is easier. If there is a typo and Glyphs reports an error, it is easier to spot the mistake in short lines. Remember that error messages always report the line number. In our example, the problem could lie either in accessing the current font or in accessing its selected layers. In the first solution, you would not know which, and would have to do some searching on your own. In the second solution, the reported line number directly points you to the problematic part, and you know right away what is going on.
  • Thirdly, the second solution keeps your code more legible. This will become very important when you go hunting for a bug.

Yes. Keep your code legible. You will deal with it at a later time. Or worse, you will have to ask someone else to look at your code, and then it is a very, very good idea to keep your code legible. For better code legibility, you will find many tips throughout the internets (Google is your friend), but for our purposes, always remember these things:

  • Keep your variable names clear and legible. Use speaking names like currentFont rather than f or currF.
  • As explained above, reserve one line per action. Do not squeeze three steps into one line.
  • Move distinguishable sets of actions into functions. Avoid creating long sequences of steps, a.k.a. ‘spaghetti code’.

Functions

What? Make my own functions? We talked a little bit about Python’s built-in functions in part 2 of this tutorial, but we have not made our own functions yet. Admittedly, for our script, it was not really necessary yet.

But consider the possibility that we want to expand the script because we want to do more than just slapping the nodes horizontally. For instance, we could also rotate the glyph a little, or randomly move it up and down, or skew it, or randomly add or subtract a few extra paths. Consider our loop:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer)
    rotateLayerRandomly(thisLayer, 5)
    randomVerticalMove(thisLayer, 100)

Looks clean and clear, right? Not really sure what the numbers in the parentheses are about, but otherwise we’re cool: we loop through each thisLayer in selectedLayers, then we slap nodes around randomly, then we rotate the layer randomly by up to 5 degrees, and finally we randomly move the whole glyph layer up or down by up to 100 units. We have not written any code for this yet, but we can already see what we are doing, simply from looking at the function names: slapNodesRandomly(), rotateLayerRandomly() and randomVerticalMove().

So, how do we make a new function? Easy, with the def statement! A function has to be defined before it is called for the first time. So, right before the loop, we insert our function definition:

def slapNodesRandomly(thisLayer):
    for thisPath in thisLayer.paths:
        for thisNode in thisPath.nodes:
            thisNode.x += random.randint( -50, 50 )

The word after def, is the function name, in our case slapNodesRandomly. It must be followed by parentheses () which can contain variable names, so-called arguments, in our case thisLayer. The parentheses are very important, because they differentiate variable names from function names. In other words, when we refer to this function, we would not just call it slapNodesRandomly, but more specifically: slapNodesRandomly(). So everyone knows it is a function we are talking about and nothing else.

In our case, the function contains one argument, thisLayer, which refers to the value that will be passed to it. The variable is local, i.e., it only is valid and accessible inside the function. That means that the word thisLayer has a different meaning inside the function (local function variable) than outside the function (global script variable). To make this distinction more clear, some coders like to apply different variable naming conventions inside functions, e.g., myLayer instead of thisLayer.

Now, all we have to do in our loop through selectedLayers is simply call the function:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer)

This calls the function, and between parentheses, it passes an argument to the function, the global variable thisLayer. And the slapNodesRandomly() function does the same thing we used to have spelled out in the loop. But now, we are ready to expand our script with more functionality, and keep the oversight.

Before we do this, let’s recap. This is our current status quo:

#MenuTitle: Glyph Shaker
# -*- coding: utf-8 -*-
__doc__="""
Goes through all selected glyphs and slaps each of their nodes around a bit.
"""

import random
random.seed()

selectedLayers = Glyphs.font.selectedLayers

def slapNodesRandomly(thisLayer):
    for thisPath in thisLayer.paths:
        for thisNode in thisPath.nodes:
            thisNode.x += random.randint( -50, 50 )

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer)

Abstraction

In the world of coding, abstraction means that you take a function and make it more configurable, i.e., fit for more purposes and applications. When we take a look at the current code, slapNodesRandomly() only changes x coordinates of all nodes by up to 50 units. We could make the function more abstract by also offering changing of y coordinates, and allowing other maximum values besides 50. Let’s do just that: First we add extra arguments into the parentheses, and while we’re at it, we also supply default values:

def slapNodesRandomly(thisLayer, maxX=50, maxY=0):

Two things to remember about this: arguments must be comma-separated, and arguments with a default value (a.k.a. ‘keyword arguments’) come after normal arguments. The default values are supplied after an equals sign.

You can still call slapNodesRandomly() the way we are used to. But you can also supply new values and override the defaults. Here are all our options when it comes to calling our function:

  • slapNodesRandomly(thisLayer), because you do not need to supply keyword arguments since they can fall back onto their default values: maxX is 50, and maxY is zero.
  • slapNodesRandomly(thisLayer, 100) which means that maxX is 100, but maxY still takes its value from the default zero.
  • slapNodesRandomly(thisLayer, 90, 80) calls the function with 90 for maxX and 80 for maxY.
  • slapNodesRandomly(thisLayer, maxY=70) skips maxX so it gets its default of 50, but sets maxY to 70. Keyword arguments can either be unnamed and in the predefined order (as in the two examples above), or named and in any order, as long as they come after the required normal arguments, in this case after thisLayer.

Of course, we still have to change a few lines to make use of the abstraction. First the line that moves the nodes randomly has to be expanded into two lines, and instead of fixed values, we insert our keyword arguments:

            thisNode.x += random.randint( -maxX, maxX )
            thisNode.y += random.randint( -maxY, maxY )

So, the complete function looks like this:

def slapNodesRandomly(thisLayer, maxX=50, maxY=0):
    for thisPath in thisLayer.paths:
        for thisNode in thisPath.nodes:
            thisNode.x += random.randint( -maxX, maxX )
            thisNode.y += random.randint( -maxY, maxY )

Congratulations, you have successfully abstracted our function. Now we can change the line where we call it:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer, maxX=70, maxY=70)

Now open a font you don’t like, select all its glyphs, and let the script do its magic. Har har.

Affine transformations: shift

Now that we cleaned up the loop, it should be easy to add new functions. Let’s start with one we mentioned already, randomVerticalMove(). It should randomly move the whole glyph layer up or down. So we know we need two arguments: the layer and the maximum shifting distance. This could be the first line of our function, then:

def randomVerticalMove(thisLayer, maxShift=100):

Next thing, we need to get a random number between negative maxShift and positive maxShift:

    shift = random.randint( -maxShift, maxShift )

And now we need to apply that shift to the whole layer, including nodes, anchors and components. If you look closely on docu.glyphsapp.com, at the methods a GSLayer has, you will find a function called GSLayer.applyTransform() and it takes the six numbers of an affine transformation matrix as an argument. In case your highschool math is just too long ago, this is the meaning of the 6 numbers:

  1. Horizontal scale from the origin: 1.0 (means no change)
  2. Horizontal shear from the origin: 0.0
  3. Vertical shear from the origin: 0.0
  4. Vertical scale from the origin: 1.0
  5. Horizontal shift: 0.0
  6. Vertical shift: 0.0

In other words, a matrix that does nothing looks like [1, 0, 0, 1, 0, 0] and if we want to shift the layer vertically, we need to change the last number. In our case we would just have to insert our variable shift, i.e., [1, 0, 0, 1, 0, shift] is our matrix. That’s it. So the rest of our function looks like this:

    shiftMatrix = [1, 0, 0, 1, 0, shift]
    thisLayer.applyTransform( shiftMatrix )

OK, recap. Our function looks like this:

def randomVerticalMove(thisLayer, maxShift=100):
    shift = random.randint( -maxShift, maxShift )
    shiftMatrix = [1, 0, 0, 1, 0, shift]
    thisLayer.applyTransform( shiftMatrix )

And our loop is now expanded to this:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer, maxX=70, maxY=70)
    randomVerticalMove(thisLayer, maxShift=200)

In the last line, we call the randomVerticalMove() function, and pass two arguments: the layer we are just looping through, and the maximum shift we allow. To make it more fun, I chose 200 instead of the default 100.

Affine transformations: rotate

How about rotating? Sounds cool, but there is no rotation in the matrix, right? No problem if you remember your last year of math at school: rotation can be achieved through a combination of shearing horizontally and vertically plus some compensatory scaling. If you like, you can dive back into trigonometrical calculations, but I will cut the story short and give you the matrix: For rotating around the origin point by an angle a, you need the matrix: cos(a), −sin(a), sin(a), cos(a), 0, 0.

So, all is set now, right? All we need to do is calculate the angle, fill it into that matrix, and apply the affine transformation, done. Well… not quite. Remember all transformations are happening around the origin point. If we want to rotate around a better pivot point, like the center of the layer bounds, we need to shift the whole layer content onto the origin point, then rotate, and finally shift everything back to its original position. Makes sense? OK, here we go:

def rotateLayerRandomly(thisLayer, maxAngle=5):

First thing we need to do is find the center of the layer and move it onto the origin point. Luckily, the layer has an attribute called bounds, and bounds has two attributes, origin and size. Digging deeper, we find that origin has x and y as attributes, while size has both width and height. To calculate the center, we need to start at the origin point, and add half the width and half the height. This is what the following two lines do:

    xCenter = thisLayer.bounds.origin.x + thisLayer.bounds.size.width * 0.5
    yCenter = thisLayer.bounds.origin.y + thisLayer.bounds.size.height * 0.5
    shiftMatrix = [1, 0, 0, 1, -xCenter, -yCenter]
    thisLayer.applyTransform( shiftMatrix )

And the two lines after that construct the transformation matrix for shifting the layer content on top of the origin point.

Now it is time to rotate. In other words, we need to first calculate a random angle between the negative and positive maximum, then construct and apply another transformation matrix with the sine and cosine of the angle. The random number part is easy:

    angle = random.randint( -maxAngle, maxAngle )

Now, how do we get the cosine and sine for that angle? In Python, there is a module for anything beyond the most basic mathematics, and it is called math. We can import it like we imported random before, and then we can access its functions. First, we need to expand the import line to also include the math module:

import random, math

Let’s see what math has in terms of trigonometric functions. In a new Python window in TextEdit or SublimeText, or the Glyphs Macro Window, type this and run it:

import math
help(math)

And you will see a lot of output, amongst which the definitions of two functions called sin() and cos(). To narrow down the help output, run this:

import math
help(math.sin)
help(math.cos)

And you should receive this output:

Help on built-in function sin in module math:

sin(...)
    sin(x)

    Return the sine of x (measured in radians).

Help on built-in function cos in module math:

cos(...)
    cos(x)

    Return the cosine of x (measured in radians).

OK, so that means that we can run math.cos(x) and math.sin(x), provided x is measured in radians. Wait a minute, our angle is measured in degrees, so we need to convert it to radians first. Luckily, there is also a radians() function in the math module. To find out more about it, we can run help(math.radians):

Help on built-in function radians in module math:

radians(...)
    radians(x)

    Convert angle x from degrees to radians.

Phew. So, let’s continue in our rotateLayerRandomly() function. First we need to make the radians conversion, then build and apply the matrix:

    angleRadians = math.radians( angle )
    rotationMatrix = [math.cos(angleRadians), -math.sin(angleRadians), math.sin(angleRadians), math.cos(angleRadians), 0, 0]
    thisLayer.applyTransform( rotationMatrix )

Did we forget anything? Of course! We need to move the whole thing back from the origin to its original position! In other words, the reverse of what we did before:

    shiftMatrix = [1, 0, 0, 1, xCenter, yCenter]
    thisLayer.applyTransform( shiftMatrix )

But I think that’s it. Let’s recap. Here is our function:

def rotateLayerRandomly(thisLayer, maxAngle=5):
    # move on top of origin point:
    xCenter = thisLayer.bounds.origin.x + thisLayer.bounds.size.width * 0.5
    yCenter = thisLayer.bounds.origin.y + thisLayer.bounds.size.height * 0.5
    shiftMatrix = [1, 0, 0, 1, -xCenter, -yCenter]
    thisLayer.applyTransform( shiftMatrix )

    # rotate around origin:
    angle = random.randint( -maxAngle, maxAngle )
    angleRadians = math.radians( angle )
    rotationMatrix = [ math.cos(angleRadians), -math.sin(angleRadians), math.sin(angleRadians), math.cos(angleRadians), 0, 0 ]
    thisLayer.applyTransform( rotationMatrix )

    # move back:
    shiftMatrix = [1, 0, 0, 1, xCenter, yCenter]
    thisLayer.applyTransform( shiftMatrix )

I added comments for clarity. And our loop now looks like this:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer, maxX=70, maxY=70)
    randomVerticalMove(thisLayer, maxShift=200)
    rotateLayerRandomly(thisLayer, maxAngle=15)

And, our last recap for now, the whole script looks like this now:

#MenuTitle: Glyph Shaker
# -*- coding: utf-8 -*-
__doc__="""
Goes through all selected glyphs, slaps their nodes around, rotates them a little, and shifts them up or down a little.
"""

import random, math
random.seed()

def slapNodesRandomly(thisLayer, maxX=50, maxY=0):
    for thisPath in thisLayer.paths:
        for thisNode in thisPath.nodes:
            thisNode.x += random.randint( -maxX, maxX )
            thisNode.y += random.randint( -maxY, maxY )

def randomVerticalMove(thisLayer, maxShift=100):
    shift = random.randint( -maxShift, maxShift )
    shiftMatrix = [1, 0, 0, 1, 0, shift]
    thisLayer.applyTransform( shiftMatrix )

def rotateLayerRandomly(thisLayer, maxAngle=5):
    # move on top of origin point:
    xCenter = thisLayer.bounds.origin.x + thisLayer.bounds.size.width * 0.5
    yCenter = thisLayer.bounds.origin.y + thisLayer.bounds.size.height * 0.5
    shiftMatrix = [1, 0, 0, 1, -xCenter, -yCenter]
    thisLayer.applyTransform( shiftMatrix )

    # rotate around origin:
    angle = random.randint( -maxAngle, maxAngle )
    angleRadians = math.radians( angle )
    rotationMatrix = [ math.cos(angleRadians), -math.sin(angleRadians), math.sin(angleRadians), math.cos(angleRadians), 0, 0 ]
    thisLayer.applyTransform( rotationMatrix )

    # move back:
    shiftMatrix = [1, 0, 0, 1, xCenter, yCenter]
    thisLayer.applyTransform( shiftMatrix )

# loop through all selected layers:
selectedLayers = Glyphs.font.selectedLayers
for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer, maxX=70, maxY=70)
    randomVerticalMove(thisLayer, maxShift=200)
    rotateLayerRandomly(thisLayer, maxAngle=15)

OK, I admit that I cleaned up some more: I updated the doc string, I moved the selectedLayers assignment to right in front of the loop, and I added some comments here and there.

And you see I have been a little inconsistent when it comes to extra spaces I leave between parentheses or brackets and their respective content. It doesn’t really matter, it is a matter of coding style, and I sometimes leave extra spaces for increased legibility. But I have been accused of sloppiness because of this, so you may want to be more consistent about it than I am.

And let’s see what it does to a font: Save the script, open a font you never really liked that much, select a few glyphs, and run the Glyph Shaker script again from the Script menu. This is what happens:

Ha, cool. Experiment with the values you pass to the functions and give those fonts hell. Have no mercy.


Update 2017-06-19: fixed sin() and cos() calls in two samples (thx Jeff Kellem).
Update 2018-12-26: corrected number values in the explanation of slapNodesRandomly(): maxX has a default of 50, not 100.
Update 2022-18-08: updated title, related articles minor formatting.
Update 2023-02-27: minor formatting, added UTF8 tip.