对于经常跟平面矢量图形打交道的开发者来说,如何便捷描述任意形状的图形,就成为不得不面对的一个问题。
其中一个方式,就是使用 SVG 格式定义下的 d 属性。
这也是目前比较通用的一种解决方案,你可以在 Adobe illustrator 等矢量工具中看到它的普遍应用
是的,这个属性就一个字母,小写英文字母:d。
d 是英文单词“draw”的首字母,顾名思义,描述绘画的路径。
可以理解为人类绘图的笔画。
本质上来说,d 属性是一个字符串,由一系列规则格式组成。
规则被称为【指令】(或者【命令】)。
既然是描述绘图的笔画,那么它内生了一个属性,就是【顺序性】。
这意味着,这个字符串中,每个规则和点位,都是有先后顺序的。
正是由这些显式的定位和规则,和隐式的先后顺序,共同决定了独一无二的图形路径。
1. d 属性的构成
第一次面对 d 属性时,可能会被一长串不明所以的“乱码”搞晕,但其实,它的组成方式很简单: [指令 (参数)]+
每一个数字都是前序最近指令的参数,若干个完整的指令方程,组成指令集,也就是 d 本身的内容了。
指令本身也不是任意且无限个。
无视字母大小写分类,一共有 4 大类,10 种,共 20 项。
4 类分别为:
①【移动】x1
②【直线】x3
③【曲线】x5
-【三次贝塞尔曲线】x2
-【二次方贝塞尔曲线】x2
-【椭圆弧曲线】x1
④【路径闭合】x1
总共用 10 种英文字母表示。
这10种分别为:
① M/m:move to 移动
② L/l:line to 画直线
③ V/v:vertical line to 画垂直线
④ H/h:horizontal line to 画水平线
⑤ C/c:cubic Bézier curve to 画立方贝塞尔曲线
⑥ S/s:smooth cubic Bézier curve to 画平滑的立方贝塞尔曲线
⑦ Q/q:quadratic Bézier curve to 画二次方贝塞尔曲线
⑧ T/t:smooth quadratic Bézier curve to 画平滑的立方贝塞尔曲线
⑨ A/a:arc curve to 画圆弧曲线
⑩ Z/z:ClosePath 闭合图形
每一种有两项,大写字母表示绝对定位,小写字母表示相对定位(也就是偏移量)。
为方便理解,减少颅内运算成本,本文示例优先选择大写绝对定位。
所以总共有 20 项指令,每个指令有各自的参数列表。
同时,一项指令下的参数列表支持复数对参数,也就是说,如果相邻两个及以上的指令相同,可以简写为一个字母,参数列表根据指令数扩增就行。
下文详述中,使用正则表达式中的 “+” 字符,来表示匹配前面一个的表达式 1 次或者多次。
比如:
d = "M 10,10 L 10,50 20,20 30,30"
d = "M 10,10 L 10,50 L 20,20 L 30 30"
复制代码
这两个,就是等价的。
当然,逗号“,”也可以用空格隔开,不影响解析。
实际开发中,我们面对的第一需求,往往是从属性字符串中逆向解出每个锚点在画布上的具体定位坐标,所以接下来从这里出发,一一描述。
2. d 属性指令集
① M/m:move to
指令 | 参数 | 公式 |
---|---|---|
M | (x, y)+ | P = {x, y} |
m | (dx, dy)+ | P = {x_old + dx, y_old + dy} |
# M x,y
# P={x, y}
def __move_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
return point
# m dx,dy
# P={x_old + dx, y_old + dy}
def __move_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
return point
复制代码
通常情况下,M 是以 path 开端定位的方式存在的。
所以往往出现在任意连续路径的第一个字符中。
所以可以简单理解成,M 的作用,就是把一个点,移动到一个具体定位上,移动路径不做任何显式的绘制。
d = "M 50,50"
复制代码
这里为了方便表述,在图中标记出了起点位置,事实上这里没有任何显示,点的外观也不存在,全屏纯空白。
如果需要显示什么,则需要接后续的指令。
② L/l:line to
指令 | 参数 | 公式 |
---|---|---|
L | (x, y)+ | P = {x, y} |
l | (dx, dy)+ | P = {x_old + dx, y_old + dy} |
# L x,y
# P={x, y}
def __line_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
return point
# l dx,dy
# P={x_old + dx, y_old + dy}
def __line_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
return point
复制代码
line 指令和 move 同理,唯一的区别,就在于 line 将移动路径以直线线段的方式绘制出来了。
d = "M 25,50 L 75,50"
复制代码
③ V/v:vertical line to
指令 | 参数 | 公式 |
---|---|---|
V | (y)+ | P = {x_old, y} |
v | (dy)+ | P = {x_old, y_old + dy} |
# V y
# P={x_old, y}
def __vertical_to(*args) -> dict:
point = {'x': args['x_old'], 'y': args['y']}
return point
# v dy
# P={x_old, y_old + dy}
def __vertical_to_d(*args) -> dict:
point = {'x': args['x_old'], 'y': args['y_old'] + args['dy']}
return point
复制代码
顾名思义,与 line 同理,不过是绘制垂直直线段。
d = "M 50,25 V 75"
复制代码
④ H/h:horizontal line to
指令 | 参数 | 公式 |
---|---|---|
H | (x)+ | P = {x, y_old} |
h | (dx)+ | P = {x_old + dx, y_old} |
# H x
# P={x, y_old}
def __horizontal_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y_old']}
return point
# h dx
# P={x_old + dx, y_old}
def __horizontal_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old']}
return point
复制代码
与 V/v 一致,区别在于这里绘制的是水平线。
d = "M 25,50 H 75"
复制代码
⑤ C/c:cubic Bézier curve to
指令 | 参数 | 公式 |
---|---|---|
C | (x1, y1, x2, y2, x, y)+ | P = {x, y} P_cs = {x1, y1} P_ce = {x2, y2} |
c | (dx1, dy1, dx2, dy2, dx, dy)+ | P = {x_old + dx, y_old + dy} P_cs = {x1_old + dx1, y1_old + dy1 } P_ce = {x2_old + dx2, y2_old + dy2} |
# C x1,y1,x2,y2,x,y
# P={x, y} P_cs={x1, y1} P_ce={x2, y2}
def __cubic_curve_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
# point_cs = {'x1': args['x1'], 'y1': args['y1']}
# point_ce = {'x2': args['x2'], 'y2': args['y2']}
return point
# c dx1,dy1,dx2,dy2,dx,dy
# P={x_old + dx, y_old + dy} P_cs={x1_old + dx1, y1_old + dy1} P_ce={x2_old + dx2, y2_old + dy2}
def __cubic_curve_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
# point_cs = {'x1': args['x1_old'] + args['dx1'], 'y1': args['y1_old'] + args['dy1']}
# point_ce = {'x2': args['x2_old'] + args['dx2'], 'y2': args['y2_old'] + args['dy2']}
return point
复制代码
这里绘制的是立方贝塞尔曲线,取自“cubic”首字母,也可以理解为三次方贝塞尔曲线。
关于贝塞尔曲线的介绍,可以参考上一篇文章:
图形学 · 简谈 Bézier curve
三次贝塞尔曲线有 4 个控制点(本文从下标 1 开始,以方便理解参数表):
p1:默认为当前点坐标
p2:(x1, y1)
p3:(x2, y2
p4:(x, y)
参数列表依次为 p2, p3, p4。
比如:
d = "M 10,10 C 10,90 90,90 90,10"
复制代码
⑥ S/s:smooth cubic Bézier curve to
指令 | 参数 | 公式 |
---|---|---|
S | (x2, y2, x, y)+ | P = {x, y} P_ce = {x2, y2} |
s | (dx2, dy2, dx, dy)+ | P = {x_old + dx, y_old + dy} P_ce = {x2_old + dx2, y2_old + dy2} |
# S x2,y2,x,y
# P={x, y} P_ce={x2, y2}
def __short_cc_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
# point_ce = {'x2': args['x2'], 'y2': args['y2']}
return point
# s dx2,dy2,dx,dy
# P={x_old + dx, y_old + dy} P_ce={x2_old + dx2, y2_old + dy2}
def __short_cc_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
# point_ce = {'x2': args['x2_old'] + args['dx2'], 'y2': args['y2_old'] + args['dy2']}
return point
复制代码
S/s 是 C/c 的变体,指令比较特殊,取自“smooth”的首字母,顾名思义,表示平滑。
平滑的数学定义,可以简单理解为“连续、可导”。
显然,贝塞尔曲线唯二出现不平滑点的位置就是两个端点了。
结尾点如果为路径端点,则必为断点,那么“smooth”的作用,就被限定在了起始端点。
同时,根据 SVG 的写法风格,一段路径的起始坐标不在该段路径的命令参数列表中,所以留给贝塞尔曲线平滑过渡的参数,就只剩下了起始端点的控制点 P_control_start,也就是 P_cs。
“Smooth” 是如何实现的呢:
P_cs 由该段路径(或者当前指令)的紧邻前一个(前序)指令决定。
★★ 展开来说:
★ 如果,前一段路径,或者前序指令,也是立方贝塞尔曲线,那么 P_cs 是前序指令 P_ce 的一个“reflection”。可以理解为中心对称/镜像/反射。
★ 如果不是,则 P_cs 为该路径的起始端点坐标。
简单来讲,这个隐式的 P_cs,在前序指令为 C/c 或者 S/s(S/s 这里显然的,因为参数列表为复数对,SVG 支持这种语法格式,上文已描述过)时,P_cs 由前序 P_ce 推出,计算参数为前序 P_ce 和 结尾端点 P。
若不是上述指令,则默认为与起始点坐标一致/重合/相同。
如果显示表示平滑三次贝塞尔曲线,那么 4 个控制点分别为:
p1:默认为当前点坐标
p2:(pre_p3)’ 或 pre_p4
p3:(x2, y2)
p4:(x, y)
隐式则只有 p3 和 p4,也正是 S 的参数列表。
当然,这种简写是合理的,节省将近一半的参数列表,字符个数也将缩减一半,对于生产环境和生活应用来说,能够节省大量重复内容,降低带宽占用,减少冗余,提高性能。
除了人类理解稍微有点麻烦,对于机器来说,各种角度基本都是利好的。
以下面四个路径为例:
d = "M 10,90 C 30,90 30,10 50,10 S 70,90 90,90"
d = "M 10,90 S 30,10 50,10 S 70,90 90,90"
d = "M 10,90 L 50,10 S 70,90 90,90"
d = "M 10,90 L 50,10 C 70,10 70,90 90,90"
复制代码
下面四张图可交叉验证上述规则,核心区域为宽 100 正方形,左边深色区域为前序路径,右边浅色区域为观察对象。
图 1:C + S
从左到右,为 C + S,绿色点为两段路径的衔接点。
可以看到右侧黑色显示表示的控制点为左侧 C 的对称点,且曲线在中间衔接点上平滑过渡。
如果用 C 来显示替换 S,可参考下图 4 所示。
图 2:S + S
d="M 10,90 S 30,10 50,10 S 70,90 90,90"
d="M 10,90 S 30,10 50,10 70,90 90,90"
复制代码
这俩是等价的。
由于可以被简写为一个指令,所以两个路径唯一的端点就在最开始和最末尾。
同时,S 也是 C 的一种,所以规则同 C。
图 3:L + S
这里列出的就是反例,可以看到衔接点处虽然是连续,但前后两个路径的曲率不同,此处不可导,故不平滑。
那么 S 的隐式控制点也就默认为了起始端点坐标。
图 4:L + C
如果还是想要右侧曲线表示为最开始的样子,那么这里只能用 C 来描述,需要显式写出控制点坐标,S 的作用自然也发挥不了了。
⑦ Q/q:quadratic Bézier curve to
指令 | 参数 | 公式 |
---|---|---|
Q | (x1, y1, x, y)+ | P = {x, y}P_c = {x1, y1} |
q | (dx1, dy1, dx, dy)+ | P = {x_old + dx, y_old + dy} P_c = {x1_old + dx1, y1_old + dy1} |
# Q x1,y1,x,y
# P={x, y} P_c={x1, y1}
def __quadratic_curve_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
# point_cs = {'x1': args['x1'], 'y1': args['y1']}
return point
# q dx1,dy1,dx,dy
# P={x_old + dx, y_old + dy} P_c={x1_old + dx1, y1_old + dy1}
def __quadratic_curve_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
# point_ce = {'x1': args['x1_old'] + args['dx1'], 'y1': args['y1_old'] + args['dy1']}
return point
复制代码
Q 是“quadratic Bézier curve”的首字母,同 C 类似,只不过这里是二次方贝塞尔曲线。
也有称为二阶/平方贝塞尔曲线。
二次贝塞尔曲线一共有 3 个控制点:
p1:默认为当前点坐标
p2:(x1, y1)
p3:(x, y)
比如:
d = "M 10,90 Q 50,10 90,90"
复制代码
需要注意的是,二次和三次贝塞尔曲线并不仅仅在控制点数量上有区别,下例可以看出对比:
d = "M 10,90 Q 50,10 90,90"
d = "M 10,90 C 50,10 50,10 90,90"
复制代码
上面的橙色曲线为 C 指令下绘制的三次(立方)贝塞尔曲线。
虽然 C 中的两个点重合在了一起,且和 Q 一致,但可以看出来,三次的曲率跟大,曲率半径更小(也就是弯曲程度更大更尖锐)。
⑧ T/t:smooth quadratic Bézier curve to
指令 | 参数 | 公式 |
---|---|---|
T | (x, y)+ | P = {x, y} |
t | (dx, dy)+ | P = {x_old + dx, y_old + dy} |
# T x,y
# P={x, y}
def __t_qc_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
return point
# t dx,dy
# P={x_old + dx, y_old + dy}
def __t_qc_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
return point
复制代码
T/t 是 “smooth quadratic Bézier curve to”的简写,虽然不知道为什么选了 “T/t” 这个字母(可能是 S 顺延下来),但其和 S/s 的原理是相同的,这里不再赘述,只需把 S/s 中的所有三次贝塞尔曲线换成二次贝塞尔曲线即可,然后参数再顺势减去一个就好。
举个栗子:
d = "M 10,90 Q 25,10 50,50 T 90,10"
复制代码
⑨ A/a:arc curve to
指令 | 参数 | 公式 |
---|---|---|
A | (rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, x, y)+ |
P = {x, y} |
a | (rx, ry, x-axis-rotation, large-arc-flag, sweep-flag, dx, dy)+ |
P = {x_old + dx, y_old + dy} |
# A rx,ry,x-axis-rotation,large-arc-flag,sweep-flag,x,y
# P={x, y}
def __arcs_to(*args) -> dict:
point = {'x': args['x'], 'y': args['y']}
return point
# a rx,ry,x-axis-rotation,large-arc-flag,sweep-flag,dx,dy
# P={x_old + dx, y_old + dy}
def __arcs_to_d(*args) -> dict:
point = {'x': args['x_old'] + args['dx'], 'y': args['y_old'] + args['dy']}
return point
复制代码
A/a 是“Elliptical arc curves”的简写,取自“arc”首字母。
显然,诞生于插值算法的贝塞尔曲线无法绘制出任意曲线,所以 A/a 代表绘制的是椭圆弧曲线。
圆形,或者椭圆形,这也是现实生活中非常常用的一种曲线。
它的比较复杂,首先,想要得到一段圆弧曲线,那么首先要有一个圆。
所以 A/a 指令不可避免需要先行计算椭圆形。
· 参数:rx, ry
这俩代表的是椭圆形在笛卡尔坐标系下,沿 x, y 轴的最短半径大小(也就是半轴长度)。
rx, ry 代表的是最短距离,由于存在终点坐标参数(也就是最后的 x,y ),实际绘制中,A 指令会先根据 rx, ry 计算最小圆弧。
若终点参数不在此椭圆上,则绘制新的更大的椭圆,此时 rx,ry 被赋予新值,原始参数被覆盖。
比如:
d = "M 50,50 A 15,5 0 1,1 50.1,50"
d = "M 50,50 A 15,5 0 1,1 501,50"
复制代码
rx 为 15,ry为 5,这个好理解。
上图的区别在于终点的定位,可以看到参数列表中的 x 扩大了 10 倍。
此时椭圆为了匹配到那个点,半轴长度不可避免地变大了。
· 参数:x-axis-rotation
也有地方在这里写作 angle,不过意思是一样的。
表示图形在 x 轴方向上的旋转角度,单位是度(°)- degree。
比如下面两个举例:
d = "M 50,50 A 15,5 0 1,1 50.1,50"
d = "M 50,50 A 15,5 90 1,1 50.1,50"
复制代码
· 参数:large-arc-flag, sweep-flag
这俩是控制参数,各自值分别为 0 或者 1,故而一共有四种组合。
large-arc-flag(圆弧大小) |
||||
0 |
1 |
|||
sweep-flag(旋转方向) |
0 |
取小段圆弧,逆时针方向 |
取大段圆弧, |
|
1 |
取小段圆弧,顺时针方向 |
取大段圆弧,顺时针方向 |
举 4 个例子:
<path fill="none" stroke="red" d = "M 50,50 A 15,5 90 0,0 50,25" stroke-width="2"/>
<path fill="none" stroke="orange" d = "M 50,50 A 15,5 90 1,0 50,25" stroke-width="2"/>
<path fill="none" stroke="yellow" d = "M 50,50 A 15,5 90 0,1 50,25" stroke-width="2"/>
<path fill="none" stroke="green" d = "M 50,50 A 15,5 90 1,1 50,25" stroke-width="2"/>
复制代码
如上图所示:
(0, 0) 红色:短弧,逆时针
(1, 0) 橙色:长弧,逆时针
(0, 1) 黄色:短弧,顺时针
(1, 1) 绿色:长弧:顺时针
· 参数:x, y
这个就是终点坐标参数,很容易理解。
⑩ Z/z:ClosePath
指令 | 参数 | 公式 |
---|---|---|
Z, z |
def __close_path(*args):
return
复制代码
不是漏写了,而是 Z/z 的意思是曲线闭合。
如果在路径末尾添加此命令,则该曲线会以直线形式,直接首尾相连闭合为封闭图形。
若不写,则为开放曲线。
很好理解。
如下例所示:
<path fill="none" stroke="#ffffff" stroke-width="3" d = "M30,21.3c0,0-6.4,2.5-6.1,21.8c0.3,19.2-10.2,23.1,10,26.2S63.1,70,64.9,79.7c1.8,9.7,23.1,8.8,23.1,8.8s15.3-41.7,4.3-43.4C81.2,43.5,65.8,32,65.8,32"/>
<path fill="none" stroke="green" stroke-width="1" d = "M30,21.3c0,0-6.4,2.5-6.1,21.8c0.3,19.2-10.2,23.1,10,26.2S63.1,70,64.9,79.7c1.8,9.7,23.1,8.8,23.1,8.8s15.3-41.7,4.3-43.4C81.2,43.5,65.8,32,65.8,32z"/>
复制代码
只需要关注最末尾是否出现指令字符 “Z/z” 即可。
绿色为添加闭合指令后的封闭图形,白色为开放曲线。
3. 写在最后
到这里,SVG – d 属性的内容就全部结束了。
这是十分庞杂的一个属性,开发者通常拿到设计师交接的设计文件时,会看到异常密集的庞大的 d 字符串,特别是有照片被描摹后,更是吓人。
比如这种,动辄上万行的 path 集合,很多人看久了还是会感到头皮发麻。
当深入理解了每一个字符代表的含义后,这大片的天书一样的字符便没有那么让人头疼了。
可以说,d 属性的存在,让 SVG 这种矢量图形格式有了灵魂,也是绝大多数矢量制图最精髓的存在了。
基本上绝大多数生活中接触到的平面图形,都可以用 d 来描述,事实上,人们也确实这么做了。
掌握了这个属性,才能说真正意义上的对矢量图形学有了认识。
祝大家看地开心。