news 2026/9/13 19:06:22

Tolaria 的 Markdown 持久化数学公式:基于占位符往返与 KaTeX 的笔记公式渲染方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Tolaria 的 Markdown 持久化数学公式:基于占位符往返与 KaTeX 的笔记公式渲染方案

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),其扫描逻辑按行进行,并有三条硬规则:

  1. 代码围栏内不动```~~~围栏开启后,行内容原样透传,直到围栏闭合;
  2. 行内代码不动:逐字符扫描时,反引号会切换inCodeSpan状态,代码段内的$不会被识别为公式;
  3. 转义美元符不动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)直接算数;以"疑似货币金额"开头则直接排除;含2x3m_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增加mathInlineblockSpecs增加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: falsetrust: false:格式错误或不可信的公式保持可见而不是抛出异常破坏整篇笔记;trust: false禁用\htmlClass等可执行 HTML 的宏能力;即便 KaTeX 意外抛错,也会降级为转义后的纯文本。渲染结果通过SafeHtmlSpan(SafeMarkup.tsx)受控注入,且元素携带data-latextitlearia-label——前者是后续交互扩展回查 LaTeX 的定位锚点,后两者保证可访问性。KaTeX 依赖声明于 package.json 的katex: ^0.16.28

核心实现四:富编辑器中的公式编辑交互

公式节点在富编辑器中并非只读。editorSchema.tsx 的MathBlockEditormathBlock提供了"双击进入源码编辑"的体验:

  • 双击渲染出的公式 → 替换为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/F2handleMathKeyDown),或对渲染出的行内公式双击(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 编辑器为前端"的项目都有直接参考价值,其可复用的工程要点是:

  1. 编码占位符必须对下游解析器惰性——用无歧义字符集(十六进制+连字符)承载载荷,避免触发下游 Markdown 语法;
  2. 识别规则要有明确的否定测试——对易误判的领域文本(此处为金额)编写回归用例,把"不识别"作为契约的一部分;
  3. 渲染层与存储层解耦——KaTeX 只负责把props.latex变成 HTML,throwOnError: false+trust: false+ 异常降级保证任何坏公式都不会击穿文档;
  4. 序列化时自定义节点旁路 lossy 序列化器——自定义 block 直接输出字面量 Markdown,普通 blocks 才走blocksToMarkdownLossy,保证往返字节级可控;
  5. 交互双向可逆——键入$...$自动生成节点、选中节点按 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 19:01:22

深入解析 lo.DropWhile:用 Go 1.18+ 泛型按谓词丢弃切片前缀

深入解析 lo.DropWhile&#xff1a;用 Go 1.18 泛型按谓词丢弃切片前缀 【免费下载链接】lo &#x1f4a5; A Lodash-style Go library based on Go 1.18 Generics (map, filter, contains, find...) 项目地址: https://gitcode.com/GitHub_Trending/lo/lo lo 是一个基于…

作者头像 李华
网站建设 2026/9/13 19:00:10

基于gVisor的LLM代码安全执行架构设计与实践

1. 项目背景与核心挑战在AI技术快速发展的今天&#xff0c;大语言模型(LLM)的代码解释能力正逐步从实验室走向生产环境。作为西南总部AI调度官团队的技术负责人&#xff0c;我们面临着一个关键挑战&#xff1a;如何在保证系统安全的前提下&#xff0c;充分发挥LLM的代码生成与执…

作者头像 李华
网站建设 2026/9/13 18:58:54

SSD主控固件DDR初始化实战:数据结构布局、耗时优化与避坑指南

1. 这不是教科书里的“初始化”——而是主控固件在上电瞬间的生死抉择SSD 主控固件启动时需要在 DDR 中初始化哪些数据结构&#xff1f;各自的规模和耗时如何&#xff1f;——这个问题看似只是嵌入式系统里一个技术细节&#xff0c;但实际是 SSD 可靠性、性能与寿命的底层分水岭…

作者头像 李华
网站建设 2026/9/13 18:58:48

MMC实时仿真三大避坑指南:模型、求解器与硬件协同优化

1. 项目概述&#xff1a;为什么MMC实时仿真不是“把模型拖进去跑一下”那么简单做MMC&#xff08;模块化多电平换流器&#xff09;的实时仿真&#xff0c;我最初也以为就是照着教科书搭个拓扑、选个求解器、设个步长&#xff0c;点下运行——结果前三个小时全在报错里打转。第一…

作者头像 李华
网站建设 2026/9/13 18:57:17

gVisor + Docker 实战:用 runsc 沙箱运行时部署 WordPress 站点

gVisor Docker 实战&#xff1a;用 runsc 沙箱运行时部署 WordPress 站点 【免费下载链接】gvisor Application Kernel for Containers 项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor 本文基于 gVisor 官方教程&#xff0c;讲解如何在 Docker 中使用 runsc…

作者头像 李华