关于 Markdown 图片和链接语法的二义性思考及其解决方案

总所周知,Markdown 是一门非常宽松的语言,存在各种方言。但是就算是最基础的图片语法,我发现它还是存在二义性。

缘起

我维护了一个名叫 typora_plugin 的项目。里面有一个插件的功能是找出文件目录中所有未被 markdown 文件使用的图片,然后一键删除。

由于只需要匹配 md 文件中的图片语法,因此使用语法解析器显得不太划算,比如著名的 remark,解析大文件需要 500ms,这速度是不可接受的。我决定自己匹配。由此发现这个问题。

简述

目前最广为接受的图片语法是 ![alt](uri),然而它是二义的,核心问题就是 uri 可能出现小括号。

比如:![alt](./img().png)123),图片路径究竟是 ./img( 还是 ./img().png 还是 ./img().png)123

注意:img(img().pngimg().png)123 都是 Windows 系统的合法文件名。

我看了 CommonMark 的 Spec:

  • 要求链接语法中 uri 的小括号必须转义。如:[link](\(foo\))
  • 然而图片语法中的 uri 小括号又没有做要求,完全没解释

我试了试手头的 Markdown 编辑器,发现存在以下几种方法。

  1. 遵循 CommonMark 的链接语法, uri 的小括号前添加 \
  2. 强制转义 uri 中的小括号,没有小括号就不需要处理
  3. 使用 <> 包裹,如 ![alt](<./img().png>)
  4. 限缩语法,要求 uri 的小括号必须平衡,不平衡的 uri 不给用

旧的解决方案

为了处理此问题,我之前设计的方案是 回退匹配+检测。逻辑太复杂了,需要回流检测,性能不好。具体做法如下:对于文本 ![alt](./image(1).png)123)456

  1. 首先贪婪匹配到 ![alt](./image(1).png)123),此时 uri 为 ./image(1).png)123,检测是否存在此文件
  2. 若不存在,则回退匹配为 ![alt](./image(1).png)
  3. 递归处理上述逻辑

新的解决方案

经过尝试,我发现 Typora 采用了上述的第四种方法,私以为是比较好的。

我又试了试比较 Github 中比较火的 Markdown Parser,markdown-itmicromarkmdast 都是采用此方案。

依照 Typora 的外部表现特征,其文法大概是(我胡乱写的):

<MarkdownImage> ::= '![' <AltContent> '](' <UriContent> ')'
<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
}
5 个赞

虽然看不懂,平时用 Obsidian 只写纯文字和链接的笔记,但是小虎虎还是觉得这个分享很有技术,很有用,为你点赞:+1: