ReactでKaTeXを使って、数式をきれいに表示する方法

​​React​KaTeX,TypeScript

KaTeX は、\frac{1}{2} のような TeX の式を、ブラウザで表示できる HTML に変えるライブラリです。React では、KaTeX が返す HTML の文字列を、小さなコンポーネントで包んで表示できます。

文中の数式とブロックの数式を出すコンポーネントを TypeScript で書き、Chromium で動かしました。CSS を読み込まなかったときの崩れ方、書き間違えた式の扱い、利用者が入力した式の表示、スマートフォンの幅での横に長い式、読み込むファイルの大きさを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは katex 0.19.1、react / react-dom 19.3.1、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild でまとめ、表示の幅 375px で確かめました。esbuild 以外のバンドラー、Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げ、サーバーでの描画(SSR や Server Components)、Markdown の中の数式を表示する方法は試していません。

KaTeX を入れて、CSS も読み込む

bash
npm install katex

katex 0.19.1 には型定義が同梱されていて、@types/katex を足さなくても tsc の型チェックが通りました。

KaTeX は、JavaScript だけでは数式を正しく表示できません。CSS の katex.min.css と、そこから読み込まれる専用のフォントが必要です。アプリの入り口で CSS を読み込みます。

tsx
// main.tsx
import 'katex/dist/katex.min.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

katex.min.css の中では、フォントのファイルが url(fonts/KaTeX_Main-Regular.woff2) のような相対パスで書かれています。esbuild でまとめるときは、--loader:.woff2=file --loader:.woff=file --loader:.ttf=file を付けてフォントをファイルとして出力させました。

CSS を読み込まないと、どうなるかも試しました。下の画像の上の段が、CSS を読み込まずに二次方程式の解の公式を表示したところです。

上の段は、CSS を読み込まないときの表示。分数の形の式の下に「x = 2a-b ± b2 - 4ac」という崩れた文字列と、その下に横に長く伸びた根号の線が出ている。下の段は、CSS を読み込んだときの表示で、x イコール 2a 分のマイナス b プラスマイナス ルート b の2乗 マイナス 4ac の式が、教科書のような書体で1つだけ表示されている
katex.min.css を読み込まないとき(上)と、読み込んだとき(下)の同じ式(表示の幅375px)

CSS がないと、同じ式が2回出ます。画像の上の段では、上にある分数の形の式が、KaTeX が一緒に出力する MathML をブラウザがそのまま描いたものです。その下の崩れた文字と根号の線は、見た目用の HTML が崩れたものです。CSS を読み込むと、MathML の部分は position: absolute、幅 1px になり、画面に出る式は1つになります。式が崩れたり二重に出たりしたときは、まず CSS が読み込まれているかを確かめてください。公式ドキュメントのよくある問題のページにも、CSS が読み込まれているかを確かめる方法が載っています。

フォントは、ビルドすると woff2・woff・ttf が 20 個ずつ、合わせて 60 個出力されました。ブラウザが読み込んだのは、そのうちの woff2 の一部だけです。文中とブロックの数式、横に長い式、入力欄、\href や HTML のタグを含む入力を並べた検証用のページでは、KaTeX_Main-Regular、KaTeX_Math-Italic、KaTeX_Size1-Regular、KaTeX_Size2-Regular の4つでした。

数式を表示するコンポーネントを書く

katex.renderToString は、TeX の式を受け取り、HTML の文字列を返します(公式ドキュメントの API の説明)。それを dangerouslySetInnerHTML で入れるコンポーネントにしました。

tsx
// Tex.tsx
import katex from 'katex';
import { useMemo } from 'react';

type Props = { tex: string; display?: boolean };

type Result = { html: string } | { error: string };

export function Tex({ tex, display = false }: Props) {
  const result = useMemo<Result>(() => {
    try {
      return { html: katex.renderToString(tex, { displayMode: display }) };
    } catch (e) {
      if (e instanceof katex.ParseError) return { error: e.message };
      throw e;
    }
  }, [tex, display]);

  if ('error' in result) {
    return (
      <code className="tex-error" title={result.error}>
        {tex}
      </code>
    );
  }

  const Tag = display ? 'div' : 'span';
  return <Tag dangerouslySetInnerHTML={{ __html: result.html }} />;
}

useMemo で、tex と display が変わったときだけ HTML を作り直します。書き間違いのときの try / catch は、次の節で説明します。

使うときは、文の中なら display を付けずに、独立した行に大きく出すなら display を付けます。

tsx
<p>
  円の面積は <Tex tex={String.raw`\pi r^2`} /> で求められます。
</p>
<Tex display tex={String.raw`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`} />

TeX の式はバックスラッシュを多く使います。普通の文字列では "\\frac" のように2つ重ねる必要がありますが、String.raw のテンプレートリテラルなら、そのまま書けます。この書き方は、公式ドキュメントの API のページでも紹介されています。ただし、String.raw を使うと ${ とバッククォートをエスケープできないことも、同じページに注意として書かれています。

検証環境で表示すると、文の中の式は <span class="katex"> になり、文字の大きさはまわりの 16px に対して 19.36px(1.21倍)でした。公式ドキュメントのフォントのページには、上付き・下付きの文字を読みやすくするため 1.21 倍で表示すること、.katex { font-size: 1.1em; } のような CSS で変えられることが書かれています。

display を付けた式は <span class="katex-display"> で包まれ、中央に寄せて表示されました。同じ \sum_{k=1}^{n} k = \frac{n(n+1)}{2} の式でも、文の中では高さ 22px、ブロックでは 57.2px でした。オプションの説明にあるとおり、ブロックの数式では \sum の上下に添え字が付き、記号も大きくなります。

KaTeX は、見た目用の HTML と一緒に MathML も出力します(output オプションの既定値 htmlAndMathml)。公式ドキュメントでは、MathML はアクセシビリティのために入れているとされています。出力された HTML では、見た目用の部分に aria-hidden="true" が付いていました。

書き間違いは、元の式のまま赤く出す

KaTeX は、式を読み取れないと katex.ParseError を投げます(エラーの扱いの説明)。閉じかっこが1つ足りない \frac{1}{2 を渡すと、次のメッセージで例外になりました。

text
KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \frac{1}{2

React のコンポーネントの描画中に例外が出ると、Error Boundary で受け止めない限り、画面の表示が消えます(React の公式リファレンスの Error Boundary の説明)。そこで Tex では ParseError だけを受け止め、元の式を <code className="tex-error"> で出し、メッセージは title 属性に入れました。title はマウスを重ねたときに出るものなので、スマートフォンでは見えません。赤い文字にするのは、次の CSS です。

css
.tex-error {
  color: #cc0000;
}

<code> の中なので、ブラウザの既定の表示で等幅の文字になります。

入力欄に打った式をその場で表示するページで試したところ、書きかけの式は赤い文字のまま表示され、閉じかっこを足した時点で数式に変わりました。ブラウザのコンソールにエラーは出ていません。

上の段は、入力欄に閉じかっこが1つ足りない式を入れたところで、「\frac{1}{2」という文字列が赤い等幅の文字で表示されている。下の段は、最後に閉じかっこを足したところで、2分の1の分数が表示されている
書き間違えた式は赤い文字で、直すと数式で表示される(表示の幅375px)

公式ドキュメントでは、エラーのメッセージには TeX の式の一部がそのまま入るため、HTML に入れるときは & や < をエスケープするよう注意しています。Tex では、式もメッセージも JSX の子や属性として渡していて、HTML として入れてはいません。入力欄に \frac{<img src=x onerror=alert(1)> と書きかけの式を入れたところ、その文字列が赤い文字のまま表示され、ページに <img> 要素は作られませんでした。

同じことは、throwOnError: false のオプションでもできます。この場合は例外にならず、式がそのまま #cc0000 の色の <span class="katex-error"> で出力され、メッセージは title 属性に入りました。ただ、\fracc{1}{2} のように知らない命令が入っているときは、エラーにはならず、\fracc だけを赤くして、残りの {1}{2} は 12 という数字として出力しました(見た目用の HTML の文字は \fracc12)。書き間違いをはっきり見せたいときは、ParseError を受け止める書き方のほうが分かりやすいです。

利用者が書いた式を表示するときの注意

コメント欄のように、利用者が書いた式を表示することもあります。dangerouslySetInnerHTML を使うので、気になるのは HTML を差し込まれないかです。公式ドキュメントのセキュリティのページでは、KaTeX が作る HTML は <script> などを差し込まれないものになっている、とされています。

<img src=x onerror=alert(1)> という文字列を Tex に渡したところ、< や = は数式の記号として並べられ、ページに <img> 要素は作られませんでした。alert のダイアログも開いていません。

リンクを作る \href のように、外部のファイルを読み込んだり HTML の属性を変えたりする命令は、trust オプションで許可しない限り使えません。既定のまま \href{javascript:alert(1)}{\text{click}} を表示すると、リンクは作られず、\href という赤い文字だけが出ました。リンクを使いたいときは、trust に関数を渡して、許可する命令とプロトコルを絞れます。

ts
import katex from 'katex';

export function renderWithLinks(tex: string): string {
  return katex.renderToString(tex, {
    trust: (ctx) => ctx.command === '\\href' && ['http', 'https'].includes(ctx.protocol ?? ''),
  });
}

型定義では ctx.protocol が省略できる値(string | undefined)なので、?? '' を付けないと tsc の型チェックが通りません。この関数を渡すと、https://example.com/ へのリンクは <a href="https://example.com/"> になり、javascript: のリンクは赤い \href のままでした。trust: true にすると、javascript:alert(1) のリンクもそのまま作られました。利用者が書いた式に trust: true を使うのは避けてください。

ほかに、セキュリティのページでは maxSize と maxExpand のオプションが挙げられています。\def\a{\a}\a のように自分自身を呼ぶマクロは、既定の maxExpand(1000回)で止まり、Too many expansions の ParseError になりました。\rule{1000em}{1em} のような大きな線は、既定では幅 1000em のまま出力され、maxSize: 10 を付けると幅 10em に抑えられました。

スマートフォンで横に長い式をスクロールさせる

ブロックの数式は、KaTeX が自動では折り返しません。幅 375px の画面で (a+b)^5 を展開した長い式を表示すると、式の幅がはみ出して、ページ全体が横にスクロールする状態(ページの幅が 438px)になりました。

公式ドキュメントのよくある問題のページにある CSS を足すと、式の部分だけを横にスクロールさせられます。

css
.katex-display {
  overflow: auto hidden;
}

この CSS を入れると、ページの幅は画面と同じ 375px に収まりました。式の枠の幅は 343px で、中身の幅は 422px あり、はみ出した分は枠の中で横にスクロールします。

読み込むファイルの大きさ

本番用ビルド(esbuild の minify あり)で、ブロックの数式を1つだけ表示するページを作り、ブラウザが読み込んだファイルを測りました。比べる相手は、同じ式を普通の文字で書いた、React だけのページです。gzip の大きさは、JS と CSS を Node.js の zlib(レベル 9)で圧縮した値で、woff2 はそのままの大きさを足しています。

ページ 読み込んだファイル 大きさ gzip
React だけ JS 1つ 223,504 バイト 69,136 バイト
KaTeX で表示 JS・CSS・woff2 2つ 560,695 バイト 192,736 バイト

KaTeX を使うページの内訳は、JS が 492,782 バイト、CSS が 25,201 バイト、フォントが KaTeX_Main-Regular の 26,272 バイトと KaTeX_Math-Italic の 16,440 バイトでした。JS は、React だけのページより 269,278 バイト増えています。

​Posted by 86