为 Glyphs 编写脚本(四)

教程
作者:Rafał Buchner & Rainer Scheichelbauer
en zh

翻译:Rainer Erich Scheichelbauer

24 五月 2017

“函数” 是扩展脚本的一种有效方式,同时能够让你保持管理、精神正常。在这一部分中,我们会用它来做一点对字体而言很邪恶的事情,嘿嘿。

本教程是系列教程的第四部分,因此假定你已经读过了(一)(二)(三)。到目前为止,我们已经完成了:

#MenuTitle: 字符形振荡器
# -*- coding: utf-8 -*-
__doc__="""
遍历所有选定的字符形,将其中的每个节点移动一小段距离。
"""

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 )

到目前为止,一切顺利。我觉得通读所有内容、并理解代码的作用很容易,毕竟这也没有那么复杂。我还是想告诉你几方面内容,让你可以保持对代码的管理。首先,看一下这行:

selectedLayers = Glyphs.font.selectedLayers

这等同于:

currentFont = Glyphs.font
selectedLayers = currentFont.selectedLayers

前一个版本中,将所有内容集中在一行,后一版则将内容拆成了两行。效果而言,两者其实并无不同,所以采用哪种都可以。不过,对于第二种方案而言,有这样一些理由:

  • 首先,你可能会在后面的场合中复用 currentFont 几次。
  • 其次,排除 bug更简单。如果出现录入错误,Glyphs 报错时,在较短的行之间更容易找出错误所在。请记住报错信息会指出所在行数。本例中,问题可能出在 “调用当前字体” 或 “调用所选图层” 两方面之一。在方案一中,你不知道到底是哪个,必须要自己检索一下;方案二中,报错的行数会直接为你指出有问题的部分,你立刻就能知晓发生了什么。
  • 第三,方案二会让你的代码更加清晰。当你要查找 bug 时,这一点会非常重要。

没错。保持你的代码清晰。你肯定会在将来某时需要处理这些事的。更糟的情况,你可能要请别人来帮忙检查代码,那么这样保持代码清晰就非常、非常重要了。为了让代码更清晰,你可以通过互联网找到很多小技巧(谷歌是你的好朋友),不过对于我们的目的而言,时刻记住这些:

  • 保持变量名称清晰可读。使用有意义的名称如 currentFont,不要使用 fcurrF
  • 如前所述,保持每行一个动作。不要将三步并作一行。
  • 将可分的动作集放在函数中。避免创建冗长的动作序列,即所谓 “面条代码”(spaghetti code)。

函数

什么?创建我自己的函数?我们在本系列教程的第二部分中了解了一点关于 Python 内建函数的内容,不过我们还没有创建过自己的函数。确实,对于我们这个脚本而言,还并不需要这个。

不过,考虑到我们可能会想要扩展这个脚本,让它可以不仅仅水平移动节点。比如,我们也可以稍微旋转一下字符形,或是随机上下移动,将其倾斜,或是随机添加或删去一些路径。想一下我们的循环:

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

看上去整洁清晰,是吧?不是很确定括号里的数字是什么意思,要不然我们就很酷了:我们遍历 selectedLayers 中的每个 thisLayer,然后将节点随机向周围移动,再将图层随机旋转最多 5 度,最后我们将整个字符形图层随机上下移动最多 100 单位。我们还没有为它编写任何代码,不过只要看到了这些函数的名称,就已经知道了我们在做什么:slapNodesRandomly()rotateLayerRandomly() 以及 randomVerticalMove()

所以,如何创建新的函数?简单,使用 def 声明!函数必须在其首次被调用之前被定义出来。所以,在循环之前,我们先插入函数的定义:

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

def 之后的单词就是函数名称,本例中为 slapNodesRandomly,它后面必须接一对圆括号 (),里面可以有变量名称(argument),本例中是 thisLayer。圆括号很重要,它们将变量名称同函数名称区分开。换句话说,当我们调用这个函数时,不能只叫它 slapNodesRandomly,而是更具体的:slapNodesRandomly()。这样,大家都知道我们所指的是一个函数,而不是别的东西。

本例中,函数包含一个变量,thisLayer,指代之后传递给它的值。变量是本地的,即只在函数内部有效。这意味着,thisLayer 在函数内部(函数本地变量)和外部(脚本全局变量)所具有的含义不同。为了更清楚地加以区分,有些程序员喜欢在函数内部应用不同的命名规则,比如用 myLayer 取代 thisLayer

现在,我们在 selectedLayers 间的循环中只需要调用这一函数:

for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer)

这就调用了这一函数,在圆括号中,它将一个变量传递给了函数:全局变量 thisLayer。而 slapNodesRandomly() 函数的所作所为和我们之前在循环中所写的内容一样。不过现在,我们就准备好了为我们的脚本扩展更多功能,并保持管理。

在此之前,我们先总结一下。这是现状:

#MenuTitle: 字符形振荡器
# -*- coding: utf-8 -*-
__doc__="""
遍历所有选定的字符形,将其中的每个节点移动一小段距离。
"""

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)

抽象函数

在代码世界里,“抽象” 意味着你将函数变得更加可配置,也就是说,适用于更多的功能和应用。我们看一下现在的代码,slapNodesRandomly() 仅将所有节点的 x 坐标更改最多 50 个单位。我们可以让函数更加抽象,让它也能够提供 y 坐标的改变,并允许 50 以外的最大值。我们这样做:首先我们在括号中添加额外的参数,同时我们也提供默认值

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

这里请记住两件事:变量必须由逗号分隔,并且带有默认值的变量(即“关键变量”)位于常规变量之后。在等号之后给出默认值。

你仍然可以像之前一样调用 slapNodesRandomly()。不过你还可以赋予新的值,并覆盖默认值。当我们需要调用函数时,有这些选择:

  • slapNodesRandomly(thisLayer),因为关键变量会采用默认值,因此这样你不需要为它们赋值:maxX 为 50、maxY 为零。
  • slapNodesRandomly(thisLayer, 100) 意味着 maxX 为 100,但 maxY 仍取默认值零。
  • slapNodesRandomly(thisLayer, 90, 80)maxX 值为 90、maxY 值为 80 调用函数。
  • slapNodesRandomly(thisLayer, maxY=70) 跳过 maxX 所以其采用默认值 50,但将 maxY 设置为 70。关键变量可以不写名称,使用预设顺序(如上两例),或写出名称,使用任意顺序,只要它们在常规变量之后(本例中即在 thisLayer 之后)。

当然了,我们还要修改几行,来发挥我们抽象函数的作用。首先,随机移动节点的这一行需要被扩展成两行,并且插入关键变量,而非定值:

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

所以,整个函数如下所示:

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 )

祝贺你,你已经成功地将我们的函数抽象化了。现在我们可以修改调用函数的这一行了:

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

现在打开一个你不喜欢的字体,全选字符形,然后让脚本来施展魔法。嘿嘿。

仿射变换:偏移

我们已经将循环整理过了,那么要添加新的函数就很简单。我们从之前提到过的一个函数开始,randomVerticalMove()。它应该随机向上或向下移动字符形的整个图层。所以我们需要添加两个变量:图层,以及最大偏移量。那么函数的第一行就可以是这样:

def randomVerticalMove(thisLayer, maxShift=100):

下一件事,我们需要在负的 maxShift 和正的 maxShift 之前取随机数:

    shift = random.randint( -maxShift, maxShift )

然后我们需要将偏移应用在整个图层上,包含节点、锚点和部件。如果你在 docu.glyphsapp.com 上细看 GSLayer 对象所带有的方法,你会找到名为 GSLayer.applyTransform() 的函数,它将仿射变换矩阵上的六个数值作为变量。如果你的高中数学已经太过久远,这里有这六个数值的含义:

  1. 从原点开始的水平缩放:1.0(意为不变)
  2. 从原点开始的水平倾斜:0.0
  3. 从原点开始的垂直倾斜:0.0
  4. 从原点开始的垂直缩放:1.0
  5. 水平偏移:0.0
  6. 垂直偏移:0.0

换句话说,像 [1, 0, 0, 1, 0, 0] 这样的矩阵结果就是 “不变形”。如果我们想让图层垂直偏移,我们需要改变最后一个数值。本例中,我们只需要在矩阵中插入变量 shift,即 [1, 0, 0, 1, 0, shift] 就行了。所以函数的其余部分如下所示:

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

好,总结。我们的函数长这样:

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

我们的循环现在扩展成了这样:

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

在最后一行中,我们调用 randomVerticalMove() 函数,然后传递两个变量:我们刚刚循环过的图层,以及我们允许的最大偏移量。为了更好玩,我选择了 200 的数值,而非默认的 100。

仿射变换:旋转

旋转怎么办?听上去很酷,但矩阵里并没有旋转啊,对吧?如果你还记得学校里最后一学年数学课的内容,就不会有问题:旋转可以通过水平倾斜和垂直倾斜的组合来实现。如果你想,你可以追溯到三角函数来计算,不过我长话短说,直接给你一个矩阵:要想绕原点旋转一个角度 a,你需要这样的矩阵:cos(a), −sin(a), sin(a), cos(a), 0, 0。

那么,全都设置好了,是吧?我们现在只需要计算角度,填入矩阵,然后应用仿射变换,就完成了。嗯……不完全是这样。还记得一切变换都是绕着原点进行的吧。如果我们想要绕着一个更合适的轴心旋转,比如图层边界框的中心,那么我们就需要将整个图层内容移动到原点,然后旋转,最后再移回原位。合理吧?好,那么来:

def rotateLayerRandomly(thisLayer, maxAngle=5):

我们要做的第一件事是找到图层的中心并把它移到原点上。所幸,图层有一个属性叫做 boundsbounds 又有两个属性,originsize。再深一层,我们发现 origin 又有 xy 两个属性,而 size 又有 widthheight。要想计算中心,我们需要从原点开始,加上宽度的一半和高度的一半。这就是以下两行代码所做的:

    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 )

之后的两行则构造变换矩阵,将图层内容偏移到原点上。

那么现在就该旋转了。换句话说,我们要先获得正负极值之间的随机角度,再用它的正弦和余弦构造另一个变换矩阵。随机数部分很简单:

    angle = random.randint( -maxAngle, maxAngle )

现在该怎样计算正弦和余弦呢?在 Python 中,有一个用于超出基本算术内容的模块,名为 math。我们可以像之前引入 random 一样引入它,然后就可以使用其函数了。首先,我们扩充 import 这行,加上 math 模块:

import random, math

我们来看看 math 中有哪些三角函数。在 TextEdit 或 SublimeText 中新建一个 Python 窗口,或在 Glyphs 的宏面板中,输入并运行:

import math
help(math)

你会看到很多输出内容,其中有两个名为 sin()cos() 的函数定义。运行这个内容来缩小帮助结果的范围:

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

你会收到这样的输出内容:

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).

好了,所以这意味着我们用以运行 math.cos(x)math.sin(x) 的值 x 以弧度计算。等一下,我们的角度是按度计算的,所以我们要先把角度换算成弧度。所幸 math 模块中还有一个 radians() 函数。要查看更多关于这一函数的内容,我们可以运行 help(math.radians)

Help on built-in function radians in module math:

radians(...)
    radians(x)

    Convert angle x from degrees to radians.

呼。那么我们继续 rotateLayerRandomly() 函数。首先我们来做弧度换算,然后建立并应用矩阵:

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

有没有忘掉什么事?当然了!我们还要把整个东西移回原位!换句话说,就是之前那步操作反过来:

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

我想就是这些了。来总结一下。这是我们的函数:

def rotateLayerRandomly(thisLayer, maxAngle=5):
    # 移到原点上:
    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 )

    # 绕原点旋转:
    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 )

    # 移回原位:
    shiftMatrix = [1, 0, 0, 1, xCenter, yCenter]
    thisLayer.applyTransform( shiftMatrix )

为了清楚,我加上了注释。现在我们的循环如下所示:

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

以及,我们的最后一次总结,完整代码现在是这样:

#MenuTitle: 字符形震荡器
# -*- coding: utf-8 -*-
__doc__="""
遍历所有选定的字符形,将其节点四处移动,旋转一点点,并上下移动一点点。
"""

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):
    # 移到原点上:
    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 )

    # 绕原点旋转:
    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 )

    # 移回原位:
    shiftMatrix = [1, 0, 0, 1, xCenter, yCenter]
    thisLayer.applyTransform( shiftMatrix )

# 在所有选定图层间循环:
selectedLayers = Glyphs.font.selectedLayers
for thisLayer in selectedLayers:
    slapNodesRandomly(thisLayer, maxX=70, maxY=70)
    randomVerticalMove(thisLayer, maxShift=200)
    rotateLayerRandomly(thisLayer, maxAngle=15)

好吧,我承认我又整理了一下:更新了档案字串,将 selectedLayers 的配置移到了循环之前,又在各处添加了一些注释。

你也会发现,对于圆括号和方括号与其中内容之间的多余空格,存在着前后不一致。这没有什么关系,只关乎写代码的风格,我有时会留出额外的空格来使其更加清晰可读。不过我也因此被指责粗心大意,所以你可能需要比我更加注重前后一致。

让我们看看它会对一款字体做些什么:保存脚本,打开一个你从没真正喜欢过的字体,选择几个字符形,然后再次从 “脚本” 菜单中运行 “字形震荡器” 脚本。情况会如此这般:

哈,很酷。对传递给函数的数值进行试验,然后将那些字体送下地狱。不用怜悯。


2017-06-19 更新:修正两个示例中的 sin() 和 cos() 调用(感谢 Jeff Kellem)。
2018-12-26 更新:更正 slapNodesRandomly() 说明中的数值:maxX 默认为 50 而非 100。

Chinese translation by Willie Liu (刘育黎) from 3type (三言).