採点支援ソフト「一括採点」で、採点答案に数式を落書きできる機能を実装するため、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出力する機能が必要でした。
要件
- ユーザーが
$x^2 + y^2 = r^2$のようなLaTeX記法で数式を入力 - リアルタイムでプレビュー表示
- Canvas上に描画して画像として保存
- 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>
問題の本質
- MathJaxは効率化のため、フォントグリフをグローバルキャッシュに格納
- 個別の数式SVGは
<use href="#...">でグローバル定義を参照 - SVGを
XMLSerializerでシリアライズすると、参照は解決されない - 結果、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