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
,分拆在了 Scripts
与 Styles
。只是后面重构的时候,我越直接将 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
, back
与 extra
三个 id 会渲染。这是因为原版提供的模板只有两个字段——问题与答案,而问题的正反面分别使用了 front
与 back
(我现在也沿用)。
另外这里面还有用 pre 标签包裹,重构后就没有了,可以跟下面对照一下。当时为啥要用 pre 标签我有点忘了,重构后 pre 标签造成了一些问题,于是去掉了。看下面的脚本处理部分其实也有对 pre 标签进行一定的处理。
但是我却不仅有这几个字段,因此我额外添加了 extra1
与 extra2
:
1 | function render() { |
这里实际上进行了一点重构,原版的 render
与 show
其实是这样的:
1 | function render() { |
可见我将 renderMath
与 markdown
整合到 renderField
了。但是为什么不用个数组然后遍历呢,数据逻辑分离?哈哈,我不知道,因为当时我是笨比。
此外其实还能看到我的 render
与 show
有用 try 块包裹。这是因为当时实验的时候发现,要是对应 id 的 div 块找不到,就会出现问题。这是错误处理没做好的原因,其实应该在 document.getElementById(ID)
处对空值进行处理检验的,而不是直接提取 innerHTML
。只是我当时并不懂,直接大手一挥,捕获并无视错误。
不管怎么说,这样就额外支持了两个字段,只需要将这两个新的 id extra1
, extra2
加到自己的字段的 div 标签 id 就可以了。不过现在想来,这个 id 命名似乎也有不妥之处,id 应该是唯一标识符,但是 front
, back
, extra
的命名都实在是太简单了。但历史原因我还是沿用至今,未来也许会修改吧。
替换与 Cloze
原模版中有这样两个函数:
1 | function replaceInString(str) { |
replaceInString
在 renderMath
中使用:
1 | function renderMath(ID) { |
而 replaceHTMLElementsInString
除了在 replaceInString
中使用外,还在 markdown
中使用:
1 | function markdown(ID) { |
可以看出来,这是作为渲染的预处理。这与 Anki 编辑器的特性有关。
Anki 编辑器的内容实际上就是 HTML,这意味着你要写 <
就必须用 <
。而即便你原模原样写进去,刷新的时候(例如切换视图)会自动帮你转换。而这个有时候会造成一些问题,我在论坛上面提出来了,只是没有得到回应:
实际上我希望的便是原模原样的纯文本。同时也有人提出希望能原生支持 Markdown,只是目前都还看不到计划。
因此,上面的预处理其实就是将 HTML 尝试转回纯文本,再交由渲染器进行渲染。这个即使在重构后的脚本中也是无可避免的:
1 | /** |
当然 replaceInString
还有两行是需要额外关注的,那就是打了星号那两行。第二行 replace
后没有重新赋值,直接丢弃了,相当于没用。而第一行是去除掉了 span 标签。
我现在不太确定,因为我没有原模版来实验了(也懒得实验),但我猜测这应该是为了解决 Cloze 的问题。
这里就先提前讲一下下面也会涉及的内容吧,就是 Anki Cloze 处理的逻辑,用一个简单的例子说明:
1 | This is {{c1::a test}}. |
会被 Anki 处理为:
1 | <!-- Cloze 1 --> |
或
1 | <!-- Cloze 2 --> |
可见有两种情况:
- 激活的 Cloze:class 为
cloze
,此外还有data-cloze
属性保存 Cloze 内容,而标签内的内容则变为[...]
。 - 未激活的 Cloze:class 为
cloze-inactive
。
两种 Cloze 都有 data-ordinal
属性,表示 Cloze 的序号。
要是在外面 Markdown 内容中还好说,毕竟这个属于 HTML 标签,Markdown 渲染器还是能处理的。但是要是在数学 中,那就不好说了。毕竟这个的扩展是发生在非常早的时候的,比渲染还要早。可以想象一下 $<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 自定义样式了。而且不仅仅是 ,乃至是整个 Markdown 模板都不可以,因为我们是去除了所有 span 标签,而非定向去除 内部的。
可以看到 Cloze 直接就是一个 [...]
参与渲染,它不会有任何特殊的样式。而当时的我,其实为 Cloze 设置了漂漂亮亮的样式,以及把 [...]
改成了下划线的形式。
当然可能会讲,这叫什么个事啊,没有就没有罢,我不稀罕!
嗯,我也是这样想的,因此高二还是高三的时候尝试了一番后就放弃了,乃至整个大一和大部分大二我也都是这样过来的。
因此当你翻回最上面的演示图时,看到 Cloze 的样式时,就能理解我有多感动了,这的的确确是圆了我高中的一个梦。
当然,这部分是下面的重点,因为这是重构后解决的,改进的原模版也没能解决这个问题。
我只对这两个预处理函数做了点改动:
1 | function replaceInString(str) { |
可见主要有三处改动。除了将上面讲过的无效的 replace
修复外,我还用更复杂的匹配,定向去除了 Cloze 的 span 标签,而非只要是 span 标签就枪毙。还有一个是将对
的处理,从 replaceHTMLElementsInString
移动到了 replaceInString
中。
其实这样看来没什么不同,因为也是移动到了 replaceInString
末尾,跟 replaceHTMLElementsInString
最开头没啥差异,除非……
没错,除非单独使用 replaceHTMLElementsInString
。
我上面给出了在 markdown
中就有单独使用 renderMathInElement
的情况。这是在 的渲染 renderMath
结束后,要对 Markdown 内容进行渲染。这时候照例转换一点内容,只是这里就出现了问题。
在这里先介绍一下 的 mhchem,mhchem 是一个用以编写化学式的 宏包,而在 MathJax 与 中都有另外的插件以支持。此外 mhchem 还支持单位,也正是我下面要用的。例如 $\pu{1mol/L}$
渲染结果就应该是 。
可以注意到数字和单位之间有一个小小的空格,如果你会打开开发者工具查看元素的话,就会发现这个空格实际上是 <span class="mspace nobreak"> </span>
。注意到了吗,
?
你再将这个
替换为一个普通的空格
,就如同上面的 replaceHTMLElementsInString
所做的那样。再看一看,是不是数字与单位之间的空格就消失了?
事实上这就是我四年前遇到的问题,我发现按理应该存在的数字与单位之间的空格,却消失得无影无踪。
我也不知道当时懵懂无知的我最终是怎么发现了这个问题,虽然这个问题现在看来也许还是蛮清晰的,但对于当时的我来说,应该还是玄之又玄的。
当然,上面的逻辑都是基于原模版的,并不是说这样就是正确的。
话说回来忘记说了,上面定向去除 Cloze 的 span 标签实际上是有问题的。因为它没有去除未激活的 span 标签,这反而带来了原模版中没有的问题。
主要就是这些吧。其实讲的更多的还是原模版的问题以及我的尝试改进?当然依旧还是有很多不足的,不然我就不会再重构一下了。
脚本
简介
警告
未经过大规模测试,在边缘情况可能还存在问题,欢迎试用、反馈或贡献!此外脚本基于本人的需求而定制,可能有额外少见的用例会影响使用,可自行选择剔除,但不提供相关支持。
脚本 GitHub 地址 _Anki-Markdown.js
,下面内容基于版本 v1.0.1。
一个增强版的 Anki Markdown 脚本,支持:
- Markdown 语法
- 基础语法
- 数学公式(含 mhchem 支持)
- highlight.js 语法高亮
==xxx==
标记高亮支持- 脚注支持
- Mermaid 图表支持
- 可自定义拓展
- Cloze 支持
- 支持为 Cloze(
.cloze
,.cloze-inactive
)配置样式 - 支持嵌套 Cloze
- 支持在 公式、代码块等中使用 Cloze
- 不支持在 Mermaid 图表中使用 Cloze
- 支持为 Cloze(
- ……
展示
以 Cloze 为例,字段内容如下(公式有笔误但不影响内容):
1 | ### This is a test |
注意,一些字符会给转为 HTML,如
<
变成<
等,因此与实际 Markdown 语法略有不同。
Cloze 的颜色是 #ec6c4f
,未激活的 Cloze 颜色是 #a8c5f2
,同时加上了下划线。可以自行修改配置,在这里区别开来便于展示。
Cloze 1 正面内容如下:
Cloze 1 反面内容如下:
Cloze 2 正面内容如下:
Cloze 2 反面内容如下:
使用说明
首先要将相关依赖放在 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 | <div class="border1"> |
其中 问题
是笔记模板的第一个字段。
使用方法如下:
- 为想要添加 Markdown 支持的字段添加 id,如上面的
front
- 在模板中添加
<script src="_Anki-Markdown.js"></script>
随后打开 _Anki-Markdown.js
,在 --- Configuration ---
栏目中的 config
常量配置中,为 fieldIds
添加对应的字段 id,如果有 Cloze,还要添加到 clozeFieldIds
。默认值如下:
1 | /** @type {string[]} Field IDs to render */ |
两面都是类似的操作。
配置说明
可在 _Anki-Markdown.js
中修改配置,例如上面的进行渲染的 字段 id。
此外可以修改 resources
变量,在里面添加所需的资源文件,内部顺序会影响加载顺序。默认会从本地目录(即 collection.media
)加载,如果没有则从配置的 CDN 加载。可以选择类型与依赖等等:
1 | { |
上面是 的 mhchem 依赖的例子。
katexOptions
是 的配置,里面有大量个人的配置(包括用于兼容的旧配置),如 macros
宏配置等,可以自行修改。
markdownOptions
是 markdown-it 配置、mermaidOptions
是 Mermaid 配置,同上。
此外脚本内预留了一些可自定义的部分,同时还有清晰的注释与调试语句,可以自行修改扩展并调试。
已知限制
- Cloze 不能在 Mermaid 图表中使用
- KaTeX 公式与代码块中的 Cloze 处理可能不稳定
- 跨行 Cloze 问题 #2
开发指引
此部分仅供开发者参考,普通用户可忽略。
Cloze 处理逻辑
其他部分的功能基本比较稳定,除了部分 HTML 转义可能出现未处理的问题外,最有可能出现问题的就是 Cloze 部分,尤其是出现在 公式或代码块中的 Cloze。
下面以一个例子简要说明该脚本 Cloze 处理的逻辑:
1 | This is {{c1::a test}}. |
会被 Anki 处理为:
1 | <!-- Cloze 1 --> |
或
1 | <!-- Cloze 2 --> |
可见有两种情况:
- 激活的 Cloze:class 为
cloze
,此外还有data-cloze
属性保存 Cloze 内容,而标签内的内容则变为[...]
。 - 未激活的 Cloze:class 为
cloze-inactive
。
两种 Cloze 都有 data-ordinal
属性,表示 Cloze 的序号。
在普通的 Markdown 解析中,这个没有问题,基本不会对正常渲染产生干扰。
但是在 公式块,或者是 highlight.js 语法高亮的代码块中,就可能出现解析异常。
而同时, Cloze 的 span 标签中的内容是要参与接下来的 Markdown 渲染(包括 , Mermaid 等),同时在最后还要支持 Cloze 的自定义样式,因此不能简单地将 Cloze 相关标签删除了事。
此脚本于是采用了一种思路——使用基本用不到的 Unicode 字符替换掉 Cloze 的 span 标签,尽最大可能减小对接下来渲染的干扰。使用 ⛶i🄀xxx⛿i🄀
的格式,其中 i 是 Cloze 的唯一序号,xxx 是 Cloze span 标签中的内容。
i 作为索引,标识了脚本保存在 clozePlaceholdersData
中的具体信息,随后辅助在渲染结束后进行恢复。
例如上面的 Cloze 1 的内容会被替换为:
1 | This is ⛶1🄀[...]⛿1🄀. |
Cloze 2 的内容会被替换为:
1 | This is ⛶1🄀a test⛿1🄀. |
但是非常不幸的是,即使是使用这样的替换,依旧是会对 公式或代码块的渲染产生影响。
部分以下面为例进行演示:
$a{{c1::b}}c$ |
这个在替换 Cloze 并进行 解析后会变成:
1 |
|
可见在 katex-html
class 的 span 中,分界符 ⛶1🄀
与 ⛿1🄀
被包裹在了 <span class="mord">...</span>
中,因此不可以简单地替换回来,必须要将这个外标签剥离去除。
此外除了 mord
class 外,针对不同位置的 Cloze,还可能出现 mtight
等 class,因此我在脚本中这样进行预处理:
1 | // --- Pre-processing step to remove KaTeX spans around markers --- |
目前是一个很脆弱的补丁,可能需要随着新问题的发现而进行改进。
代码块部分以下面为例:
1 | ```python |
这会给渲染成
1 | <pre |
可见分界符甚至给拆分了,用于标识序号的数字,被 <span class="hljs-number">...</span>
包裹了。
此外这个也并不一定会发生,发生时也未必成对,因此在脚本中进行了比较复杂的处理,这里不详述。可以参考相应的代码。
也许可以将序号换成不同的 Unicode 字符,或许能避免这个问题。
后续的改进方式可能就是并非依赖于字符替换,而是从 DOM 结构进行处理。只是这对其要求更高了,我暂且是没有什么动力去完成,也没什么思路。
即使是字符替换也是有改进方向的,例如上面一些问题的关键在于开闭标签层级不同,也许可以进行一些处理,在合理的范围内进行一些移动,使得层级相同。当然我现在也已经挺满意了,懒得动弹。