为 Glyphs 编写脚本(四)
“函数” 是扩展脚本的一种有效方式,同时能够让你保持管理、精神正常。在这一部分中,我们会用它来做一点对字体而言很邪恶的事情,嘿嘿。
本教程是系列教程的第四部分,因此假定你已经读过了(一)、(二)和(三)。到目前为止,我们已经完成了:
#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
,不要使用f
或currF
。 - 如前所述,保持每行一个动作。不要将三步并作一行。
- 将可分的动作集放在函数中。避免创建冗长的动作序列,即所谓 “面条代码”(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.0(意为不变)
- 从原点开始的水平倾斜:0.0
- 从原点开始的垂直倾斜:0.0
- 从原点开始的垂直缩放:1.0
- 水平偏移:0.0
- 垂直偏移: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):
我们要做的第一件事是找到图层的中心并把它移到原点上。所幸,图层有一个属性叫做 bounds
,bounds
又有两个属性,origin
和 size
。再深一层,我们发现 origin
又有 x
和 y
两个属性,而 size
又有 width
和 height
。要想计算中心,我们需要从原点开始,加上宽度的一半和高度的一半。这就是以下两行代码所做的:
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 (三言).