尝试编写一个编译器

起因

Manim 群友 Fran 做了一个 QQ tex bot 并引入了群聊,看起来挺好用的,只需要满足匹配规则的消息就能生成公式的图片。它的依赖是 Fran 自改的 asciimathml 工具,能够使用比较简单的 ASCII 字符来表达比较复杂的公式。

这个原项目其实应该说年代有点久远了,而且源码与 DOM 是强耦合的,导致在 Node.js 环境下会有很多潜在的问题,而且也没有类型提示,所以我就有了重构它的想法。

我想做的其实就只是把 asciimath 格式的字符串转化为 LaTeX\LaTeX 格式的字符串,并不想去把 asciimathml 这种带标签的字符串转换为 LaTeX\LaTeX 的 DOM 元素了,毕竟现在也有 KaTeX 和 MathJax 这些工具,也就没有必要再去做这个工作了。

同时,前面也说到,旧代码与 DOM 耦合度非常高,因此,我想要做的,就是纯字符串处理的一个程序,而基本上完全与 DOM 隔离开。

项目编写流程

Tokenize

先看一个例子,下面的数学公式应该被渲染为 0+exdx=1\displaystyle{ \int _{ 0 } ^{ + \infty } \text{e} ^{ - x } {\text{d}x} = 1 }

int_0^(+oo) "e"^(-x) dx = 1

我们发现,这个公式包含了非常多的关键字,以及一些可以直接作为 LaTeX\LaTeX 代码字符串的部分,我们需要找出所有的关键字,把它们包装成一些指定的类型(例如普通关键字、带有一个参数的关键字、带有多个参数的关键字等等),形成一个 token 序列,然后把它们传给下一级做进一步处理。

然而,asciimath 中内置的关键字就有 200 多个,而且应当还能添加自定义关键字,如果读取不固定的字符串,与非常大的一个集合去匹配,这样做显然是不明智的。对于这种情况,我们也就理所应当地使用字典树来进行匹配了1

构建字典树

我们在算法中认识的字典树,通常每个节点内字符元素的长度是 26,因为我们常用的也就是 26 个英文字母。而在这里,关键字可能包含大小写、一些特殊符号等,所以还必须向节点中额外添加一些元素,以满足当前的要求。

然而,这样又会让由字符找到数组索引较为困难。由于 Ascii 字符也不是很多,因此直接用一个 Map 映射就可以了。当然,应该还有更好的解决方案只是现在代码已经写完了,我也懒得改了

JavaScript 中带有 emoji 的字符串

Fran 在阅读我的代码的时候,发现我使用了 C 语言的方式来遍历字符串,并没有考虑到超过 \uffff 的字符,因此建议使用 [...str].forEach 这种方式来遍历字符串。虽然我并没有用 emoji 作为关键字的打算

搜索关键字

其实这棵字典树承担了 Tokenize 的所有工作,包括识别关键字、数字、其他符号等。另外受 markdown 启发,用一个空行来表示换行,因此也就用这种方式来作为 align 环境下的换行符了。

这样我们就有了这样的优先级排序

空行 \displaystyle{ \succ } 跳过空白 \displaystyle{ \succ } 关键字 \displaystyle{ \succ } 数字 \displaystyle{ \succ } 正体文字 (\text 环境) \displaystyle{ \succ } 任意字符

与此同时,将这些 token 分类,得到一个序列,而后传给下一步骤。

碎碎念

具体分类详见 https://github.com/widcardw/asciimath-parser/blob/main/src/symbols.ts#L1

其实我是没想到竟然需要分这么多类,但是 asciimath 确实有这么多种分类,根据这么多分类,才能在 Parse 阶段转换为合理的 AST ¯\_(ツ)_/¯

Parse

对于一个扁平的 token 序列,我们肯定需要使用递归才能创建一个树形的结构,而整个程序的流程大概就是这样

function parser(tokens: TokenizedValue[]) {
const root = createRootNode()
let current = 0
while (current < tokens.length) {
const res = walk(tokens, current) // 读取并处理一个节点
root.body.push(res.node) // 将读取到的节点加入序列中
current = res.current // 更新当前指针
}
return root
}

而最关键的逻辑就在 walk 中了。

简单的 Token 处理方法

对于数字、正体文本、可以单独使用的符号,可以直接简单的创建对应的节点即可,不需要递归处理,这其实也就相当于是递归的一部分终止情况。

被左右括号包裹的情况

我们将 [1,2,3] 这种仅仅包含逗号的序列,直接作为普通的数组来看,因此事实上可以直接依次返回,但我们还是将它包裹为一个节点的序列,将整个节点序列封装为一个新的节点而后返回。

为什么需要这样处理

从最开始的例子中,我们发现 (-x) 也是被包裹在一对括号中的,整个节点都被当作一个上标作为了 ^ 的参数,因此还是有必要这样处理的。

如果这个被括号包裹的序列中出现了分号,那么我们就需要将它处理为矩阵。例如 [1,2;3,4]

[1234]\displaystyle{ \left[ \begin{array}{cc} 1 & 2 \\ 3 & 4 \end{array} \right] }

这样其实逻辑就很明了了,只需要逐行地去构建矩阵就行了。

然而,我在 asciimath.org 看到了另外的一个用法,我也想把它复刻过来

[123456]\displaystyle{ \left[ \begin{array}{cc|c} 1 & 2 & 3 \\ 4 & 5 & 6 \end{array} \right] }
[1, 2 | 3; 4, 5 | 6]

思路是,在 matrix 环境内再嵌套一个 array 环境,使用 {cc|c} 参数来指定分割线的位置。为了简单化处理,在扫描矩阵的每一行时,每当遇到 | 时,都记录下当前竖线所在矩阵的位置,最后汇总所有竖线应当出现的列数,得到对应的 {cc|c} 参数即可。

综上,这一段程序需要不断向后查找,直到找到匹配的右括号,还需要判断在这一段 token 序列中是否出现了分号,以此来判断要将它处理为数组、矩阵还是其他类型。

被竖线包裹的表达式

竖线作为一个非常特殊的存在,可以作为绝对值两侧的包裹,也可以作为矩阵行列式的左右包裹,还能单独使用,因此也是需要像上面矩阵中说到的那样预先判断,而后才能进一步处理。

有以下这些情况,分别处理即可。

  • 单独使用 {(x,y)x2+y21}\displaystyle{ \left\lbrace \left( x , y \right) \mid x ^{ 2 } + y ^{ 2 } \leqslant 1 \right\rbrace }
  • 绝对值 lnx\displaystyle{ \left| \ln x \right| }
  • 行列式 1234\displaystyle{ \left| \begin{array}{cc} 1 & 2 \\ 3 & 4 \end{array} \right| }

除法

我们希望用一个 / 符号就能表示除法,并将这个表达式转换为分式 frac 的形式,那么就不得不在读取完当前节点 A\displaystyle{ A } 后,立即向后观察是否存在除法。如果存在,那么还需要进一步读取,得到分母 B\displaystyle{ B } 后,将它渲染为 \frac{A}{B} 的形式。于是,walk 程序应当这样安排。

function walk(tokens: TokenizedValue[], current: number) {
// 将当前的 token 处理为 ast 的节点,并使指针前进
const token = tokens[current]
let { node, cur } = processCurrentToken(token)
current = cur
if (current < tokens.length) {
const nextToken = tokens[current]
// 检测下一个 token 是否为除法
if (nextToken.type === TokenTypes.Div) {
// 创建一个双参数节点
const newNode = createNodeWithTwoParams('\\frac')
current++
// 读取分母
const b = walk(tokens, current)
current = b.current
// 分子
newNode.param[0] = node
// 分母
newNode.param[1] = b.node
node = newNode
}
}
return { node, current }
}

当然,有一个优先级也比较高的运算符——阶乘,只不过它不需要继续向后读取了,用更简单的方式处理即可。

带有参数的表达式

上下标

上下标应当是优先级最高的,比除法的优先级更高,应当把 pi^2/6 渲染为 π26\displaystyle{ \frac{ \pi ^{ 2 } }{ 6 } } 而不是 π26\displaystyle{ \pi ^{ \frac{ 2 }{ 6 } } }

基于这个例子,我们进行这样的分析:在读取完当前的 token 之后,继续向后看一个 token,如果是上下标,那么需要继续向后读取,直到组成一个符合语法的上下标为止。但是考虑到一些优先级的关系,又有下面的情况

  • 对于 pi^2 这样简单的式子,无须考虑优先级
  • 对于 pi^2/6 带有除法的,读取到上标为止就直接结束,将 pi^2 打包为一个节点,再处理除法
  • 对于 a_1^2 既有下标又有上标的,读取到一个下标就结束,将 a_1 打包为一个节点,再处理上标

这样,只需在 look forward 阶段加一个循环,不断将第一个合法的节点包装起来,进入下一个循环继续读取即可。

其他带有一个或两个参数的表达式

例如 sqrt, abs, frac 这类的表达式,它们只需向后读取一个或两个节点,即可依据这些节点,生成一个新的表达式节点,似乎反而不需要考虑优先级了。

Code Generation

在上一步中,我们已经构建好了 AST 树,在代码生成阶段,只需深度优先遍历这棵树即可。上一步生成的节点类型有以下几类,由于类型较少,所以处理起来就相当方便了。

enum NodeTypes {
Root = 'Root',
Const = 'Const', // 可直接生成目标代码的节点
ParamOne = 'ParamOne', // 带有一个参数的节点
ParamTwo = 'ParamTwo', // 带有两个参数的节点
Matrix = 'Matrix', // 矩阵
Flat = 'Flat', // 包裹了一系列节点的节点
}

由于是深度优先遍历,那么就不可避免的需要用到递归。

Const 节点

可以直接查询该节点对应的 tex 代码输出即可。

根节点 & Flat 节点

由于根节点其实跟 Flat 节点没有本质的区别,因此可以用类似的方式处理:直接遍历节点内包裹的系列节点并生成对应的代码即可。

一些处理上的区别

由于修改版的 asciimath 支持 aligned 环境,因此还需要检测根节点中是否存在 aligned 环境相关的节点。若存在,那么就包裹上 aligned 环境即可。

矩阵节点

由于矩阵节点在 parse 阶段被处理成了二维数组,只需遍历就可以翻译成扁平的 LaTeX\LaTeX 代码了。

带有参数的节点

由于 symbols.tstex 关键字中,我使用了 $1$2 这样的标识符来占位,因此在代码生成阶段,可以直接使用字符串替换即可传递参数。

完整代码

https://github.com/widcardw/asciimath-parser

参考资料

Footnotes

  1. 当然询问 Fran 之后,发现原本就是用字典树来匹配的

#Parser #编译器 #Programming language #编程语言 #LaTeX