可视化公式编辑器

0 阅读11分钟

在富文本里插公式,我为什么最后把 MathQuill 换成了 MathLive

先想一个画面:题干里插着一张公式图片。字号和正文对不上,放大了糊成马赛克,老师想把 2x 改成 3x,只能把整张图删掉重画。更麻烦的是,这张图在数据库里就是一坨 base64,搜不到、改不了、导不进 Word。

在线教育、题库、文档协作,只要沾了数学内容,早晚会撞上这个问题。我做的是一套嵌在富文本编辑器里的可视化公式输入,前后换过两套渲染引擎。

先说结论:TinyMCE 6 + MathLive 编辑 + MathJax 3 展示,公式以 LaTeX 源码存库,渲染产物扔进 Shadow DOM,不进保存内容。

git地址:github.com/liliyang857…

在线体验:liliyang857.github.io/math-latex-…

image.png


一、先想清楚一件事:公式到底以什么形态存在

动手之前必须先把这个问题定下来,它决定了后面所有接口的形状。市面上常见三种存法:

存法二次编辑导出 PDF/Word存储体积搜索
PNG 图片要靠 OCR,或者把源码塞进 data-*位图,放大会糊大(base64 一张几十 KB)搜不到
LaTeX 字符串直接改源码需要服务端重新渲染小(几十字节)能搜到源码
MathML需要专门的编辑器依赖目标格式支持大一般

我们选了 LaTeX 源码:数据库里存的是 x^2-2x-3=0 这种裸文本,展示时再渲染。理由很朴素——它是唯一一种既轻量、又能被任意系统消费、还能人肉读懂的格式。

但这带来一个新的硬约束:任何一次「编辑 → 渲染 → 再编辑」的往返,都不能污染源码。

这一条后来成了整套选型的判据。


二、MathQuill 看着最省事,但它有天花板

一开始我选的是 MathQuill 0.10。理由很充分:它是 Khan Academy 和一大批在线题库在用的老牌方案,API 简单,latex() 直接进出。

上线前做功能验收,撞到墙:

诉求MathQuill 0.10MathLive 0.111
矩阵 \begin{pmatrix}❌✅
分段函数 \begin{cases}❌✅
多行对齐 \begin{aligned}❌✅
细空格 \,❌ 丢弃或变逗号✅ 原样保留
化学式 \ce{H2O}(mhchem)❌✅
LaTeX 往返⚠️ 有损✅ 逐字符无损
jQuery必须锁 3.x不需要
静态渲染要另接 MathJax同引擎

「有损」这三个字杀伤力最大。MathQuill 的 latex() 会做等价改写:

  • x^{2} 导出成 x^2(单字符上下标去花括号)
  • 括号自动补 \left…\right
  • 命令后的尾随空格被 replace(/(\\[a-z]+) (?![a-z])/ig,'$1') 干掉

功能上等价,但对「以源码为唯一真相」的设计来说这是致命的:用户只是在可视化区点了一下光标、什么都没改,源码就变了。任何基于源码的 diff、去重、缓存、审核记录都会失效。要挡住只能写归一化函数,两侧都清洗一遍再比——这套活儿越写越脏。

还有个更直接的坑:npm i jquery 装到 4.x,MathQuill 加载即抛 TypeError: Cannot read properties of undefined (reading 'prototype')——它内部用了 jQuery.isFunction、.bind(),jQuery 4 全删了。得精确锁 3.7.1。

所以最终换了 MathLive。数学能力开箱即用,往返逐字符无损,还省掉一个 jQuery。

代价是体积。 MathLive 编译后 800 KB(gzip 221 KB)。这个数字放在内网后台无所谓,放在 C 端页面里必须处理。后面第八节讲怎么压。


三、双引擎:编辑用 MathLive,展示用 MathJax

这是整个项目里最反直觉、也最值得讲的一个决定。

既然 MathLive 就能渲染公式,为什么正文还要再挂一套 MathJax?

因为 MathLive 的排版是「面向输入」的,MathJax 的排版是「面向成品」的,两者的错误策略相反。

真实的 bug 长这样:编辑器的实时预览区用的是 MathLive 的 <math-span>,用户敲了 \left( \frac{1}{2},忘了闭合 \right)。MathLive 为了不让编辑者卡住,会自动补一个不可见定界符继续排版——所以预览区里公式看起来是好的。

点确认,插进正文,正文走 MathJax。MathJax 严格校验,判定失败,回退成源码文本。

现象就是:预览正常,插入正文变成一串 LaTeX 源码。录入时完全发现不了。

修法不是让 MathJax 也容错,而是把预览也切到 MathJax,让录入时看到的就是正文里会看到的东西。SDK 0.2.26 就是这么改的:底部预览和模板卡片统一走 renderLatex() 同一条链路,遇到 data-mjx-error 就直接显示源码(警示色 + title 说明)。

这条规则后来沿用到了所有地方:同一份 LaTeX,全系统只有一条渲染链路,只有一套判据。

编辑器和正文的数据流

image.png 调用侧只有三个 API:

import { openFormulaEditor, renderLatex, initFormulaRendering } from 'formula-editor-modal'
import 'formula-editor-modal/style.css'

// 应用启动调一次:让页面里已有的 LaTeX 节点能渲染
await initFormulaRendering()

const handle = openFormulaEditor({
  subject: 'math',      // math / physics / chemistry
  outputType: 'latex',  // latex 返回公式节点;img 返回 PNG 图片
  latex: '',            // 初始源码;双击编辑时传节点的 data-latex
  async onConfirm(result) {
    const html = result.type === 'latex'
      ? await renderLatex(result.data)   // → 标准 span
      : result.data                       // → <img> HTML
    editor.insertContent(html)
  },
  onCancel() {},
})

onConfirm 支持 async:插入成功之后才关弹窗,失败就显示错误、保留输入、可以重试。

节点长什么样

<span class="fe-formula" data-latex="x^2"
      contenteditable="false"
      style="display:inline-block;vertical-align:baseline;">x^2</span>

三点设计:

  1. data-latex 是唯一真相。文本内容是 x^2(未渲染时的降级显示),渲染产物在 Shadow DOM 里。
  2. contenteditable="false" 让富文本编辑器把整个 span 当成一个原子节点,光标进不去,不会把公式拆散。
  3. SVG 在 Shadow DOM,所以 innerHTML、cloneNode 都拿不到它——这是特性不是 bug:库里存的 HTML 只有几十字节,不带上百 KB 的 SVG 路径。

vertical-align: baseline 是 0.2.9 加的。之前默认的 middle 会让公式里的数字和正文数字上下错位半个字,行内看起来很明显。


四、接进 TinyMCE:花时间的不是公式,是光标

TinyMCE 本身不带公式能力,但它把「富文本编辑器该有的脏活」都做对了:撤销栈、选区管理、粘贴清洗。SDK 不需要知道 TinyMCE 存在,宿主负责接线。

接线有四件事必须做对,每一条都是踩出来的。

1. 用 inline 模式,让两边共用同一份 document

await tinymce.init({
  target: editorElement.value,
  inline: true,
  fixed_toolbar_container_target: toolbarElement.value,
  toolbar_persist: true,
  skin: false,
  content_css: false,
  noneditable_class: 'fe-formula',
  extended_valid_elements:
    'span[class|data-latex|contenteditable|style],img[class|data-latex|src|alt|width|height|style]',
})

默认的 iframe 模式里,编辑器正文在另一个 document 里,Shadow DOM 的公式渲染、弹窗定位、选区书签全都要跨文档处理。inline: true 让正文就在主 document 里,这些问题一次性消失。

extended_valid_elements 不能省:TinyMCE 会按白名单清洗 DOM,data-latex 不在默认白名单里,不清洗的话第一次保存就丢了。

2. 书签要用 getBookmark(1),不是 getBookmark(2, true)

TinyMCE 5 时代流传的写法是 getBookmark(2, true)。用在这个场景里会出事:它会把「紧挨着不可编辑公式的光标」还原成「选中整个公式」。结果是第二次插入公式时,把上一个公式覆盖掉了。

正确做法:

currentEditor.focus()
if (!target) currentEditor.selection.collapse(false)   // 新增:光标折到末尾
const bookmark = currentEditor.selection.getBookmark(1)

弹窗开着的这几秒里,用户可能在弹窗里点来点去,编辑器的选区早就没了。所以插入前必须先 moveToBookmark(bookmark) 把位置找回来。

3. 插入必须走 undoManager.transact()

currentEditor.undoManager.transact(() => {
  if (target?.isConnected) currentEditor.selection.select(target)  // 编辑:替换旧节点
  currentEditor.insertContent(html)
})
currentEditor.dispatch('input')
currentEditor.dispatch('change')

包在 transact() 里,一次公式插入才是撤销栈里的一个原子操作。不包的话,selection.select 和 insertContent 会变成两步,用户按一次 Ctrl+Z 只退一半——公式没了,但原来那个被替换的公式也回不来。

dispatch('input') / dispatch('change') 是给外层表单用的。TinyMCE 通过 API 改内容不会自动触发这两个事件,React Hook Form / v-model 都收不到更新。

4. 双击编辑:从 data-latex 回填

instance.on('dblclick', event => {
  const node = (event.target as Element).closest<HTMLElement>(
    'span.fe-formula[data-latex], img.fe-formula-image[data-latex]',
  )
  if (node && instance.getBody().contains(node)) {
    event.preventDefault()
    editFormula(node)
  }
})

注意两点:

  • 用 closest() 而不是判断 event.target 本身。用户双击的位置大概率落在公式 SVG 的某个 <path> 上,不是 span。
  • instance.getBody().contains(node) 兜底,防止浮层里的元素被误判。

回填时有个细节:双击编辑要保留原公式的形式。原来插的是图片,改完还是图片;原来是 LaTeX 节点,改完还是 LaTeX 节点。所以 outputType 由节点类型决定,不能用页面上的全局开关:

outputType: target ? (target.tagName === 'IMG' ? 'img' : 'latex') : outputType.value

五、返回形式:LaTeX 还是 PNG?

SDK 支持两种返回,怎么选取决于下游环境:

outputType: 'latex'outputType: 'img'
返回标准 span,Shadow DOM 里渲染 SVG完整 <img> HTML,PNG data URL
体积几十字节一张几十 KB
放大矢量,无损位图,会糊
依赖展示端要装 SDK 并调 initFormulaRendering()任何系统都能显示
二次编辑直接读 data-latex读 data-latex(图片上也带着)

选 img 的典型场景是下游不可控:题库要同步到老后台、要发到第三方系统、要进微信富文本。那些地方不认你的 SDK,一张 PNG 最稳。

关键设计是图片也带 data-latex:

<img class="fe-formula-image" data-latex="\frac{1}{2}" src="data:image/png;base64,...">

所以「图片模式插入的公式」双击依然能进编辑器改,不是死图。

⚠️ 一个只在 TinyMCE 5 上出现的坑:它会自动把 data: 图片转成 blob URL,刷新后地址失效。要排除:

images_dataimg_filter: image => !image.classList.contains('fe-formula-image')

业务侧如果有「监听 img 自动上传」的逻辑,也要跳过 img.fe-formula-image。


六、正文里的 $x^2$:自动渲染,但不脏数据

实际内容里大量是接口直接返回的字符串,题干中混着 $...$。不可能让每个页面在 v-html 之前自己跑一遍替换。

做法是应用启动时开一个全局开关:

import { initFormulaRendering, vLatex } from 'formula-editor-modal'

app.directive('latex', vLatex)
void initFormulaRendering({ renderLatexText: true }).catch(console.error)

开了之后,用 MutationObserver 自动处理:当前页面、异步接口回来的内容、列表翻页、弹窗、Vue 的文本更新,全都不用改。

几条边界处理得比较细:

  • 跳过输入框、可编辑区、TinyMCE 正文、代码块、已有的公式节点和图片——否则会把用户正在写的东西改掉
  • 只改展示 DOM,不改接口对象、不改表单值。保存的永远是原始数据
  • 不解 HTML 属性,只处理文本节点
  • \$ 不当公式定界符
  • 一条公式必须落在同一个文本节点里(<span> 把公式拆开就不认)
  • 某个区域想保持源码,加 data-fe-latex-ignore

想只渲染某个区域,就别开全局,挂指令就行:

<div v-latex v-html="question.title"></div>
<section v-latex><QuestionDetail /></section>

错误是逐个公式隔离的:MathJax 抛错时,那一条保留原始 LaTeX(不显示成乱码、也不改写源码),同批后面的公式继续渲染。相同节点、相同源码的失败不会重试;改过 data-latex 才重试。这条很重要——一个畸形公式不应该让整页公式全灭。


七、踩坑清单

挑几个当时查了很久、症状又反直觉的。

1. Mathfield not mounted——表现是「编辑区整个不出现」

const mf = new MathfieldElement()
host.appendChild(mf)     // 必须先挂载
mf.value = 'x^2'         // 再赋值

反过来直接抛 Mathfield not mounted。这个错误发生在 onMounted 的 try 里,如果外面套了 catch,用户看到的现象是编辑区压根没出现,而不是报错弹窗。查了半天才定位。

2. 关闭函数别叫 close

function close() { emit('update:visible', false) }   // ❌ 静默失效

浏览器全局有 window.close(),裸的 close() 会被解析成它。不报错、什么也不发生,TypeScript 也不报(window.close 是合法的全局函数)。后果是「取消 / Esc / 点遮罩 / 点 ✕」四个入口一起失灵。

改名 requestClose() 就好了。同理别用 name、status、top、length、origin 这些 window 上已有的名字做顶层函数。

3. MathLive 自带的 ☰ / ⌨ 角标,外部 CSS 藏不掉

.ML__toggles 在 MathLive 的 shadow root 里,::v-deep 也穿不过去;:host 上暴露的 CSS 变量里也没有控制它们显隐的开关(mf.menuItems = [] 只能让菜单没条目,按钮还在)。

好在它用的是 attachShadow({ mode: 'open' }),可以直接注入:

mf.shadowRoot?.appendChild(
  Object.assign(document.createElement('style'), {
    textContent: '.ML__toggles{display:none !important}',
  }),
)

软键盘能力不受影响——自己放按钮调 window.mathVirtualKeyboard.show()/.hide()。注意它的 --keyboard-zindex 默认 10000,和弹窗遮罩同层会被压在下面:

math-virtual-keyboard { --keyboard-zindex: 10001; }

4. 卡片网格:grid-auto-rows: auto + overflow: hidden 会把内容压没

公式模板库是网格卡片。行高用默认 auto,卡片带 overflow: hidden,结果整排卡片被压扁,里面的公式被裁掉。

根因很隐蔽:卡片有 overflow: hidden 时,它的 min-content 高度会被算成 0,而 auto 行高要靠内容高度算——于是行被压扁(实测 99px 压成 35px)。

症状全是反的,特别容易查错方向:网格 scrollHeight === clientHeight,看起来「刚好放得下、不需要滚动」,其实卡片里是空的。

修法就是给死值:

.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
         grid-auto-rows: 100px; overflow-y: auto; align-content: start; }
.card-body { height: 56px; }   /* 公式区固定高,超出由缩略图等比缩小 */

5. 小尺寸预览要用 textstyle

模板卡片里渲染公式别写 mode="displaystyle":它会把 \sum、\lim 的上下限排到符号上下方,几十像素高的卡片放不下就被裁。默认的 textstyle 把上下限排在右侧,小尺寸反而清楚。

6. math-span 的 textContent 是 LaTeX 源码

MathLive 的静态元素把源码留在 light DOM 的文本节点里(视觉上被 slot 藏住了)。所以:

button.textContent   // → "\begin{matrix}\square&...矩阵"

这会直接进按钮的可访问名,读屏软件念一串 LaTeX。必须显式给 aria-label:

<button :aria-label="tab.label">
  <math-span aria-hidden="true">{{ tab.icon }}</math-span>{{ tab.label }}
</button>

顺带的教训:用 textContent 断言 UI 文案会误报,要断言就用 getAttribute('aria-label')。

7. 缩略图量尺寸要用 offsetWidth

卡片里的公式超出要等比缩小。量尺寸必须用 offsetWidth/offsetHeight(布局尺寸),不能用 getBoundingClientRect()(返回缩放后的尺寸)——用后者会「缩一次 → 量到更小 → 再缩」,越缩越小。

而且不能只量一次:MathLive 渲染完、KaTeX 字体到位都会改布局尺寸。

new ResizeObserver(fit).observe(el)
el.addEventListener('render', fit)   // 静态元素渲染完成时派发(不冒泡)

八、体积:800 KB 的引擎不该进主包

MathLive 打完 800 KB,MathJax 1652 KB。两个都静态 import,首屏就废了。

这个项目 build 完的实际产物(Vite 默认分包):

chunk原始gzip什么时候加载
主包(Vue + TinyMCE + 业务)1095 KB384 KB首屏
MathJax1652 KB595 KB页面里出现公式时
MathLive800 KB221 KB第一次打开弹窗
KaTeX 字体341 KB256 KB第一次打开弹窗
CSS98 KB18 KB首屏

主包大是 TinyMCE 的锅,不是公式的锅。真正要守住的是最后两行:只读页面(题干展示、试卷预览、答案解析)压根不加载 MathLive 和那套字体——它们只用于编辑,不该为看的人付钱。

引擎本身做两件事:

let modulePromise: Promise<typeof import('mathlive')> | null = null

export function loadMathLive() {
  modulePromise ??= import('mathlive')
    .then((mod) => {
      const MF = mod.MathfieldElement
      MF.fontsDirectory = null    // 断掉 CDN 字体,用本地 fonts.css 声明的
      MF.soundsDirectory = null   // 默认按键音效也走 CDN,直接关
      MF.locale = 'zh-cn'         // 语言包内置在 bundle 里,不发请求
      return mod
    })
    .catch((err) => { modulePromise = null; throw err })   // 失败可重试
  return modulePromise
}

缓存 Promise,多个组件共用同一次加载;失败时清空,允许重试。三个 null 是把 MathLive 默认的外链全部掐掉。

再有就是告诉 Vue 编译器这些不是组件,否则会刷一片警告:

vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('math-') } } })

配完之后页面零外部请求:字体本地、语言包内置、音效关掉,内网和离线环境都能跑。TinyMCE 也是同样的处理——npm 包本地加载,skin: false + content_css: false + 手动引 skin CSS,不碰 Tiny Cloud 的 API Key,也不依赖 CDN。


九、能力边界(说清楚,别让人踩坑)

  • Word/PDF 导出对 Shadow DOM 的支持要单独验证。浏览器端 SVG 在 shadow root 里,innerHTML、cloneNode 都拿不到。SDK 提供了 Node 入口,在服务端重新渲染:

    import { renderLatexSvg, splitLatexText } from 'formula-editor-modal/server'
    const html = renderLatexSvg('\\frac{1}{2}')   // 自包含的 MathJax SVG
    const parts = splitLatexText('题干 $x^2$')     // 拆正文和公式
    

    库里的数据格式不用改。

  • 图片模式会增加存储体积。一张 PNG 几十 KB,接口有长度限制的话得业务侧另外上传。

  • 源码丢了就救不回来。MathJax 渲染失败时节点会保留原始 LaTeX 文本,但如果哪天被人为清空了 data-latex,那条公式只能重打。

  • 自动渲染只改展示 DOM。别把渲染后的 DOM 当保存内容提交,要存原始数据或走序列化流程。

序列化有两个方向,对应两种导出:

// 保留公式节点(HTML 导出 / 存库)
const html = serializeFormulaContent(editor.getContent())

// 公式节点转 $...$(LaTeX 导出)
const latex = serializeFormulaLatex(editor.getContent())
// <p>求 $x^2$ 的值</p>

十、做出来的东西长什么样

弹窗照 Mathpix 的形态做的:宽高随视口自适应(1440 视口下实测 980×580),圆角 12px,遮罩 50% 黑,关闭按钮故意探出面板右上角 14px,主色 #2563eb。

结构自上而下:头部(标题 + 学科分段)→ 分类标签 → 内容区(公式卡片 / 符号面板)→ 大编辑区 → 底部操作条(取消 / 清空 / 确认插入)。

弹窗转存失败,建议直接上传图片文件

符号库对齐了 Mathpix 的逐 tab 数量,一共 476 个:

tab数量tab数量
常用35矩阵23
运算46上下标46
关系44大型运算41
箭头66函数25
希腊132双曲三角函数18

「希腊」是 132 而不是 24:54 个希腊字母(24 大写 + 24 小写 + 6 个变体 ϵ ϑ ϖ ϱ ς ϕ)+ \mathbb{A}–\mathbb{Z} + \mathcal{A}–\mathcal{Z} + 26 个斜体 a–z。

对齐数量这件事,比「我有的都能渲染」有意义得多。后者是自证,前者才能发现「该有的缺了」。 顺手把整批 LaTeX 的合法性也校验了——MathLive 渲染失败会派发 render-error 事件,它不冒泡,所以在文档最开始用捕获阶段挂监听,把每个 tab 点一遍,errors 为空就是全合法:

document.addEventListener('render-error', (e) => errors.push(e.target.textContent), true)

另外两个细节:

  • 分类图标直接用 Unicode 数学符号(∑ ∫ ∮ ⇌ ▦ α),比引图标字体省事,字体回退也稳。避开 emoji 区的(⚛ ⚗ 在部分环境渲染成方框)。
  • 矩阵 / 分段函数 / 多行对齐共用一个 5×5 行列选择器,插入的就是可填写的格子,不是死模板。

十一、这套东西适合谁

如果你的场景符合下面任意一条,这套方案基本能直接搬:

  • 富文本里要写公式,且公式要能被再次编辑。题库、教案、试卷、实验报告、在线作业,都算。
  • 公式要能离开你的系统。导出 Word、生成 PDF、同步到第三方平台——存 LaTeX 才能在各处重新渲染,存图只能糊。
  • 下游环境不可控。这时候用 outputType: 'img',一张自带 data-latex 的 PNG,哪都能显示,回来还能改。
  • 需要离线或内网部署。零外部请求,字体和语言包都在包里。
  • 公式量大、要能搜。存源码而不是存图,LIKE '%\frac%' 都能搜。

反过来,如果只是「展示几道题、永远不用编辑」,别上编辑器,直接用 MathJax 渲染 $...$ 就够,省下 800 KB。


最后

回头看,这个需求真正的难度不在「公式怎么渲染」——那部分交给 MathLive 和 MathJax 就行。难的是三件不显眼的事:

  1. 一致性:录入时看到的,必须和正文里、导出时看到的一模一样。所以预览也要用 MathJax,哪怕它更严格。
  2. 往返无损:任何一次编辑都不能悄悄改掉用户的源码。选型时这一条比功能多寡更重要。
  3. 光标:富文本里插入一个原子节点,撤销栈、书签、选区还原,每一个都能单独出 bug。

如果你也在做类似的东西,希望这几条坑能帮你省两天。

在线体验:liliyang857.github.io/math-latex-…