KaTeX から SVG に変換する

KaTeX で組んだ数式を、外部ファイルに依存しない SVG として保存するための実装メモ。 ここでは KaTeX の HTML 出力を foreignObject で保持しつつ、KaTeX の DOM から「文字ごとの使用フォント」を解析し、必要なグリフだけを WOFF2 として SVG に埋め込む。

1. KaTeXをSVGとして保存することについて

数式をスライド、印刷用の素材、Canvas、画像変換パイプラインなどへ渡したい場合、HTML ページから独立した SVG があると扱いやすい。 KaTeX の通常出力は HTML、CSS、Web フォントの組み合わせなので、そのままでは表示環境の CSS とフォント配信に依存する。 そこで、レンダリング済みの KaTeX DOM と必要な CSS、サブセット化したフォントを SVG 内に閉じ込める。

ただし、理由がない場合は MathJax を使うのを推奨する。 MathJax は SVG 出力を直接持っており、数式を SVG として扱う用途では自然な選択肢になる。 この記事の方法は、KaTeX の高速な HTML レンダリングや既存の KaTeX ベースの表示結果を、そのままスタンドアロン SVG にしたい場合のためのもの。

ここで作る SVG は、文字を <path> に変換した純粋なベクターではない。 foreignObject 内に HTML を入れるため、ブラウザ表示には向く一方、PDF 変換ツールや一部の SVG レンダラでは欠落する場合がある。

2. 全体の処理フロー

単に「数式に含まれる文字」を全部の KaTeX フォントに対してサブセット化すると、スペースなどの共通文字に反応して、ほぼすべてのフォントが対象になってしまう。 サブセットフォントにもヘッダーや基本テーブルのオーバーヘッドがあるため、20 個の KaTeX フォントを少しずつ埋め込むだけで大きくなる。 重要なのは、文字集合ではなく「この文字はこのフォントで描かれる」という対応を作ること。

  1. KaTeX で数式を HTML としてレンダリングし、表示用の .katex-html を得る。
  2. DOM ツリーを走査し、テキストノードごとに親要素のクラス階層からフォントファミリーとスタイルを判定する。
  3. フォントキーごとに Unicode コードポイントの集合を作る。
  4. KaTeX の WOFF2 を TTF に展開し、該当フォントだけをグリフ単位でサブセット化する。
  5. 不要テーブルを落とし、Brotli 圧縮レベル 11(WOFF2)で再圧縮する。
  6. unicode-range 付きの @font-face と KaTeX CSS を SVG の <defs> に入れる。
  7. foreignObject に KaTeX の HTML 構造を入れ、最後に CSS と SVG を minify する。

3. foreignObjectによる埋め込みの方法

分数、指数、根号、行列、大きな演算子などの二次元レイアウトを自前で SVG の <text> に戻すのは難しい。 そこで、KaTeX が生成した HTML レイアウトをそのまま foreignObject に入れる。 レイアウト計算はブラウザの HTML/CSS エンジンに任せる。

※以下のコード例は説明用の簡略版である。

function buildSvg({ container, subsetWoff2Parts, paddingX, paddingY }) {
  // foreignObject の外側は通常の SVG として扱える。
  // ここでは KaTeX HTML の周囲に余白を足したサイズを SVG 全体のサイズにする。
  const containerRect = container.getBoundingClientRect();
  const width = Math.ceil(containerRect.width + paddingX * 2);
  const height = Math.ceil(containerRect.height + paddingY * 2);

  // サブセット化した WOFF2 を Data URL にし、SVG 内の style に閉じ込める。
  // unicode-range を付けることで、同じ font-family 名のまま必要な文字だけを読ませられる。
  const fontFaces = subsetWoff2Parts.map((part) => {
    const { weight, style } = getFontStyleAndWeight(part.subfamilyName);

    return `
      @font-face {
        font-family: "${part.familyName}";
        src: url("data:font/woff2;base64,${bytesToBase64(part.bytes)}") format("woff2");
        font-weight: ${weight};
        font-style: ${style};
        unicode-range: ${part.unicodeRange};
      }`;
  }).join("\n");

  // KaTeX CSS に含まれる CDN フォント定義は使わない。
  // ここで作った Data URL 版 @font-face に置き換える。
  const cleanKatexCss = katexCss.replace(/@font-face\s*\{[\s\S]*?\}/g, "");
  const minifiedCss = minifyCss(cleanKatexCss + "\n" + fontFaces);

  // foreignObject の中だけ XHTML 名前空間にする。
  // これがないと SVG XML として解釈されたときに HTML レイアウトにならない。
  return minifySvg(`<svg xmlns="http://www.w3.org/2000/svg"
      width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
    <defs><style>${minifiedCss}</style></defs>
    <rect width="100%" height="100%" fill="#fff"/>
    <foreignObject width="100%" height="100%">
      <div xmlns="http://www.w3.org/1999/xhtml"
          style="padding:${paddingY}px ${paddingX}px;box-sizing:border-box;display:inline-block;">
        ${container.closest(".katex").outerHTML}
      </div>
    </foreignObject>
  </svg>`);
}

実装では .katex-mathml を除去し、KaTeX が根号・伸縮アクセント・打ち消し線などに使う内側の <svg> には SVG 名前空間を明示する。 また、HTML 内の &nbsp;, &middot;, &times; は XML として安全な数値文字参照へ置き換える。 SVG は XML なので、HTML では通る実体参照がそのままでは壊れることがある。

4. KaTeXのDOMクラスから「文字ごとの使用フォント」を判定する

KaTeX の .katex-html には、文字の種類に応じたクラスが付く。 例えば .mathnormalKaTeX_Math-Italic.amsrm.mathbbKaTeX_AMS-Regular、大型の積分記号は .op-symbol.large-op から KaTeX_Size2-Regular と判定できる。

ここでは主要なクラスとフォントキーの対応を示す。

DOMクラスフォントキー用途
.mathnormalKaTeX_Math-Italic変数、ギリシャ文字など
.mathbfKaTeX_Main-Bold太字の本文系文字
.amsrm, .mathbbKaTeX_AMS-RegularAMS記号、黒板太字
.mathcalKaTeX_Caligraphic-Regularカリグラフィ
.mathfrakKaTeX_Fraktur-Regularフラクトゥール
.delimsizing.size1KaTeX_Size1-Regular伸縮括弧
.op-symbol.large-opKaTeX_Size2-Regular積分などの大型演算子

※以下のコード例は説明のための簡略版である。改行コードの除外や、より網羅的なクラス判定を含む完全な実装は、ソースコードを参照のこと。

function collectFontToCharactersMap(katexHtmlRoot) {
  const fontToCharactersMap = new Map();

  function traverse(node) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.nodeValue;
      if (!text) return;

      const fontInfo = getFontForElement(node.parentElement);
      const key = `${fontInfo.family}-${fontInfo.style}`;

      if (!fontToCharactersMap.has(key)) {
        fontToCharactersMap.set(key, new Set());
      }

      for (const char of text) {
        fontToCharactersMap.get(key).add(char.codePointAt(0));
      }
      return;
    }

    if (node.nodeType === Node.ELEMENT_NODE) {
      // KaTeX の根号罫線などに含まれる装飾用 SVG は文字フォントの対象外。
      if (node.tagName.toLowerCase() === "svg") return;

      for (const child of node.childNodes) {
        traverse(child);
      }
    }
  }

  traverse(katexHtmlRoot);
  return fontToCharactersMap;
}
function getFontForElement(element) {
  let family = "KaTeX_Main";
  let hasItalic = false;
  let hasBold = false;

  let current = element;
  while (current && !current.classList.contains("katex-html")) {
    const cl = current.classList;

    if (cl.contains("textit") || cl.contains("mathit")) hasItalic = true;
    if (cl.contains("mathbf")) hasBold = true;

    if (cl.contains("mathnormal")) {
      family = "KaTeX_Math";
      hasItalic = true;
    } else if (cl.contains("amsrm") || cl.contains("mathbb") || cl.contains("textbb")) {
      family = "KaTeX_AMS";
    } else if (cl.contains("mathcal")) {
      family = "KaTeX_Caligraphic";
    } else if (cl.contains("mathfrak") || cl.contains("textfrak")) {
      family = "KaTeX_Fraktur";
    } else if (cl.contains("mathtt") || cl.contains("texttt")) {
      family = "KaTeX_Typewriter";
    } else if (cl.contains("mathsf") || cl.contains("textsf")) {
      family = "KaTeX_SansSerif";
    } else if (cl.contains("delimsizing")) {
      if (cl.contains("size1")) family = "KaTeX_Size1";
      else if (cl.contains("size2")) family = "KaTeX_Size2";
      else if (cl.contains("size3")) family = "KaTeX_Size3";
      else if (cl.contains("size4")) family = "KaTeX_Size4";
    } else if (cl.contains("op-symbol")) {
      if (cl.contains("small-op")) family = "KaTeX_Size1";
      else if (cl.contains("large-op")) family = "KaTeX_Size2";
    }

    current = current.parentElement;
  }

  let style = "Regular";
  if (hasBold && hasItalic) style = "Bold Italic";
  else if (hasBold) style = "Bold";
  else if (hasItalic) style = "Italic";

  return { family, style };
}

5. 必要なグリフだけをWOFF2としてSVGに埋め込む

実装では GlyphtGlyphtContextWoffCompressionContext を使う。 KaTeX の WOFF2 をいったん TTF に展開し、フォントオブジェクトへ読み込んでから、フォントごとのコードポイント集合で font.subset() を実行する。

※以下のコード例は主要な処理の流れを示す簡略版である。エラーハンドリングや非同期タイムアウト処理を含む完全な実装は、ソースコードを参照のこと。

import {
  GlyphtContext,
  WoffCompressionContext,
} from "@glypht/core";

const fontContext = new GlyphtContext();
const compressionContext = new WoffCompressionContext(1);

// Vite の例。KaTeX の WOFF2 を URL としてすべて拾い、先に TTF へ展開しておく。
const fontUrlModules = import.meta.glob("/node_modules/katex/dist/fonts/*.woff2", {
  eager: true,
  query: "?url",
  import: "default",
});

const originalWoff2BytesList = await Promise.all(
  Object.values(fontUrlModules).sort().map(async (url) => {
    const response = await fetch(url);
    return new Uint8Array(await response.arrayBuffer());
  }),
);

const decompressedFontBytesList = await Promise.all(
  originalWoff2BytesList.map((bytes) => {
    return compressionContext.decompressToTTF(bytes.slice(), { transfer: true });
  }),
);
async function subsetFonts(fontToCharactersMap, fontWeight) {
  const fonts = await fontContext.loadFonts(
    decompressedFontBytesList.map((bytes) => bytes.slice()),
    { transfer: true },
  );

  const subsetWoff2Parts = [];

  for (const font of fonts) {
    const key = `${font.familyName}-${font.subfamilyName}`;
    if (!fontToCharactersMap.has(key)) continue;

    const fontCodePoints = Array.from(fontToCharactersMap.get(key)).sort((a, b) => a - b);

    const subset = await font.subset({
      // 可変フォントの場合に備えて wght 軸を指定する。
      // KaTeX の通常フォントでは固定値として扱われる。
      axisValues: [{
        type: "single",
        tag: "wght",
        value: fontWeight,
      }],
      unicodeRanges: {
        named: [],
        custom: fontCodePoints,
      },
      // SVG 表示に不要なテーブルは落とす。
      // glyph と layout に必要なテーブルは Glypht 側が保持する。
      dropTables: ["DSIG", "gasp", "hdmx", "LTSH", "VDMX", "META", "MVAR", "PCLT"],
      preprocess: true,
    });

    if (!subset?.data || subset.data.length === 0) continue;

    const subsetWoff2 = await compressionContext.compressFromTTF(subset.data, {
      // subset() の戻り値は TTF。スタンドアロン SVG に入れる前に WOFF2 へ戻す。
      algorithm: "woff2",
      level: 11,
      transfer: true,
    });

    subsetWoff2Parts.push({
      bytes: subsetWoff2,
      unicodeRange: fontCodePoints.map((c) => `U+${c.toString(16).toUpperCase()}`).join(", "),
      familyName: font.familyName,
      subfamilyName: font.subfamilyName,
    });
  }

  for (const font of fonts) {
    await font.destroy();
  }

  return subsetWoff2Parts;
}

ここでのポイントは、サブセット化の対象を「そのフォントで実際に描かれる文字」に限定すること。 unicode-range も同じ文字集合に合わせて付けるため、SVG 内では KaTeX 本来の font-family 名を保ったまま、ブラウザが該当文字だけをサブセットフォントから読む。

6. CSSおよびSVGの最小化

フォントを小さくしても、KaTeX CSS をそのまま入れるとそれだけで大きい。 KaTeX CSS には、今回の式では使わないレイアウト、サイズ、アクセント、環境用のセレクタが多く含まれる。 そのため、外部フォント用の @font-face を取り除き、生成された KaTeX DOM にマッチするルールだけを CSSOM で残す。 そのうえで、生成した Data URL 版 @font-face を足してから minify する。

function pruneKatexCss(css, katexRoot) {
  const style = document.createElement("style");
  style.textContent = css;
  document.head.appendChild(style);

  try {
    return Array.from(style.sheet.cssRules).flatMap((rule) => {
      if (rule.type !== CSSRule.STYLE_RULE) return [];

      // カンマ区切りの selectorText を分解し、
      // 実際の KaTeX DOM に当たるセレクタだけを残す。
      const selectors = rule.selectorText.split(",").map((selector) => selector.trim());
      const usedSelectors = selectors.filter((selector) => {
        const normalized = selector.replace(/::?(before|after)/g, "").trim();
        return katexRoot.matches(normalized) || katexRoot.querySelector(normalized);
      });

      return usedSelectors.length ? `${usedSelectors.join(",")}{${rule.style.cssText}}` : [];
    }).join("");
  } finally {
    style.remove();
  }
}

function minifyCss(css) {
  // 固定入力である KaTeX CSS と自前の @font-face だけを対象にした軽量 minify。
  // 任意の CSS を受ける場合は PostCSS / csso などのパーサを使う。
  return css
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/\s+/g, " ")
    .replace(/\s*([\{\}:;,])\s*/g, "$1")
    .trim();
}

function minifySvg(svg) {
  // XML として意味を変えない範囲でタグ間の空白と連続空白を落とす。
  // path 化した SVG ではないため、属性名の短縮や構造変更までは行わない。
  return svg
    .replace(/>\s+</g, "><")
    .replace(/\s+/g, " ")
    .trim();
}

CSSOM を使う理由は、コメントや空白だけでなく「使っていないセレクタ」そのものを落とすため。 その後の文字列 minify は、残った CSS を SVG 内に埋め込む前の仕上げとして使う。 今回のデモでは、フォントのサブセット化だけでなく、この CSS pruning によって最終 SVG もさらに小さくなる。

7. 実測結果(デモ)

このデモは HTML だけで完結させるため、KaTeX、KaTeX CSS、KaTeX WOFF2 は CDN から読み込む。 Glypht は module worker と WASM を使うため、worker と WASM ファイルだけは記事と同じ origin の katex2svg-vendor/ に置いている。 ボタンを押すと、ブラウザ内で WOFF2 を TTF に展開し、DOM 解析で得たフォントごとのコードポイントだけをサブセット化し、WOFF2 として再圧縮した SVG を生成する。

固定サイズの SVG に、通常の数式、AMS 記号、カリグラフィ、フラクトゥール、サンセリフ、タイプライタ、大きな区切り記号を混ぜて配置する。 説明ラベルは <text> として追加し、数式部分だけを foreignObject に入れる。

元WOFF2合計 -
サブセット合計 -
生成SVGサイズ -
サブセットフォント数 -
Glyphtのworker/wasmとCDN上のKaTeXフォントを読み込みます。初回は少し時間がかかります。
KaTeX collage source

生成SVGプレビュー

未生成

8. 制限事項と改善点

  • foreignObject に依存するため、ブラウザ以外の SVG レンダラでは表示できないことがある。
  • DOM クラスからのフォント判定は KaTeX の内部構造に依存する。KaTeX のバージョンを固定し、更新時にマッピングを確認する。
  • すべての KaTeX コマンドを網羅するには、根号、伸縮アクセント、cancel, array, cases, 色、mathscr, mathsf, boldsymbol, サイズ付き区切り記号などのテストケースを増やす必要がある。
  • \href, \includegraphics, \htmlClass, \htmlStyle などの HTML 拡張は、KaTeX の trust 設定に依存する。外部画像や任意スタイルを扱う場合は、別途セキュリティ方針と表示確認が必要になる。
  • CJK など KaTeX フォント外の Unicode 文字はシステムフォントにフォールバックするため、完全な単体 SVG として保存したい場合は別のフォント埋め込み戦略が必要になる。
  • 正規表現ベースの CSS minify は固定入力向け。一般化するなら CSS パーサや SVGO を使う。
  • 生成 SVG のアクセシビリティを重視するなら、元の LaTeX を <title><desc> に入れる処理を追加する。
  • フォントのサブセット化と WOFF2 再圧縮は重い処理なので、複数数式を扱う場合はフォントの展開結果とサブセット結果をキャッシュする。

参考