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

欲知后事如何,且听下回分解,今晚太摆了,没写多少。下面是之前写的先复制过来,后面也要改改。

简介

警告

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

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

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

  • Markdown 语法
    • 基础语法
    • KaTeX 数学公式(含 mhchem 支持)
    • highlight.js 语法高亮
    • ==xxx== 标记高亮支持
    • 脚注支持
    • Mermaid 图表支持
    • 可自定义拓展
  • Cloze 支持
    • 支持为 Cloze(.cloze, .cloze-inactive)配置样式
    • 支持嵌套 Cloze
    • 支持在 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 的 mhchem 依赖的例子。

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

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

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

已知限制

评论部分

开发指引

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

其他部分的功能基本比较稳定,除了部分 HTML 转义可能出现未处理的问题外,最有可能出现问题的就是 Cloze 部分,尤其是出现在 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 1 -->
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 公式块,或者是 highlight.js 语法高亮的代码块中,就可能出现解析异常。

而同时, Cloze 的 span 标签中的内容是要参与接下来的 Markdown 渲染(包括 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 部分以下面为例进行演示:

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

这个在替换 Cloze 并进行 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 字符,或许能避免这个问题。