修复 ManimGL 中的 SVGMobject

今天一天都在修 ManimGL 里的 SVGMobject,还是比较有收获的,写篇文章记录一下
起因是 fran 给了一个在 ManimGL 里表现怪异的 svg 文件:formula.svg

它在 ManimGL 下表现的是:

不难发现几个问题:

  • 整体上下翻转了
  • 左边多了一条粗线
  • 矩阵大括号中间断开了
  • 角标没有缩放,不在正确位置上

通过检查 svg 源码可以发现主要是两个问题,一个是直接从嵌套在内层的 svg 元素中提取出了物件,二是没有正确处理 svg 的 transform

只从最外层 svg 提取物件

这个问题产生的原因是在 SVGMobject.init_points 这个方法中,原来的代码是:

1
2
3
4
5
6
7
8
9
10
doc = minidom.parse(self.file_path)
self.ref_to_element = {}

for svg in doc.getElementsByTagName("svg"):
mobjects = self.get_mobjects_from(svg)
if self.unpack_groups:
self.add(*mobjects)
else:
self.add(*mobjects[0].submobjects)
doc.unlink()

很明显,这里的 for 循环提取了全部的 svg 标签,然后从中提取出 mobjects

但是这样的话如果有嵌套在 svg 内部的 svg 就也会从中提取 mobjects,但这时就没有了外层的约束,导致重复生成,而且生成的位置错误

在上面那个 svg 中就是这样,大括号分为三个部分,其中中间的一段是复用了一个元素,并且使用的是 svg 标签

解决方法也很直接,直接遍历 doc 的子节点 childNodes,并且判断其 tagName 是否是 “svg”,不是就跳过
但这样仍存在一个问题,doc 的子节点可能不是 Element,比如注释,就是 Comment,它并没有 tagName 属性,所以还需要先判断一下这个节点是不是 Element

最终的解决办法:

1
2
3
4
5
for child in doc.childNodes:
if not isinstance(child, minidom.Element): continue
if child.tagName != 'svg': continue
mobjects = self.get_mobjects_from(child)
...

正确处理 transforms

上面 svg 中,角标没有缩放、放到正确位置的原因是,这两个操作在这个 svg 中都使用了 transform 来达成,所以它的值是 “translate(…) scale(…)”,前者负责平移,后者负责缩放

但是 ManimGL 中原来的处理方法是直接使用 “matrix(…)” “translate(…)” “scale(…)” 进行匹配,将中间内容当作参数,如果中途任何环节出现报错,都直接忽略掉
但是这样它会将这个 transform 解析成参数为 “…) scale(…” 的一个 translate,这显然会在后面抛出异常

然后就尝试了使用空格分割
效果倒是出现了,但是还是有一点问题,角标距离元素的距离太近,对比发现,可能是因为缩放和平移的施加顺序不同导致的

svg 标准中也有说明,transform 应该从右向左依次施加,所以临时的修正写法是:

1
2
3
transforms = element.getAttribute('transform').split(" ")[::-1]
for transform in transforms:
...

但是这样也有问题,因为标准中对于 transform 串的规定很宽松,两个 transform 中间可以不加空格,也可以有任意多空格,名字和左括号中间也可以有空格……

然后参考了 ManimCE,发现了里面有一个链接,是一个 python 写的 svg 解析器:https://github.com/cjlano/svg

按照里面的写法,使用了正则表达式来匹配名称和参数,根据规范,svg 仅支持 css transform 中的 matrix translate scale rotate skewX skewY,但我顺手还加了 translateX/Y scaleX/Y
全部的正则和匹配方法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
transform_names = [
"matrix",
"translate", "translateX", "translateY",
"scale", "scaleX", "scaleY",
"rotate",
"skewX", "skewY"
]
transform_pattern = re.compile("|".join([x + r"[^)]*\)" for x in transform_names]))
number_pattern = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?")
transforms = transform_pattern.findall(element.getAttribute('transform'))[::-1]
for transform in transforms:
op_name, op_args = transform.split("(")
op_name = op_name.strip()
op_args = [float(x) for x in number_pattern.findall(op_args)]

其中第一个正则表达式很显然,匹配每一个名称,后面接任意多个非右括号的字符,然后是右括号
第二个字符比较复杂,可视化后还是比较清晰的:

然后根据标准依次解决就好了:

  • matrix 直接保留原来写好的就可以
  • translate 平移,translateX 时 y=0,translateX 时 x=0
  • scale 缩放,注意可能有负的情况,但是 ManimGL 已经禁止 scale 的时候 factor 小于等于 0,所以需要先 flip 一下(这也是为什么上面的公式上下颠倒了,因为有一个 scale(1, -1) 没有施加)
  • rotate 旋转,和 css 里不一样,这里的角度单位都是 deg,并且省去了 deg,而且可能会接受 3 个参数,这时后两个会做为一个坐标,表示旋转的中心点
  • skewX skewY 倾斜,可以直接转为 matrix:
    • skewX(a): [1., 0., tana, 1., 0., 0.]
    • skewY(a): [1., tana, 0., 1., 0., 0.]

这之后上面的 svg 就可以正确渲染了:


但是这之后还是有 bug,其中一个是老问题,在 path 元素的 string 中,有些时候会省略掉空格,导致原来的 ManimGL 无法处理,另一个是当 M 指令后紧接着 S 指令时,会因为点集内只有一个点而引起错误

正确解析 path string

这个问题使用上面的那个正则表达式可以解决一部分,但是还有一个神奇的情况:两个 0 连着出现,也就是 “00”
这时后上面的正则会认为这是一个 0.0,而实际上,这是两个 0.0 并在了一起

然后 fran 改了改正则,在前面加了 “0|” 解决了这个问题

但是另一个更神奇的情况出现了:A1.098 1.098 0 11.777 1.875z
看到这个第一反应肯定是五个数对吧,但是 A 指令只接受七个数

看了浏览器解析的结果后发现,参数实际上是 1.098 1.098 0 1 1 0.777 1.875
而这个是 1 1 .777 三个数而不是 11.777 一个数的原因是,A 指令的第 4、5 个参数是 flag,一定是 0 或 1

这样的话使用正则就很复杂了
一顿查找后发现了另一个解析 svg path 的库:https://github.com/regebro/svg.path/

按照里面的思路,重新写了一个 path 的解析器,即按照规则一个一个读取需要的数据,并且随时删掉开头的空格/逗号
其中读取单个浮点数用的还是上边的正则表达式

在重写了 path 解析之后,就很少因为这里出问题了

正确处理 S 指令

对于下一个问题,报错出现在 VMobject.get_reflection_of_last_handle

1
2
3
4
5
6
7
8
def add_smooth_cubic_curve_to(self, handle, point):
self.throw_error_if_no_points()
new_handle = self.get_reflection_of_last_handle()
self.add_cubic_bezier_curve_to(new_handle, handle, point)

def get_reflection_of_last_handle(self):
points = self.get_points()
return 2 * points[-1] - points[-2]

这里这样做的原因是,S 指令会把前一个点的控制点关于前一个点中心对称,作为当前点的控制点。而这里仅有一个当前点 point[-1],而没有控制点 point[-2],导致了 IndexError

而标准里也说了,如果 S 指令前面没有其他生成路径的指令,直接把当前点当作控制点,所以改一下就好:

1
2
3
4
if self.get_num_points() == 1:
new_handle = self.get_points()[-1]
else:
new_handle = self.get_reflection_of_last_handle()

至此 SVGMobject 的 bug 基本上没剩多少了,但是还有几个标签没有实现,以及没有处理样式

而在样式处理这方面,ManimCE 做的已经很好了,打算有时间去借鉴过来 _(:з」∠)_

今天修 bug 的全部更改详见:3b1b/manim#1712

Reference

修复 ManimGL 中的 SVGMobject

https://blog.tonycrane.cc/p/81940d35.html

作者

TonyCrane

发布于

2022-01-25

更新于

2022-02-16

许可协议