总所周知,Markdown 是一门非常宽松的语言,存在各种方言。但是就算是最基础的图片语法,我发现它还是存在二义性。
缘起
我维护了一个名叫 typora_plugin 的项目。里面有一个插件的功能是找出文件目录中所有未被 markdown 文件使用的图片,然后一键删除。
由于只需要匹配 md 文件中的图片语法,因此使用语法解析器显得不太划算,比如著名的 remark,解析大文件需要 500ms,这速度是不可接受的。我决定自己匹配。由此发现这个问题。
简述
目前最广为接受的图片语法是 ,然而它是二义的,核心问题就是 uri 可能出现小括号。
比如:.png)123),图片路径究竟是 ./img( 还是 ./img().png 还是 ./img().png)123?
注意:
img(、img().png、img().png)123都是 Windows 系统的合法文件名。
我看了 CommonMark 的 Spec:
- 要求链接语法中 uri 的小括号必须转义。如:
[link](\(foo\)) - 然而图片语法中的 uri 小括号又没有做要求,完全没解释
我试了试手头的 Markdown 编辑器,发现存在以下几种方法。
- 遵循 CommonMark 的链接语法, uri 的小括号前添加
\ - 强制转义 uri 中的小括号,没有小括号就不需要处理
- 使用
<>包裹,如.png>) - 限缩语法,要求 uri 的小括号必须平衡,不平衡的 uri 不给用。
旧的解决方案
为了处理此问题,我之前设计的方案是 回退匹配+检测。逻辑太复杂了,需要回流检测,性能不好。具体做法如下:对于文本 .png)123)456
- 首先贪婪匹配到
.png)123),此时 uri 为./image(1).png)123,检测是否存在此文件 - 若不存在,则回退匹配为
.png) - 递归处理上述逻辑
新的解决方案
经过尝试,我发现 Typora 采用了上述的第四种方法,私以为是比较好的。
我又试了试比较 Github 中比较火的 Markdown Parser,markdown-it,micromark 和 mdast 都是采用此方案。
依照 Typora 的外部表现特征,其文法大概是(我胡乱写的):
<MarkdownImage> ::= ''
<AltContent> ::= <CharExclCloseBracket> <AltContent> | ε
<UriContent> ::= <UriItem> <UriContent> | ε
<UriItem> ::= <CharExclParens> | '(' <UriContent> ')'
<CharExclCloseBracket> ::= [^\[\]\n]+
<CharExclParens> ::= [^\(\)\n]+
对应的简易解析器大概是:
function findImages(text) {
const parseUri = (text, startIdx) => {
let parenLevel = 0
let curIdx = startIdx
while (curIdx < text.length) {
const char = text[curIdx]
switch (char) {
case "(":
parenLevel++
break
case ")":
if (parenLevel === 0) {
return curIdx
}
parenLevel--
break
case "\n":
return
}
curIdx++
}
}
const results = []
const prefixRegex = /!\[([^\[\]\n]*)\]\(/g
let prefixMatch
while ((prefixMatch = prefixRegex.exec(text)) !== null) {
const uriStartIdx = prefixMatch.index + prefixMatch[0].length
const closeParenIdx = parseUri(text, uriStartIdx)
if (closeParenIdx === undefined) {
continue
}
const fullMatchEndIndex = closeParenIdx + 1
results.push({
alt: prefixMatch[1],
uri: text.slice(uriStartIdx, closeParenIdx),
start: prefixMatch.index,
end: fullMatchEndIndex,
})
prefixRegex.lastIndex = fullMatchEndIndex
}
return results
}