0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MathJax数式をCanvasに描画しようとしたら沼にハマった話

Last updated at Posted at 2025-12-30

採点支援ソフト「一括採点」で、採点答案に数式を落書きできる機能を実装するため、Canvas に MathJax を導入しようと思ったら、これが大変。
実装完了に伴い、プロジェクトからdocsを削除するので、Qiitaに備忘録をまとめてもらいました。

削除したドキュメントファイル を直接読んでもらったほうがわかりやすいかも?
実ファイルは これ です。

svg 埋め込んでね、というそれだけの話です。

以下はいつもの Claude Code にまとめさせたやつです。

TL;DR

MathJaxで生成されたSVGをCanvasに描画しようとすると、数式が表示されない問題が発生します。原因は、MathJaxが生成するSVGが<use>要素でグローバル定義を参照しているため、SVGを単独でシリアライズすると参照先が失われるからです。

解決策: #MJX-SVG-global-cache defsからグローバル定義を取得し、SVGに埋め込んでから描画する。

function addMathJaxDefsToSvg(svgData: string): string {
  if (svgData.includes("mjx-container") || svgData.includes("<use")) {
    const globalDefs = document.querySelector("#MJX-SVG-global-cache defs")
    if (globalDefs) {
      svgData = svgData.replace(/(<svg[^>]*>)/, `$1${globalDefs.outerHTML}`)
    }
  }
  return svgData
}

背景

教育用の採点アプリケーションを開発していて、答案画像の上にMathJax数式をオーバーレイ表示し、最終的にPDF出力する機能が必要でした。

要件

  1. ユーザーが $x^2 + y^2 = r^2$ のようなLaTeX記法で数式を入力
  2. リアルタイムでプレビュー表示
  3. Canvas上に描画して画像として保存
  4. PDF出力時に答案画像と合成

問題: なぜ数式が表示されないのか

症状

MathJaxでレンダリングした数式をCanvasに描画しようとすると、以下のような症状が発生しました:

  • DOMプレビューでは正しく表示される($x^2$ → 正常な数式)
  • Canvas描画では何も表示されない、または一部のみ表示

最初に試したアプローチ(失敗)

1. html2canvas を使用

import html2canvas from 'html2canvas'
const canvas = await html2canvas(mathElement)

結果: OKLCH色空間のパースエラーで失敗

Error: Attempting to parse an unsupported color function "oklch"

2. SVG foreignObject で直接埋め込み

const svgContent = `
  <svg>
    <foreignObject>
      <div>${mathHtml}</div>
    </foreignObject>
  </svg>
`

結果: 分数線のみ表示され、文字が描画されない

3. Canvas 2D API で直接描画

const paths = svg.querySelectorAll('path')
paths.forEach(path => {
  const d = path.getAttribute('d')
  ctx.fill(new Path2D(d))
})

結果: <use>要素を解決できず、何も描画されない

原因の特定

MathJaxが生成するSVGの構造を詳しく調べました。

MathJaxのSVG構造

<!-- MathJaxが生成する数式のSVG -->
<mjx-container>
  <svg>
    <!-- 注目: 個別SVGにはdefs要素がない -->
    <g transform="translate(0, 100)">
      <use href="#MJX-1-TEX-I-1D465"></use>  <!-- x -->
      <use href="#MJX-1-TEX-N-32"></use>      <!-- 2 (上付き) -->
    </g>
  </svg>
</mjx-container>

<!-- グローバルキャッシュ(ページのどこかに存在) -->
<svg id="MJX-SVG-global-cache" style="display: none;">
  <defs>
    <path id="MJX-1-TEX-I-1D465" d="M52 289Q..."></path>
    <path id="MJX-1-TEX-N-32" d="M109 429Q..."></path>
    <!-- 他の文字定義... -->
  </defs>
</svg>

問題の本質

  1. MathJaxは効率化のため、フォントグリフをグローバルキャッシュに格納
  2. 個別の数式SVGは<use href="#...">でグローバル定義を参照
  3. SVGをXMLSerializerでシリアライズすると、参照は解決されない
  4. 結果、Canvasに描画しても参照先がないため何も表示されない

解決策

核心アイデア

SVGをシリアライズする前に、グローバルキャッシュから<defs>を取得してSVGに埋め込む。

実装

1. グローバル定義の取得と埋め込み

/**
 * MathJax defsをSVGデータに追加する
 * @param svgData シリアライズされたSVG文字列
 * @returns defs追加済みのSVG文字列
 */
function addMathJaxDefsToSvg(svgData: string): string {
  // MathJax要素が含まれているかチェック
  const hasMathJax = svgData.includes("mjx-container") || svgData.includes("<use")

  if (!hasMathJax) {
    return svgData
  }

  // グローバルキャッシュからdefs要素を取得
  const globalDefs = document.querySelector("#MJX-SVG-global-cache defs")

  if (globalDefs && globalDefs.innerHTML.length > 10) {
    // SVGの開始タグ直後にdefsを挿入
    svgData = svgData.replace(
      /(<svg[^>]*>)/,
      `$1${globalDefs.outerHTML}`
    )
  }

  return svgData
}

2. SVG → Canvas 描画の全体フロー

async function renderMathToCanvas(
  svgElement: SVGSVGElement,
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number
): Promise<void> {
  return new Promise((resolve, reject) => {
    // 1. SVGをシリアライズ
    let svgData = new XMLSerializer().serializeToString(svgElement)

    // 2. MathJax defsを埋め込み(ここが重要!)
    svgData = addMathJaxDefsToSvg(svgData)

    // 3. Blob URLを作成
    const blob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" })
    const url = URL.createObjectURL(blob)

    // 4. Image経由でCanvasに描画
    const img = new Image()
    img.onload = () => {
      ctx.drawImage(img, x, y)
      URL.revokeObjectURL(url)
      resolve()
    }
    img.onerror = () => {
      URL.revokeObjectURL(url)
      reject(new Error("SVG画像の読み込みに失敗"))
    }
    img.src = url
  })
}

3. MathJax処理の待機

MathJaxは非同期で動作するため、レンダリング完了を待つ必要があります。

/**
 * MathJax処理を実行して完了を待機
 */
async function processMathJax(container: HTMLElement): Promise<void> {
  const MathJax = window.MathJax
  if (!MathJax) return

  // MathJax 4.x の場合
  if (MathJax.typesetPromise) {
    await MathJax.typesetPromise([container])
  }
  // MathJax 3.x の場合
  else if (MathJax.typeset) {
    MathJax.typeset([container])
  }

  // レンダリング完了を待機
  await waitForRenderingComplete()
}

/**
 * ブラウザの描画完了を待機
 */
async function waitForRenderingComplete(frames = 2): Promise<void> {
  for (let i = 0; i < frames; i++) {
    await new Promise(resolve => requestAnimationFrame(resolve))
  }
}

完全な実装例

テキスト → SVG → Canvas の変換パイプライン

/**
 * LaTeXテキストをCanvasに描画可能なSVGに変換
 */
async function convertTextToSvg(
  text: string,
  fontSize: number = 16
): Promise<SVGSVGElement | null> {
  // 1. 一時的なDOM要素を作成
  const container = document.createElement("div")
  container.style.cssText = `
    position: absolute;
    left: -9999px;
    font-size: ${fontSize}px;
  `
  container.innerHTML = text
  document.body.appendChild(container)

  try {
    // 2. MathJax処理
    await processMathJax(container)

    // 3. サイズを測定
    const width = container.scrollWidth
    const height = container.scrollHeight

    // 4. SVGを生成(foreignObjectを使用)
    const svgContent = `
      <svg xmlns="http://www.w3.org/2000/svg"
           width="${width}" height="${height}">
        <foreignObject width="100%" height="100%">
          <div xmlns="http://www.w3.org/1999/xhtml">
            ${container.innerHTML}
          </div>
        </foreignObject>
      </svg>
    `

    const parser = new DOMParser()
    const doc = parser.parseFromString(svgContent, "image/svg+xml")
    return doc.documentElement as unknown as SVGSVGElement
  } finally {
    document.body.removeChild(container)
  }
}

ハマりポイントと対処法

1. グローバルキャッシュが見つからない

MathJaxの初期化が完了していない可能性があります。

// MathJax準備完了を待機
async function waitForMathJaxReady(): Promise<boolean> {
  return new Promise(resolve => {
    if (window.mathJaxReady) {
      resolve(true)
      return
    }

    window.addEventListener("mathjax-ready", () => resolve(true), { once: true })

    // タイムアウト
    setTimeout(() => resolve(false), 5000)
  })
}

2. SSR環境でのエラー

Next.jsなどのSSR環境ではdocumentが存在しません。

if (typeof window === "undefined") {
  return null // サーバーサイドでは処理をスキップ
}

3. 複数のMathJax数式を並列処理

各数式ごとに独立したコンテナを使用することで、並列処理が可能になります。

// 各行を並列でSVG変換
const svgPromises = lines.map(line => convertLineToSvg(line))
const svgs = await Promise.all(svgPromises)

まとめ

MathJaxをCanvas描画する際の核心は、グローバル定義の埋め込みです。

// これだけで解決!
const globalDefs = document.querySelector("#MJX-SVG-global-cache defs")
svgData = svgData.replace(/(<svg[^>]*>)/, `$1${globalDefs.outerHTML}`)

この解決策により、MathJax数式を含むテキストを自由にCanvas上に描画し、画像やPDFとして出力できるようになりました。

動作確認環境

  • MathJax 4.0
  • TypeScript 5.x
  • Next.js 15 (App Router)
  • Electron 30.x

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?