Markdown に数式を混ぜると壊れる
左に Markdown を打つと右にプレビューが出る、というだけのエディタを自分用に作った(katex.tecktom.com)。Markdown の変換に marked、数式の組版に KaTeX を使っている。この2つを同じ画面に置くと、素直につなぐだけでは数式が崩れる。この記事では、その原因と、私が実装で引っかかった箇所を書く。
何が崩れるか
まず、次の2つが崩れる。
$a*b*c$が$a<em>b</em>c$になる(*で囲まれたbが強調と読まれる)$x_1 + x_2$の添字が斜体になる(_が同じく強調と読まれる)
書いた側が期待しているのは と である。
marked は Markdown の実装であって、数式を知らない。だから数式の中に書いた _ や * も Markdown の記法として読む。KaTeX 側の auto-render はプレビューの DOM を走査して $…$ を探すので、そこへ回ってきた時点で数式は既に別物になっている。
変換のあとで戻す、という方法は通らない
「marked に通してから、数式の部分だけ元へ戻す」という順番を最初に考えた。これは innerHTML の文字列置換では通らない。$a<b$ のような数式があると、< がタグの始まりとして読まれて、そこから後ろの構造が変わってしまう。
戻す先を DOM のテキストノード(nodeValue)にすれば、この問題は起きない。テキストノードの中身は HTML として解釈されないので、< は文字のまま残る。
数式を先に抜き、トークンへ退避する
実際に採った順番は、数式を先に抜く方法である。
- 入力を頭から走査し、区切りで囲まれた数式を配列へ退避して、その場所に目印の文字列(占位トークン)を置く
- 残りを marked に通す。トークンは英数字だけなので、Markdown の記法とも読まれず、HTML エスケープもされない
- 出来上がった DOM のテキストノードの中で、トークンを数式の文字列へ戻す
- KaTeX の auto-render に渡して組む
ここで、トークンの綴りを固定にすると本文が壊れる。読者が地の文に同じ綴りを書くと、3 の一括置換がその文字を数式と見なして書き換えてしまう。エディタの仕組みを説明する文章、つまりこの記事のような文で現実に起こる。だから綴りには1描画ごとの乱数を混ぜて、書き手が予測できないようにした。
この記事を組んでいるこのサイトのビルダも、同じ作りで同じ固定の綴りを使っていた。記事を書きはじめて自分で踏んだので、あわせて直した。
退避の境界でつまずいた4つ
数式をどこからどこまでと見るか、という判断に、思ったより多くの場合分けが要る。
コードの中のドル記号
`a$b` と書いたとき、このバッククォートの中の $ を数式の開きと見なすと、同じ行の後ろにある本物の数式 $x$ の $ と誤ってペアになる。結果として、閉じのバッククォートごと数式へ飲み込まれて、コードも数式も表示されなくなる。
対策として、marked と同じ規則でコードの範囲を先に測り、その範囲は中身を触らずに写している。CommonMark のインラインコードは「開いたバッククォートと同じ長さの列で閉じる」という規則で、閉じが無ければコードではない。バッククォート3つで囲むフェンスの方は、閉じが無ければ文書の末尾までコードになる。
通貨のドル記号
Cost $5 and $x$ のような文では、最初の $ と2つ目の $ がペアになって「5 and 」が数式として組まれてしまう。ここは Pandoc の規則に合わせた。開きの $ の直後が空白なら数式ではない、閉じの $ の直前が空白なら数式ではない、閉じの $ の直後が数字なら通貨である、の3つである。
このとき、数字で始まる数式を数式でないと判断してはいけない。$2\pi$ や $3x^2$ は理数系の文章では普通に出てくる。私の最初の実装は「開きの直後が数字なら通貨」という規則を入れていて、これらを何も言わずに未描画にしていた。通貨との切り分けは、開き側ではなく閉じ側の条件で足りる。
数式ではないと判断した $ も、そのまま残しておくと後段の auto-render がプレビューをもう一度走査して、そこで誤ってペアを作る。そのため、文字としての $ も別のトークンへ退避しておき、auto-render が終わってから $ の文字へ戻している。
エスケープしたドル記号
CommonMark は \$ を文字としてのドルと読む。この規則に合わせないと、読者に $x$ という書き方をそのまま見せたいときの \$x\$ が数式として組まれてしまう。marked と自分の走査で読み方が違うと、こういう形で食い違いが出る。
走査の計算量
閉じの無い区切りに出会うたびに文書の先頭から探し直すと、処理時間が入力長の2乗になる。閉じない \[ が並ぶ 120KB の下書きで 45.9 秒かかった。
走査の位置は前へ進むだけなので、ある位置で閉じが見つからなかった区切りは、それより後ろでも見つからない。一度そう分かった区切りは以後探さないようにすれば、意味を変えずに1回の走査で済む。
同じ形のものがもう1か所にあった。閉じの無いバッククォートの列を、長さ n で失敗したら n−1 で試し、さらに n−2 で試す、という書き方をしていて、8万字で 8.5 秒かかっていた。こちらは列ごと先へ送るようにした。CommonMark でもコードの開きは最長のバッククォート列なので、列をまとめて送る方が marked の挙動とも合う。
この2つは、性能の問題というより復帰できなくなる問題である。このエディタは下書きをブラウザに保存するので、45 秒かかる原稿が保存されると、次に開いたときも同じ時間だけ固まる。その間は「クリア」のボタンも押せない。
数式を組む側の落とし穴
\overbrace{ を150段重ねただけの 1,805 字で、Chromium のレンダラプロセスが落ちる。KaTeX は入れ子1段につき10層から20層の span を作るので、DOM が深くなりすぎることが原因である。\overbrace に固有の話ではなく、\underbrace・\sqrt・\frac・\vec など8種すべてで、深さ100 は正常に描けて深さ150 で落ちた。
こちらも、落ちること自体より、その文字列が下書きに残ることの方が問題になる。デバウンスの120ミリ秒が過ぎる前にタブを閉じると、描画を通らずにその文字列が保存される。次に開くと復元の途中で落ちる。新しいタブで開いても同じところで落ちるので、そのページは開けなくなり、復旧の手段は開発者ツールかサイトデータの削除だけになる。
直し方は2つ入れた。1つは、KaTeX へ渡す前に入れ子の深さを数えて、上限を超える式は数式として扱わないこと。ここで地の文へ戻すだけでは、後段の auto-render が同じ式をもう一度 KaTeX へ渡してしまうので、数式とは別のトークンへ退避して、auto-render が終わってから文字列として表示している。深さは波括弧だけでなく \left…\right と \begin…\end も1段として数える。波括弧だけを数えると、同じ型の入力で同じように落ちる。
もう1つは、描画の入口で「今から描く」という印を保存して、最後まで描けたら消すこと。次に開いたときにその印が残っていれば、前回は描画の途中で落ちたと分かる。そのときは下書きを残したまま、描画だけ見送る。深さの上限で防げない別の原因に対する備えとして置いてある。
あわせて、KaTeX の maxSize と maxExpand も指定した。既定では maxSize に上限が無いので、\rule{99999em}{99999em} と1行書くだけでページの幅が 1,815,144px になり、画面が使えなくなる。
貼り付けられた本文は他人の入力である
marked は本文に書かれた生の HTML をそのまま出力する。自分だけで使うなら困らないが、公開して他人に Markdown を貼ってもらう形にすると、<script> や、onerror を仕込んだ <img> がそのまま動く。そこで、組んだ HTML を DOMPurify に通してから DOM へ入れている。数式はこの時点でトークンへ退避済みなので、サニタイズの対象にならない。
ここで <style> タグを禁止しても、style 属性を許したままだと足りない。<div style="position:fixed;inset:0;z-index:99999"> を1つ貼れば画面全体を覆えるので、偽のログイン画面を重ねることができる。そのため属性の方も取り除いている。KaTeX が数式に付ける style="height:…" はサニタイズが終わったあとの工程で付くので、数式の見た目は変わらない。
実物
上で書いたものは katex.tecktom.com で動いている。1枚の HTML ファイルなので、ブラウザの表示ソースがそのまま実装になっている。コードを読むならそちらを見てほしい。自作の部分は MIT ライセンスで、同梱している KaTeX・marked・DOMPurify はそれぞれのライセンスをページから読めるようにしてある。
← 記事の一覧へ