Anki 支持 Markdown 的模板

历程

呀啊,这周算是圆了高三的一个梦,那就是加强了 Markdown 模板。

初版模板是基于插件(已停止维护)Markdown and KaTeX Support,后面将其中的脚本迁移到了一个继承的模板后就移除了。在那会我记得还是零差评呢。这个继承下来的模板也经过了不小改动,但从几个图标还是能看出来原主,有机会讲讲。

上个学期刚开学时让这个模板支持了白天模式,结果直接终结了黑夜模式长达数年的统治,我现在反而觉得白天模式更顺眼了,黑夜模式有种「老气」。

哦,原来 Markdown 是将加粗弄为 strong 标签而非 b 标签,是我误解了,难怪难怪。我以前只知道斜体是 emph 不是 i。

而这周一的时候,正在制卡,正好制到了需要 Mermaid 图表辅助说明的地方。联想到这学期的笔记 Mermaid 图表不少,而且对理解很有帮助、不可或缺,要是手动截图的话太麻烦了,因此就决定像是之前一样手动去支持一下。

啥叫「像是之前」?模板是基于上面插件的模板不假,但此前我有过脚注的需求,因此自行加了 footnote 插件。此外还手动更新了一些依赖、修复了一些问题等等,反正就是根据自己的需求定制了一番。因此就打算依葫芦画瓢弄了 Mermaid。

不过这个确实不好弄,我找了一番没找到合适的,基本上相关的插件都太老了,Mermaid 7 什么的,现在已经 11 了,API 已经改了不少,用不了了。

于是干脆就抛给 Gemini,顺带决定重写一下,总觉着现在这样就是不断屎糊墙,太丑陋了。

哦突然想到 To Do,目前最早的一个尚未完成的 To Do,居然正好也是 9.3 哎,说起来今年 9.3 估摸着能看阅兵哎。

这个也蛮早了,上面插件的作者不维护后,有人进行了重构,解决了一些问题,即 REWORK 版本。于是我就寻思着整合了一下,最早的想法看了看估计能追溯到高考后那段时间。

既然说到这了,不如就来说一下原始模板有些什么问题吧。不过其实说的是我改版后的原始模板,具体可以追溯到重写前的最后一版 _Markdown-KaTeX.js

问题

下面的问题说明基本上是基于 22d1ea8 时的仓库说明的,而且没有截图,只有口述。

22d1ea8 时目录格式跟现在有所不同,并不是全部脚本、样式表都堆砌在 collection.media 这样,而是放在了 Assets,分拆在了 ScriptsStyles。只是后面重构的时候,我越直接将 Git 仓库建在了 collection.media,这样不用手动同步了,只是会稍显混乱。

而最为核心的则是 Markdown-KaTeX.js 这个脚本。当然,其实在 collection.media 中应该是作为 _Markdown-KaTeX.js 引入的。这个模板魔改于原始模板,即使解决了一些原有的问题,依旧会有一些无法满足我的需求。

那么首先来看看我改了什么,以及为什么要这样改吧。

多字段

首先说明一下,Markdown 渲染支持靠的是字段的 div 标签 id 来判断的,例如当时的 ClozeMarkdown 模板「问题」字段就如下:

<div class="h2 xleft" id="front"><pre>{{cloze:问题}}</pre></div>

而原版其实只有 front, backextra 三个 id 会渲染。这是因为原版提供的模板只有两个字段——问题与答案,而问题的正反面分别使用了 frontback(我现在也沿用)。

另外这里面还有用 pre 标签包裹,重构后就没有了,可以跟下面对照一下。当时为啥要用 pre 标签我有点忘了,重构后 pre 标签造成了一些问题,于是去掉了。看下面的脚本处理部分其实也有对 pre 标签进行一定的处理。

但是我却不仅有这几个字段,因此我额外添加了 extra1extra2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function render() {
try {renderField("front")} catch {}
try {renderField("back")} catch {}
try {renderField("extra")} catch {}
try {renderField("extra1")} catch {}
try {renderField("extra2")} catch {}
show();
}

function renderField(field) {
renderMath(field);
markdown(field);
}

function show() {
try {document.getElementById("front").style.visibility = "visible"} catch {}
try {document.getElementById("back").style.visibility = "visible"} catch {}
try {document.getElementById("extra").style.visibility = "visible"} catch {}
try {document.getElementById("extra1").style.visibility = "visible"} catch {}
try {document.getElementById("extra2").style.visibility = "visible"} catch {}
}

这里实际上进行了一点重构,原版的 rendershow 其实是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
function render() {
renderMath("back");
markdown("back");
renderMath("extra");
markdown("extra");
show();
}

function show() {
document.getElementById("back").style.visibility = "visible";
document.getElementById("extra").style.visibility = "visible";
}

可见我将 renderMathmarkdown 整合到 renderField 了。但是为什么不用个数组然后遍历呢,数据逻辑分离?哈哈,我不知道,因为当时我是笨比。

此外其实还能看到我的 rendershow 有用 try 块包裹。这是因为当时实验的时候发现,要是对应 id 的 div 块找不到,就会出现问题。这是错误处理没做好的原因,其实应该在 document.getElementById(ID) 处对空值进行处理检验的,而不是直接提取 innerHTML。只是我当时并不懂,直接大手一挥,捕获并无视错误。

不管怎么说,这样就额外支持了两个字段,只需要将这两个新的 id extra1, extra2 加到自己的字段的 div 标签 id 就可以了。不过现在想来,这个 id 命名似乎也有不妥之处,id 应该是唯一标识符,但是 front, back, extra 的命名都实在是太简单了。但历史原因我还是沿用至今,未来也许会修改吧。

替换与 Cloze

原模版中有这样两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function replaceInString(str) {
str = str.replace(/<[\/]?pre[^>]*>/gi, "");
str = str.replace(/<br\s*[\/]?[^>]*>/gi, "\n");
str = str.replace(/<div[^>]*>/gi, "\n");
// Thanks Graham A!
* str = str.replace(/<[\/]?span[^>]*>/gi, "")
* str.replace(/<\/div[^>]*>/g, "\n");
return replaceHTMLElementsInString(str);
}

function replaceHTMLElementsInString(str) {
str = str.replace(/&nbsp;/gi, " ");
str = str.replace(/&tab;/gi, " ");
str = str.replace(/&gt;/gi, ">");
str = str.replace(/&lt;/gi, "<");
return str.replace(/&amp;/gi, "&");
}

replaceInStringrenderMath 中使用:

1
2
3
4
5
6
7
8
9
10
11
12
function renderMath(ID) {
let text = document.getElementById(ID).innerHTML;
> text = replaceInString(text);
document.getElementById(ID).textContent = text;
renderMathInElement(document.getElementById(ID), {
delimiters: [
{left: "$$", right: "$$", display: true},
{left: "$", right: "$", display: false}
],
throwOnError : false
});
}

replaceHTMLElementsInString 除了在 replaceInString 中使用外,还在 markdown 中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function markdown(ID) {
let md = new markdownit({typographer: true, html:true, highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (__) {}
}

return ''; // use external default escaping
}}).use(markdownItMark);
> let text = replaceHTMLElementsInString(document.getElementById(ID).innerHTML);
text = md.render(text);
document.getElementById(ID).innerHTML = text.replace(/&lt;\/span&gt;/gi,"\\");
}

可以看出来,这是作为渲染的预处理。这与 Anki 编辑器的特性有关。

Anki 编辑器的内容实际上就是 HTML,这意味着你要写 < 就必须用 &lt;。而即便你原模原样写进去,刷新的时候(例如切换视图)会自动帮你转换。而这个有时候会造成一些问题,我在论坛上面提出来了,只是没有得到回应:

实际上我希望的便是原模原样的纯文本。同时也有人提出希望能原生支持 Markdown,只是目前都还看不到计划。

因此,上面的预处理其实就是将 HTML 尝试转回纯文本,再交由渲染器进行渲染。这个即使在重构后的脚本中也是无可避免的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Decodes HTML entities in a string.
* @param {string} text - The string to decode.
* @returns {string} The decoded string.
*/
function decodeHTMLEntities(text) {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
}

...

content = md.render(decodeHTMLEntities(element.innerHTML));
element.innerHTML = content; // Update the DOM with rendered HTML

当然 replaceInString 还有两行是需要额外关注的,那就是打了星号那两行。第二行 replace 后没有重新赋值,直接丢弃了,相当于没用。而第一行是去除掉了 span 标签。

我现在不太确定,因为我没有原模版来实验了(也懒得实验),但我猜测这应该是为了解决 Cloze 的问题。

这里就先提前讲一下下面也会涉及的内容吧,就是 Anki Cloze 处理的逻辑,用一个简单的例子说明:

1
2
3
This is {{c1::a test}}.

This is a {{c1::nested {{c2::cloze}} test}}.

会被 Anki 处理为:

1
2
3
4
<!-- Cloze 1 -->
This is <span class="cloze" data-cloze="a test" data-ordinal="1">[...]</span>.

This is a <span class="cloze" data-cloze="nested <span class=&quot;cloze-inactive&quot; data-ordinal=&quot;2&quot;>cloze</span> test" data-ordinal="1">[...]</span>.

1
2
3
4
<!-- Cloze 2 -->
This is <span class="cloze-inactive" data-ordinal="1">a test</span>.

This is a <span class="cloze-inactive" data-ordinal="1">nested <span class="cloze" data-cloze="cloze" data-ordinal="2">[...]</span> test</span>.

可见有两种情况:

  • 激活的 Cloze:class 为 cloze,此外还有 data-cloze 属性保存 Cloze 内容,而标签内的内容则变为 [...]
  • 未激活的 Cloze:class 为 cloze-inactive

两种 Cloze 都有 data-ordinal 属性,表示 Cloze 的序号。

要是在外面 Markdown 内容中还好说,毕竟这个属于 HTML 标签,Markdown 渲染器还是能处理的。但是要是在数学 KaTeX\KaTeX 中,那就不好说了。毕竟这个的扩展是发生在非常早的时候的,比渲染还要早。可以想象一下 $<span>a</span>$ 的渲染结果是什么样子的,总不会是 aa 吧,实际上应该是 <span>a</span><span>a</span>

因此原模版的处理方式就是,什么 Cloze?我不要了!我直接将所有的 span 标签去除,也就是说只保留它的内容。

这是一种蛮力的方式。但是似乎看起来也还行?例如说我有个这样的 Cloze:

$a {{c1::b}} c$

在渲染前就变成了

$a <span class="cloze" data-ordinal="1">[...]</span> c$

去除 span 标签后变成

$a [...] c$

诶,这不是一样还是给应该打 Cloze 的 b 打了 Cloze 吗?

但是实际上这个有一个缺陷。直接蛮力去除 span 标签真的没问题吗?让我们看看都损失了些什么。

显然是损失掉了 span 标签的一些属性,具体来说就是 class, data-ordinal 以及可能的 data-cloze

后两个我们不论,class 损失代表了什么?代表了我们无法为 Cloze 自定义样式了。而且不仅仅是 KaTeX\KaTeX,乃至是整个 Markdown 模板都不可以,因为我们是去除了所有 span 标签,而非定向去除 KaTeX\KaTeX 内部的。

可以看到 Cloze 直接就是一个 [...] 参与渲染,它不会有任何特殊的样式。而当时的我,其实为 Cloze 设置了漂漂亮亮的样式,以及把 [...] 改成了下划线的形式。

当然可能会讲,这叫什么个事啊,没有就没有罢,我不稀罕!

嗯,我也是这样想的,因此高二还是高三的时候尝试了一番后就放弃了,乃至整个大一和大部分大二我也都是这样过来的。

因此当你翻回最上面的演示图时,看到 KaTeX\KaTeX Cloze 的样式时,就能理解我有多感动了,这的的确确是圆了我高中的一个梦。

当然,这部分是下面的重点,因为这是重构后解决的,改进的原模版也没能解决这个问题。

我只对这两个预处理函数做了点改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function replaceInString(str) {
str = str.replace(/<[\/]?pre[^>]*>/gi, "");
str = str.replace(/<br\s*[\/]?[^>]*>/gi, "\n");
str = str.replace(/<div[^>]*>/gi, "\n");
str = str.replace(/<span class="cloze" data-cloze=".*?" data-ordinal="\d+">|<[\/]?span[^>]*>/gi, "")
str = str.replace(/<\/div[^>]*>/gi, "\n");
str = str.replace(/&nbsp;/gi, " ");
return replaceHTMLElementsInString(str);
}

function replaceHTMLElementsInString(str) {
str = str.replace(/&tab;/gi, " ");
str = str.replace(/&gt;/gi, ">");
str = str.replace(/&lt;/gi, "<");
return str.replace(/&amp;/gi, "&");
}

可见主要有三处改动。除了将上面讲过的无效的 replace 修复外,我还用更复杂的匹配,定向去除了 Cloze 的 span 标签,而非只要是 span 标签就枪毙。还有一个是将对 &nbsp; 的处理,从 replaceHTMLElementsInString 移动到了 replaceInString 中。

其实这样看来没什么不同,因为也是移动到了 replaceInString 末尾,跟 replaceHTMLElementsInString 最开头没啥差异,除非……

没错,除非单独使用 replaceHTMLElementsInString

我上面给出了在 markdown 中就有单独使用 renderMathInElement 的情况。这是在 KaTeX\KaTeX 的渲染 renderMath 结束后,要对 Markdown 内容进行渲染。这时候照例转换一点内容,只是这里就出现了问题。

在这里先介绍一下 KaTeX\KaTeXmhchem,mhchem 是一个用以编写化学式的 LaTeX\LaTeX 宏包,而在 MathJax 与 KaTeX\KaTeX 中都有另外的插件以支持。此外 mhchem 还支持单位,也正是我下面要用的。例如 $\pu{1mol/L}$ 渲染结果就应该是 1 mol/L\pu{1mol/L}

可以注意到数字和单位之间有一个小小的空格,如果你会打开开发者工具查看元素的话,就会发现这个空格实际上是 <span class="mspace nobreak">&nbsp;</span>。注意到了吗,&nbsp;

你再将这个 &nbsp; 替换为一个普通的空格 ,就如同上面的 replaceHTMLElementsInString 所做的那样。再看一看,是不是数字与单位之间的空格就消失了?

事实上这就是我四年前遇到的问题,我发现按理应该存在的数字与单位之间的空格,却消失得无影无踪。

我也不知道当时懵懂无知的我最终是怎么发现了这个问题,虽然这个问题现在看来也许还是蛮清晰的,但对于当时的我来说,应该还是玄之又玄的。

当然,上面的逻辑都是基于原模版的,并不是说这样就是正确的。

话说回来忘记说了,上面定向去除 Cloze 的 span 标签实际上是有问题的。因为它没有去除未激活的 span 标签,这反而带来了原模版中没有的问题。

主要就是这些吧。其实讲的更多的还是原模版的问题以及我的尝试改进?当然依旧还是有很多不足的,不然我就不会再重构一下了。

脚本

简介

警告

未经过大规模测试,在边缘情况可能还存在问题,欢迎试用、反馈或贡献!此外脚本基于本人的需求而定制,可能有额外少见的用例会影响使用,可自行选择剔除,但不提供相关支持。

脚本 GitHub 地址 _Anki-Markdown.js,下面内容基于版本 v1.0.1。

一个增强版的 Anki Markdown 脚本,支持:

  • Markdown 语法
    • 基础语法
    • KaTeX\KaTeX 数学公式(含 mhchem 支持)
    • highlight.js 语法高亮
    • ==xxx== 标记高亮支持
    • 脚注支持
    • Mermaid 图表支持
    • 可自定义拓展
  • Cloze 支持
    • 支持为 Cloze(.cloze, .cloze-inactive)配置样式
    • 支持嵌套 Cloze
    • 支持在 KaTeX\KaTeX 公式、代码块等中使用 Cloze
    • 不支持在 Mermaid 图表中使用 Cloze
  • ……

展示

以 Cloze 为例,字段内容如下(公式有笔误但不影响内容):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
### This is a test

- **Bold** test
- *Italic* test
- ==highlight== test
- <u>HTML underline</u> test

1. This's
2. ordered
3. test[^test]
4. I'm {{c1::Cloze 1}}
- I'm {{c1::nested in Cloze 1 named {{c2::Cloze 2}} lol}} (nested test)
- I'm {{c2::nested in Cloze 2 named {{c1::Cloze 1}} lol}} (nested test)
- I'm normal {{c2::Cloze 2}}

[^test]: footnote test

This is {{c1::$\LaTeX$}}. This is $\lim_{n \to \infty} {{c1::\sum_{i=0}^n \frac{1}{{{c2::i^2}}} }} = \frac{\pi^2}{6}$, and
$$
\begin{cases}
{{c1::\int_0^\infty t^x \e^{-t} \d x}}, &amp; \text{Display test}\\
\int_0^\infty {{c1::{{c2::t^x}} \e^{-t} }} \d x, &amp; \text{Copied}\\
\pu{1 mol}, &amp; \text{mhchem test}
\end{cases}
$$

```python
# Code block
print("highlight test")
# Codeblock Cloze
{{c1::def func:
print("This is a func")
return {{c2::lambda x: x ** 2}}}}
```

```mermaid
flowchart LR
A[This is mermaid] --&gt; B[test]
%% C("{{c1::mermaid}}") -.-&gt;|or| D{"{{c1::My {{c2::PlantUML}}}}"}
E{"Cloze is not supported in mermaid"} --&gt; F("yet")
```

注意,一些字符会给转为 HTML,如 < 变成 &lt; 等,因此与实际 Markdown 语法略有不同。

Cloze 的颜色是 #ec6c4f,未激活的 Cloze 颜色是 #a8c5f2,同时加上了下划线。可以自行修改配置,在这里区别开来便于展示。

Cloze 1 正面内容如下:

Image

Cloze 1 反面内容如下:

Image

Cloze 2 正面内容如下:

Image

Cloze 2 反面内容如下:

Image

使用说明

首先要将相关依赖放在 collection.media 目录,包括 _Anki-Markdown.js 与脚本内部出现的依赖(下面的依赖是可选的,若本地文件不存在会从 CDN 获取),可能有:

  • _katex.css
  • _highlight.min.css
  • _katex.js
  • _auto-render.js
  • _markdown-it.min.js
  • _highlight.min.js
  • _mhchem.js
  • _markdown-it-mark.min.js
  • _markdown-it-footnote.min.js
  • _mermaid.min.js

以 Cloze 类型的笔记模板为例,Basic 类型类似。

上面用于演示的笔记模板如下(局部):

1
2
3
4
5
6
7
8
9
10
<div class="border1">
<div class="h1 xcolor xleft">
<span class="ximg"><img src="_space.png" height="24" width="36" /></span>
问题
</div>

<div class="h2 xleft" id="front">{{cloze:问题}}</div>
</div>

<script src="_Anki-Markdown.js"></script>

其中 问题 是笔记模板的第一个字段。

使用方法如下:

  1. 为想要添加 Markdown 支持的字段添加 id,如上面的 front
  2. 在模板中添加 <script src="_Anki-Markdown.js"></script>

随后打开 _Anki-Markdown.js,在 --- Configuration --- 栏目中的 config 常量配置中,为 fieldIds 添加对应的字段 id,如果有 Cloze,还要添加到 clozeFieldIds。默认值如下:

1
2
3
4
5
/** @type {string[]} Field IDs to render */
fieldIds: ["front", "back", "extra", "extra1", "extra2"],

/** @type {string[]} Field IDs that might contain Cloze deletions and need placeholder processing. */
clozeFieldIds: ["front", "back"],

两面都是类似的操作。

配置说明

可在 _Anki-Markdown.js 中修改配置,例如上面的进行渲染的 字段 id。

此外可以修改 resources 变量,在里面添加所需的资源文件,内部顺序会影响加载顺序。默认会从本地目录(即 collection.media)加载,如果没有则从配置的 CDN 加载。可以选择类型与依赖等等:

1
2
3
4
5
6
7
{
type: "script",
name: "mhchem",
local: "_mhchem.js",
cdn: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/mhchem.min.js",
dependsOn: "katex",
},

上面是 KaTeX\KaTeX 的 mhchem 依赖的例子。

katexOptionsKaTeX\KaTeX 的配置,里面有大量个人的配置(包括用于兼容的旧配置),如 macros 宏配置等,可以自行修改。

markdownOptions 是 markdown-it 配置、mermaidOptions 是 Mermaid 配置,同上。

此外脚本内预留了一些可自定义的部分,同时还有清晰的注释与调试语句,可以自行修改扩展并调试。

已知限制

原 issue 评论部分

  1. Cloze 不能在 Mermaid 图表中使用
  2. KaTeX 公式与代码块中的 Cloze 处理可能不稳定
    1. 跨对象公式的 Cloze 渲染截断问题 #4
  3. 跨行 Cloze 问题 #2

开发指引

此部分仅供开发者参考,普通用户可忽略。

Cloze 处理逻辑

其他部分的功能基本比较稳定,除了部分 HTML 转义可能出现未处理的问题外,最有可能出现问题的就是 Cloze 部分,尤其是出现在 KaTeX\KaTeX 公式或代码块中的 Cloze。

下面以一个例子简要说明该脚本 Cloze 处理的逻辑:

1
2
3
This is {{c1::a test}}.

This is a {{c1::nested {{c2::cloze}} test}}.

会被 Anki 处理为:

1
2
3
4
<!-- Cloze 1 -->
This is <span class="cloze" data-cloze="a test" data-ordinal="1">[...]</span>.

This is a <span class="cloze" data-cloze="nested <span class=&quot;cloze-inactive&quot; data-ordinal=&quot;2&quot;>cloze</span> test" data-ordinal="1">[...]</span>.

1
2
3
4
<!-- Cloze 2 -->
This is <span class="cloze-inactive" data-ordinal="1">a test</span>.

This is a <span class="cloze-inactive" data-ordinal="1">nested <span class="cloze" data-cloze="cloze" data-ordinal="2">[...]</span> test</span>.

可见有两种情况:

  • 激活的 Cloze:class 为 cloze,此外还有 data-cloze 属性保存 Cloze 内容,而标签内的内容则变为 [...]
  • 未激活的 Cloze:class 为 cloze-inactive

两种 Cloze 都有 data-ordinal 属性,表示 Cloze 的序号。

在普通的 Markdown 解析中,这个没有问题,基本不会对正常渲染产生干扰。

但是在 KaTeX\KaTeX 公式块,或者是 highlight.js 语法高亮的代码块中,就可能出现解析异常。

而同时, Cloze 的 span 标签中的内容是要参与接下来的 Markdown 渲染(包括 KaTeX\KaTeX, Mermaid 等),同时在最后还要支持 Cloze 的自定义样式,因此不能简单地将 Cloze 相关标签删除了事。

此脚本于是采用了一种思路——使用基本用不到的 Unicode 字符替换掉 Cloze 的 span 标签,尽最大可能减小对接下来渲染的干扰。使用 ⛶i🄀xxx⛿i🄀 的格式,其中 i 是 Cloze 的唯一序号,xxx 是 Cloze span 标签中的内容。

i 作为索引,标识了脚本保存在 clozePlaceholdersData 中的具体信息,随后辅助在渲染结束后进行恢复。

例如上面的 Cloze 1 的内容会被替换为:

1
2
3
This is ⛶1🄀[...]⛿1🄀.

This is a ⛶2🄀[...]⛿2🄀.

Cloze 2 的内容会被替换为:

1
2
3
This is ⛶1🄀a test⛿1🄀.

This is a ⛶3🄀nested ⛶2🄀[...]⛿2🄀 test⛿3🄀.

但是非常不幸的是,即使是使用这样的替换,依旧是会对 KaTeX\KaTeX 公式或代码块的渲染产生影响。

KaTeX\KaTeX 部分以下面为例进行演示:

$a{{c1::b}}c$

这个在替换 Cloze 并进行 KaTeX\KaTeX 解析后会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

<span
><span class="katex"
><span class="katex-mathml"
><math xmlns="http://www.w3.org/1998/Math/MathML"
><semantics
><mrow
><mi>a</mi><mtext></mtext><mn>1</mn><mtext>🄀</mtext
><mo stretchy="false">[</mo><mi mathvariant="normal">.</mi
><mi mathvariant="normal">.</mi><mi mathvariant="normal">.</mi
><mo stretchy="false">]</mo><mtext></mtext><mn>1</mn
><mtext>🄀</mtext><mi>c</mi></mrow
><annotation encoding="application/x-tex"
>a⛶1🄀[...]⛿1🄀c</annotation
></semantics
></math
></span
><span class="katex-html" aria-hidden="true"
><span class="base"
><span class="strut" style="height: 1em; vertical-align: -0.25em"></span
><span class="mord mathnormal">a</span><span class="mord">⛶1🄀</span
><span class="mopen">[</span><span class="mord">...</span
><span class="mclose">]</span><span class="mord">⛿1🄀</span
><span class="mord mathnormal">c</span></span
></span
></span
></span
>

可见在 katex-html class 的 span 中,分界符 ⛶1🄀⛿1🄀 被包裹在了 <span class="mord">...</span> 中,因此不可以简单地替换回来,必须要将这个外标签剥离去除。

此外除了 mord class 外,针对不同位置的 Cloze,还可能出现 mtight 等 class,因此我在脚本中这样进行预处理:

1
2
3
4
5
6
7
8
9
10
11
// --- Pre-processing step to remove KaTeX spans around markers ---
// KaTeX will wrap the markers in spans with classes like "mord", "mtight", etc. like <span class="mord mtight">⛶1🄀</span>
const katexStartMarkerWrapperRegex =
/<span\s+class="mord(?: m[a-z]+)*"[^>]*>((?:⛶(?:(?:<span[^>]*>)?\d+(?:<\/span>)?|\d+)🄀)+)<\/span>/g;
const katexEndMarkerWrapperRegex =
/<span\s+class="mord(?: m[a-z]+)*"[^>]*>((?:⛿(?:(?:<span[^>]*>)?\d+(?:<\/span>)?|\d+)🄀)+)<\/span>/g;

// Remove the KaTeX wrappers, leaving only the marker content
let currentHtml = htmlContent
.replace(katexStartMarkerWrapperRegex, "$1")
.replace(katexEndMarkerWrapperRegex, "$1");

目前是一个很脆弱的补丁,可能需要随着新问题的发现而进行改进。

代码块部分以下面为例:

1
2
3
```python
{{c1::print(1)}}
```

这会给渲染成

1
2
3
4
<pre
class="hljs"
><code><span class="hljs-number">1</span>🄀[...]⛿<span class="hljs-number">1</span>🄀
</code></pre>

可见分界符甚至给拆分了,用于标识序号的数字,被 <span class="hljs-number">...</span> 包裹了。

此外这个也并不一定会发生,发生时也未必成对,因此在脚本中进行了比较复杂的处理,这里不详述。可以参考相应的代码

也许可以将序号换成不同的 Unicode 字符,或许能避免这个问题。

后续的改进方式可能就是并非依赖于字符替换,而是从 DOM 结构进行处理。只是这对其要求更高了,我暂且是没有什么动力去完成,也没什么思路。

即使是字符替换也是有改进方向的,例如上面一些问题的关键在于开闭标签层级不同,也许可以进行一些处理,在合理的范围内进行一些移动,使得层级相同。当然我现在也已经挺满意了,懒得动弹。