Tolaria 的 Markdown 持久化数学公式:基于占位符往返与 KaTeX 的笔记公式渲染方案
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
在 Tolaria(一个以 Markdown 文件为唯一事实来源的桌面笔记应用)中,数学公式支持是"可读性"与"文件持久性"之间的一次典型工程权衡:用户期望像$E=mc^2$、$$ ... $$这样的 LaTeX 语法在富文本编辑器里直接渲染,同时保存后的.md文件必须保留原始的纯文本分隔符,不能被编辑器私有格式改写。本文基于架构决策记录 0082 展开,完整覆盖该决策的背景、备选方案与后果,并结合 mathMarkdown.ts、editorSchema.tsx 等源码,讲解占位符编码、公式合法性启发式、KaTeX 安全渲染与输入交互的完整实现链路。读完后你将掌握:如何在 BlockNote/ProseMirror 编辑器中为自定义内容设计"可往返"的 Markdown 桥接层,以及如何用测试固化往返契约。
背景:为什么公式不能直接交给编辑器处理
Tolaria 的笔记是持久化的 Markdown 文件,主编辑器使用 BlockNote,raw 模式使用 CodeMirror。来自技术笔记工具的用户期望行内公式$E=mc^2$与展示公式$$ ... $$在笔记中直接渲染,但又不希望笔记因此变成"只有应用自己能读懂"的私有格式。
ADR 0082 的 Context 部分指出了两个具体约束:
- 本地编辑器包中的 BlockNote 目前没有第一方的数学公式 block;
- Tiptap 官方虽然提供了基于 KaTeX 节点的 Mathematics 扩展,但 Tolaria 的保存路径依赖 BlockNote 的 Markdown 解析器和
blocksToMarkdownLossy()序列化器——直接塞入不透明的 ProseMirror 数学节点,而没有配套的 Tolaria 序列化器,会导致原始 Markdown 源码丢失或被改写。
因此决策非常明确:公式支持通过由编辑器流水线(editor pipeline)拥有的"Markdown 占位符往返"实现——把公式源码先换成临时占位符再交给 Markdown 解析,渲染层用 Tolaria 自定义 schema 节点承载,保存/进入 raw 模式前再还原为原始分隔符。
备选方案:四种路线的取舍
ADR 中完整记录了四个候选方案及否决理由,这是理解当前架构的钥匙:
| 方案 | 评估 | 结论 |
|---|---|---|
| Tolaria 自有的占位符往返 + KaTeX 渲染 | 与既有的 wikilink 架构一致,保留纯文本源码,不依赖 BlockNote 对非默认 ProseMirror 数学节点的支持 | 采纳 |
| 在 BlockNote 中直接用 Tiptap Mathematics 扩展 | 官方出品、KaTeX 支撑,有吸引力;但无法单独解决 Tolaria 的 BlockNote Markdown 序列化契约 | 否决 |
| 仅在 raw 模式支持公式 | 保留源码,但牺牲了富编辑器中应有的阅读体验 | 否决 |
| 把公式存为自定义 JSON/frontmatter 元数据 | 未来可做更丰富的结构化编辑,但违反 Markdown 优先的持久性要求 | 否决 |
值得注意的是,"与既有 wikilink 架构一致"并非随口一提——Tolaria 的 richEditorMarkdown.ts 中,wikilink 的预处理/注入(preProcessWikilinks/injectWikilinks)与公式的preProcessMathMarkdown/injectMathInBlocks位于同一条流水线里(见下文"往返流水线在保存路径中的位置"),公式模块实际上是这套"自研 Markdown 编解码器"模式的又一次复用。
核心实现一:占位符编码与往返函数
ADR 的 Consequences 第一条指出:src/utils/mathMarkdown.ts是笔记公式的唯一权威解析/序列化桥。该模块对外导出五组 API,分别对应往返的不同阶段:
// src/utils/mathMarkdown.ts export const MATH_INLINE_TYPE = 'mathInline' export const MATH_BLOCK_TYPE = 'mathBlock' const INLINE_TOKEN_PREFIX = '@@TOLARIA_MATH_INLINE:' const BLOCK_TOKEN_PREFIX = '@@TOLARIA_MATH_BLOCK:' const TOKEN_SUFFIX = '@@' export function preProcessMathMarkdown({ markdown }: MarkdownSource): string // 源码 -> 占位符 export function injectMathInBlocks(blocks: unknown[]): unknown[] // 占位符 -> schema 节点 export function restoreMathInBlocks(blocks: unknown[]): unknown[] // 节点还原回文本 export function serializeMathAwareBlocks(editor: MarkdownSerializer, blocks: unknown[]): string export function renderMathToHtml({ latex, displayMode }: MathRenderRequest): string占位符的设计要点在于LaTeX 载荷的编码方式。源码中每个字符被转换为十六进制 code point 并以连字符连接(mathMarkdown.ts):
function encodeLatex({ latex }: LatexPayload): string { return Array.from(latex, (char) => char.codePointAt(0)?.toString(16) ?? '').join('-') } function decodeLatex({ encoded }: EncodedPayload): string { if (/^[0-9a-f-]+$/iu.test(encoded)) { try { return encoded.split('-').map((part) => String.fromCodePoint(Number.parseInt(part, 16))).join('') } catch { return encoded } } try { return decodeURIComponent(encoded) } catch { return encoded } } function mathToken({ prefix, latex }: TokenRequest): string { return `${prefix}${encodeLatex({ latex })}${TOKEN_SUFFIX}` }这样做有明确收益:LaTeX 源码中的~、^、_、{}等字符在 Markdown 里都有歧义含义(如~触发删除线、_触发斜体),一旦编码成[0-9a-f-]字符集,占位符在后续的 Markdown 解析中就是**惰性(inert)**的,不会触发任何格式转换。测试文件 mathMarkdown.test.ts 专门固化了这条契约:
it('keeps math placeholder payloads inert for Markdown parsing', () => { const preprocessed = preProcessMathMarkdown({ markdown: 'Spacing math $x + y ~ z$ stays math.' }) expect(preprocessed).toContain('@@TOLARIA_MATH_INLINE:') expect(preprocessed).not.toContain('~@@') })即$x + y ~ z$被替换为@@TOLARIA_MATH_INLINE:<hex>@@之后,~不再裸露于 Markdown 流中。
核心实现二:公式识别的边界处理
preProcessMathMarkdown是整个流水线的入口(mathMarkdown.ts),其扫描逻辑按行进行,并有三条硬规则:
- 代码围栏内不动:
```与~~~围栏开启后,行内容原样透传,直到围栏闭合; - 行内代码不动:逐字符扫描时,反引号会切换
inCodeSpan状态,代码段内的$不会被识别为公式; - 转义美元符不动:
isEscaped通过统计$前连续反斜杠数量的奇偶性来判断该$是否被转义,被转义者不构成公式定界符。
对行内公式$...$,定界符必须满足isSingleDollar(前后都不是$,避免与$$冲突),闭合$后紧跟字母或数字时也不成立(防止把a$1$5这类货币文本误判为公式)。
对展示公式则支持两种形态,均由readDisplayMath分派:
- 单行形式
$$x^2$$:正则^\$\$(.+)\$\$$匹配整行; - 跨行形式:独占一行的
$$起始,向后查找下一个独占$$行,中间各行以\n连接为 LaTeX 载荷(readMultilineDisplayMath,mathMarkdown.ts)。
金融文本防护启发式
识别行内公式时最棘手的场景是业务笔记中大量的金额文本,例如$24.59M、($884M)。模块中的isValidInlineLatex在"非空、首尾无空格"之外,叠加了两道过滤:
function isValidInlineLatex({ latex }: LatexPayload): boolean { return Boolean(latex.trim()) && !/^\s|\s$/.test(latex) && !looksLikeFinancialProse({ latex }) && looksLikeIntentionalMath({ latex }) }looksLikeIntentionalMath的判定优先级:单个变量名(x'、y)直接算数;以"疑似货币金额"开头则直接排除;含2x、3m_1这类系数-变量语法则算数;随后要求存在数学符号(-+*/=<>^_{}~之一)或 LaTeX 命令(\frac等);最后若存在三个以上字母的纯英文单词则排除。looksLikeFinancialProse扫描"数字 + K/M/B/T/% 后缀 + 逗号/句号/右括号/空白"的金额前缀,且金额后跟随散文词时,判定为金融文本而非公式。
这套启发式由测试精确钉住(mathMarkdown.test.ts):
it('does not treat financial prose between dollar amounts as inline math', () => { const markdown = 'FY2025 revenue grew 178.5% YoY to $24.59M, and gross margins improved ' + 'dramatically from 63.0% to 82.6%. The company has a solid cash position ($884M) ' + 'and a strategic focus.' expect(preProcessMathMarkdown({ markdown })).toBe(markdown) }) it('keeps finance suffix amounts literal even when they are compact', () => { const markdown = 'Keep $2k$, $2M$, and $2%$ as finance prose.' expect(preProcessMathMarkdown({ markdown })).toBe(markdown) })同时$2+2$、$x_i$、$2x$、$\frac{a}{b}$仍被正确识别为行内公式(mathMarkdown.test.ts)。这说明该启发式是"保真优先、宁缺毋滥"的取向:误识别为公式的代价(改写业务文本)高于漏识别(公式按纯文本展示)。
核心实现三:BlockNote schema 节点与 KaTeX 安全渲染
injectMathInBlocks负责把 BlockNote 解析出的 blocks 中的占位符文本转换为自定义节点:
- 行内占位符
@@TOLARIA_MATH_INLINE:...@@被切分文本项,替换为{ type: 'mathInline', props: { latex } }内联内容项(expandInlineMath); - 当某个 block 的唯一内容是
@@TOLARIA_MATH_BLOCK:...@@文本时,整个 block 被改写为{ type: 'mathBlock', props: { latex } }(buildMathBlock,mathMarkdown.ts); - 表格单元格内容也会被递归处理(
transformTableContent),所以| Formula |\n| --- |\n| $a+b$ |这类表格内公式同样可往返(mathMarkdown.test.ts)。
这两类节点在 BlockNote schema 中注册(editorSchema.tsx):
export const MathInline = createReactInlineContentSpec( { type: MATH_INLINE_TYPE, propSchema: { latex: { default: '' } }, content: 'none', }, { render: (props) => ( <MathRender latex={props.inlineContent.props.latex} displayMode={false} /> ), }, ) const MathBlock = createReactBlockSpec( { type: MATH_BLOCK_TYPE, propSchema: { latex: { default: '' } }, content: 'none', }, { render: (props) => <MathBlockEditor block={props.block} editor={props.editor} />, }, )并随wikilink一起挂入 schema:inlineContentSpecs增加mathInline,blockSpecs增加mathBlock(editorSchema.tsx)。由于content: 'none',公式节点是自包含的叶子节点,LaTeX 源码只存在于props.latex一个属性中——这正是"保存路径只需还原属性、无需遍历子节点"的结构基础。
渲染统一走MathRender组件(editorSchema.tsx):
function MathRender({ latex, displayMode }: { latex: string; displayMode: boolean }) { const source = displayMode ? `$$\n${latex}\n$$` : `$${latex}$` return ( <SafeHtmlSpan aria-label={`Math: ${latex}`} className={displayMode ? 'math math--block' : 'math math--inline'} >export function renderMathToHtml({ latex, displayMode }: MathRenderRequest): string { try { return katex.renderToString(latex, { displayMode, throwOnError: false, trust: false, }) } catch { return escapeHtml({ text: latex }) } }即throwOnError: false与trust: false:格式错误或不可信的公式保持可见而不是抛出异常破坏整篇笔记;trust: false禁用\htmlClass等可执行 HTML 的宏能力;即便 KaTeX 意外抛错,也会降级为转义后的纯文本。渲染结果通过SafeHtmlSpan(SafeMarkup.tsx)受控注入,且元素携带data-latex、title与aria-label——前者是后续交互扩展回查 LaTeX 的定位锚点,后两者保证可访问性。KaTeX 依赖声明于 package.json 的katex: ^0.16.28。
核心实现四:富编辑器中的公式编辑交互
公式节点在富编辑器中并非只读。editorSchema.tsx 的MathBlockEditor为mathBlock提供了"双击进入源码编辑"的体验:
- 双击渲染出的公式 → 替换为
Textarea,内容为props.latex; Cmd/Ctrl + Enter提交编辑(updateBlock(blockId, { props: { latex } })),Escape取消并还原草稿;- 提交时通过
updateMathBlockLatexSafely包裹updateBlock,若捕获到"过期 block 引用"错误则走统一的变换错误恢复通道(reportRecoveredEditorTransformError),避免编辑器进入不可恢复状态。
另一方面,mathInputExtension.ts 负责让用户直接键入公式:
- 输入时实时转换:
createMathInputTransform监听beforeinput,当用户敲下空格或换行时,调用readCompletedInlineMathAtEnd(mathMarkdown.ts)检查光标前行文本是否恰好以一段完整的$...$结尾;是则把该段文本用tr.replaceWith替换为mathInline节点,并保留用户刚输入的尾随字符; - 反方向转换:光标选中
mathInline节点后按Enter/F2(handleMathKeyDown),或对渲染出的行内公式双击(handleRenderedMathDoubleClick),都会把节点替换回字面量$latex$文本,并把选区定位到 LaTeX 载荷内部——用户随后就能像在 plain 文本中一样直接修改公式源码,再次触发上面的输入转换。这一"渲染态 ↔ 源码态"的双向切换还会上报math_source_edit_reopened遥测事件(携带 keyboard/pointer 激活方式)。
往返流水线在保存路径中的位置
公式编解码只是 Tolaria 更大的"durable markdown"编排中的一个环节。richEditorMarkdown.ts 展示了完整的读取方向顺序:
export function preProcessRichEditorMarkdown(markdown, vaultPath?, notePath?) { const withLiteralBackslashes = preserveLiteralBackslashes(markdown) const withDurableBlocks = preProcessDurableEditorMarkdown({ markdown: withLiteralBackslashes }) const withEmptyChecklists = preProcessEmptyChecklistItems(withDurableBlocks) const withBlankQuotes = preProcessBlankBlockquoteParagraphs(withEmptyChecklists) const withBlankParagraphs = preProcessBlankParagraphs(withBlankQuotes) const withBareImages = normalizeBareImageUrls(withBlankParagraphs) const withImages = vaultPath ? resolveImageUrls(withBareImages, vaultPath, notePath) : withBareImages const withLinkedCode = preProcessLinkedCodeMarkdown(withImages) const withWikilinks = preProcessWikilinks(withLinkedCode) const withMath = preProcessMathMarkdown({ markdown: withWikilinks }) return preProcessSingleTildeStrikethrough({ markdown: withMath }) }保存方向则由 editorDurableMarkdown.ts 编排:serializeDurableEditorBlocks依次外包 file attachment、HTML/mermaid/tldraw 围栏、callout 与公式等序列化层。其中公式层的serializeCalloutAndMathAwareBlocks(editorDurableMarkdown.ts)与serializeMathAwareBlocks(mathMarkdown.ts)的策略一致:遇到mathBlock就先把此前积累的普通 blocks 用restoreMathInBlocks+blocksToMarkdownLossy刷出为 Markdown 片段,然后直接输出$$\n{latex}\n$$字面量,最后以空行连接各片段。行内公式则在restoreInlineMath中还原为$latex$文本后再交给 BlockNote 序列化器(mathMarkdown.ts)。
这条链路与 wikilink、callout、mermaid、tldraw 等模块共同构成"每个自定义内容都有成对的 preProcess/inject/serialize 函数"的模式,公式模块是 ABSTRACTIONS.md 中所描述抽象的落地实例之一。
效果与约束
结合 ADR 0082 的 Consequences 与上述源码,可以归纳出该方案的运行时行为与边界:
- 富编辑器中看到 KaTeX 渲染结果,raw 模式(CodeMirror)中看到的是原始
$...$/$$...$$字面量——raw 模式是编辑精确公式源码最直接的途径; - Obsidian 风格导入的笔记保持可读:因为落盘格式就是标准美元定界符,文件在 Tolaria 之外(Obsidian、VS Code、Pandoc 等)同样可理解;
- 未来扩展点在同一个源码契约上:公式编辑辅助功能可以构建在同一 Markdown 存储契约之上,无需改动存储模型;
- 再评估条件也被写进 ADR:只有当能够证明 Tiptap Mathematics 直接集成不引入自定义 lossy 行为、能完整保存 Tolaria 的 Markdown 保存路径时,才会重新考虑直连方案。
- 识别侧的已知取舍:金融启发式(
$2k$、$884M)等)优先保护业务文本,代价是某些不常规的"行内公式"写法会按纯文本展示——这是由 mathMarkdown.test.ts 中多条回归测试显式固化的产品级行为,而非 bug。
小结:占位符往返模式的可复用要点
Tolaria 的公式方案对任何"以 Markdown 文件为持久化载体、以 Block 编辑器为前端"的项目都有直接参考价值,其可复用的工程要点是:
- 编码占位符必须对下游解析器惰性——用无歧义字符集(十六进制+连字符)承载载荷,避免触发下游 Markdown 语法;
- 识别规则要有明确的否定测试——对易误判的领域文本(此处为金额)编写回归用例,把"不识别"作为契约的一部分;
- 渲染层与存储层解耦——KaTeX 只负责把
props.latex变成 HTML,throwOnError: false+trust: false+ 异常降级保证任何坏公式都不会击穿文档; - 序列化时自定义节点旁路 lossy 序列化器——自定义 block 直接输出字面量 Markdown,普通 blocks 才走
blocksToMarkdownLossy,保证往返字节级可控; - 交互双向可逆——键入
$...$自动生成节点、选中节点按 Enter/F2 或双击退回源码,编辑体验闭环。
所有关键证据路径汇总:决策记录 docs/adr/0082-markdown-durable-math-notes.md;解析/序列化桥 src/utils/mathMarkdown.ts 及其测试 src/utils/mathMarkdown.test.ts;schema 与渲染组件 src/components/editorSchema.tsx;输入交互扩展 src/components/mathInputExtension.ts 及测试 src/components/mathInputExtension.test.ts;读写流水线 src/utils/richEditorMarkdown.ts 与 src/utils/editorDurableMarkdown.ts。
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考