前端 · 深入理解 SVG – d 属性的作用原理

对于经常跟平面矢量图形打交道的开发者来说,如何便捷描述任意形状的图形,就成为不得不面对的一个问题。

其中一个方式,就是使用 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"
复制代码

image.png

这两个,就是等价的。

当然,逗号“,”也可以用空格隔开,不影响解析。

实际开发中,我们面对的第一需求,往往是从属性字符串中逆向解出每个锚点在画布上的具体定位坐标,所以接下来从这里出发,一一描述。

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"
复制代码

image.png

这里为了方便表述,在图中标记出了起点位置,事实上这里没有任何显示,点的外观也不存在,全屏纯空白

如果需要显示什么,则需要接后续的指令。

② 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"
复制代码

image.png

③ 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"
复制代码

image.png

④ 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"
复制代码

image.png

⑤ 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"
复制代码

image.png

⑥ 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

image.png

从左到右,为 C + S,绿色点为两段路径的衔接点。

可以看到右侧黑色显示表示的控制点为左侧 C 的对称点,且曲线在中间衔接点上平滑过渡。

如果用 C 来显示替换 S,可参考下图 4 所示。

图 2:S + S

image.png

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

image.png

这里列出的就是反例,可以看到衔接点处虽然是连续,但前后两个路径的曲率不同,此处不可导,故不平滑。

那么 S 的隐式控制点也就默认为了起始端点坐标。

图 4:L + C

image.png

如果还是想要右侧曲线表示为最开始的样子,那么这里只能用 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"
复制代码

image.png

需要注意的是,二次和三次贝塞尔曲线并不仅仅在控制点数量上有区别,下例可以看出对比:

d = "M 10,90 Q 50,10 90,90"
d = "M 10,90 C 50,10 50,10 90,90"    
复制代码

image.png

上面的橙色曲线为 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"
复制代码

image.png

⑨ 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"
复制代码

image.png
image.png

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"
复制代码

image.png
image.png

· 参数: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"/>
复制代码

image.png

如上图所示:
(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"/>
复制代码

image.png

只需要关注最末尾是否出现指令字符 “Z/z” 即可。

绿色为添加闭合指令后的封闭图形,白色为开放曲线。

3. 写在最后

到这里,SVG – d 属性的内容就全部结束了。

这是十分庞杂的一个属性,开发者通常拿到设计师交接的设计文件时,会看到异常密集的庞大的 d 字符串,特别是有照片被描摹后,更是吓人。

比如这种,动辄上万行的 path 集合,很多人看久了还是会感到头皮发麻。

image.png

当深入理解了每一个字符代表的含义后,这大片的天书一样的字符便没有那么让人头疼了。

可以说,d 属性的存在,让 SVG 这种矢量图形格式有了灵魂,也是绝大多数矢量制图最精髓的存在了。

基本上绝大多数生活中接触到的平面图形,都可以用 d 来描述,事实上,人们也确实这么做了。

掌握了这个属性,才能说真正意义上的对矢量图形学有了认识。

祝大家看地开心。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享