HTML・MarkdownへLaTeXの数式を埋め込む
ブラウザ側のスクリプトへKaTeXを組み込めば、本体を改修せずに数式を埋め込めます。 ここでは、管理画面で設定するHTMLと、説明項目のMarkdownを対象にします。プリザンター1.5.8.1の実装と、KaTeX 0.19.0で確認した方法です。
数式を描画するのはブラウザです。保存するのは元のTeX文字列で、レコードの値を数式のHTMLへ変換して保存する処理は行いません。LaTeXの文書全体をコンパイルする機能ではなく、KaTeXが対応する数式記法を使います(対応コマンド)。
埋め込み方を選ぶ
| 入れたい場所 | 数式の指定 | 向いている用途 |
|---|---|---|
| テーブル管理のHTML、拡張HTML | .pleasanter-tex要素のdata-tex属性 | 説明文の途中の数式、画面に固定した式、項目値と連動する表示 |
| 説明項目のMarkdownビューア | 言語名がmathのコードブロック | 利用者が記録ごとに入力する式、手順書・計算条件の記録 |
このサンプルは$...$をページ全体から探しません。金額などの通常の本文を数式として扱わず、指定した要素・コードブロックを描画します。KaTeXのauto-render拡張で区切り文字を探す方法もありますが、この実装には使いません。
1. 描画スクリプトとスタイルを設定する
対象サイトの**ブラウザ側の「スクリプト」**へ、次のJSの全文を入れます。HTML・Markdownを表示する画面で実行する設定にしてください。サーバースクリプトには入れません。対象サイトの「スタイル」へはCSSの全文を入れます。全サイトで共通化する場合は、拡張スクリプトと拡張スタイルへ配置して適用先を絞ります。
JSはKaTeXのJS・CSSを、版を固定したCDNから一度だけ読み込みます。両方の読み込みが終わってから描画を始めます。KaTeXのブラウザ向け配布構成は公式の読み込み方法で確認できます。
描画スクリプトの全文
(function () {
'use strict';
if (window.PleasanterLatex) {
window.PleasanterLatex.refresh();
return;
}
const version = '0.19.0';
const config = window.PleasanterLatexConfig || {};
const assetBase = (config.assetBase ||
'https://cdn.jsdelivr.net/npm/katex@' + version + '/dist/').replace(/\/?$/, '/');
const nonce = (document.currentScript || document.querySelector('script[nonce]'))?.nonce;
const states = new WeakMap();
let renderer;
let queued = false;
function loadAsset(tag, url) {
return new Promise(function (resolve, reject) {
const element = document.createElement(tag);
if (nonce) element.nonce = nonce;
const timer = setTimeout(function () {
element.remove();
reject(new Error('数式ライブラリの読み込みがタイムアウトしました: ' + url));
}, 20000);
element.onload = function () { clearTimeout(timer); resolve(); };
element.onerror = function () {
clearTimeout(timer);
element.remove();
reject(new Error('数式ライブラリを読み込めません: ' + url));
};
if (tag === 'link') {
element.rel = 'stylesheet';
element.href = url;
} else {
element.src = url;
element.async = true;
}
document.head.appendChild(element);
});
}
function renderInto(tex, element, displayMode) {
renderer.render(tex, element, {
displayMode: displayMode,
output: 'htmlAndMathml',
throwOnError: true,
trust: false,
maxSize: 20,
maxExpand: 1000,
macros: {}
});
}
function renderExplicit(element) {
const tex = element.getAttribute('data-tex') || '';
const displayMode = element.getAttribute('data-display') === 'block';
const previous = states.get(element);
if (previous?.tex === tex && previous.displayMode === displayMode) return;
try {
if (tex.trim()) renderInto(tex, element, displayMode);
else element.textContent = '';
} catch (error) {
element.textContent = tex;
console.warn('[PleasanterLatex] 数式を確認してください', error);
}
states.set(element, { tex: tex, displayMode: displayMode });
}
function renderMarkdown(code) {
const tex = code.textContent || '';
const previous = states.get(code);
if (previous?.tex === tex) return;
const block = code.closest('.md-code-block');
const pre = code.parentElement;
if (!block || pre?.tagName !== 'PRE') return;
let output = block.querySelector(':scope > .pleasanter-tex-output');
if (!tex.trim()) {
output?.remove();
pre.classList.remove('pleasanter-tex-source');
pre.hidden = false;
states.set(code, { tex: tex });
return;
}
if (!output) {
output = document.createElement('div');
output.className = 'pleasanter-tex-output';
}
try {
renderInto(tex, output, true);
if (!output.isConnected) block.appendChild(output);
pre.classList.add('pleasanter-tex-source');
pre.hidden = true;
} catch (error) {
output.remove();
pre.classList.remove('pleasanter-tex-source');
pre.hidden = false;
console.warn('[PleasanterLatex] mathブロックを確認してください', error);
}
states.set(code, { tex: tex });
}
function refresh() {
if (queued) return;
queued = true;
requestAnimationFrame(function () {
queued = false;
if (!renderer) return;
document.querySelectorAll('.pleasanter-tex').forEach(renderExplicit);
document.querySelectorAll('.md-viewer .md-code-block > pre > code.language-math')
.forEach(renderMarkdown);
});
}
const api = { refresh: refresh, ready: null };
window.PleasanterLatex = api;
async function start() {
if (window.katex && window.katex.version !== version) {
throw new Error('既存のKaTeXと版が異なります。JSとCSSを' + version + 'へ揃えてください。');
}
await Promise.all([
loadAsset('link', assetBase + 'katex.min.css'),
window.katex ? Promise.resolve() : loadAsset('script', assetBase + 'katex.min.js')
]);
if (!window.katex || window.katex.version !== version) {
throw new Error('KaTeX ' + version + 'を読み込めませんでした。');
}
renderer = window.katex;
const observer = new MutationObserver(function (mutations) {
if (mutations.some(function (mutation) {
if (mutation.type === 'attributes') return true;
const target = mutation.target.nodeType === 1
? mutation.target : mutation.target.parentElement;
return !target?.closest('.katex, .pleasanter-tex, .pleasanter-tex-output');
})) refresh();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-tex', 'data-display']
});
refresh();
return true;
}
api.ready = new Promise(function (resolve) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
} else resolve();
}).then(start).catch(function (error) {
console.warn('[PleasanterLatex] 初期化できませんでした', error);
return false;
});
})();スタイルの全文
.md-code-block > pre.pleasanter-tex-source {
display: none !important;
}
.pleasanter-tex-output {
max-width: 100%;
overflow-x: auto;
padding: 0.5em 1em;
}
.pleasanter-tex[data-display="block"] {
display: block;
max-width: 100%;
overflow-x: auto;
}数式が長いときは描画領域を横スクロールできるようにし、Markdownの元の表示用preだけを隠します。CSSも設定してください。
CDNを使わず自分のサーバーで配信する
KaTeX 0.19.0のビルド済み配布物から、katex.min.js・katex.min.css・fonts/を、プリザンターのwwwroot/Extensions/katex-0.19.0/へ配置します。CSSとfontsの相対位置を保ちます。 自分で配信するファイルの選び方はKaTeXの配布方法を参照してください。
描画スクリプトより前に、次の設定を実行します。
window.PleasanterLatexConfig = {
assetBase: document.getElementById('ApplicationPath').value
+ 'Extensions/katex-0.19.0/'
};ApplicationPathを使うので、プリザンターをサブパスへ配置している場合にも対応します(システム変数)。ライブラリのファイルは、このマニュアルの描画スクリプトとは別に配置します。本体更新時にも配置ファイルを引き継いでください。
2. HTMLの指定場所へ数式を入れる
HTMLを設定できる場所へ、次を入れます。本文の途中にはspan、独立した数式にはdivとdata-display="block"を使います。
<p>
面積は
<span class="pleasanter-tex" data-tex="A = \pi r^2"></span>
で求めます。
</p>
<div class="pleasanter-tex"
data-display="block"
data-tex="x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"></div>HTML属性では、バックスラッシュをそのまま書けます。式に&や二重引用符を含める場合は、HTML属性として&・"へエスケープします。JavaScriptから設定する場合はsetAttribute()へ式の文字列を渡してください。
このHTMLをMarkdownの本文へ貼る方法ではありません。 本体のMarkdown用HTMLレンダラーはHTMLをエスケープします。Markdownから使う場合は、次のmathブロックを使います(HTMLの処理)。
3. Markdownの説明項目へ数式を入れる
説明項目のビューアでMarkdownを使う場合は、先頭を[md]にして、言語名をmathにします。
[md]
## 計算条件
二次方程式の解を使います。
```math
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
```
行を揃える場合は、次の形です。
```math
\begin{aligned}
y &= ax + b \\
\frac{dy}{dx} &= a
\end{aligned}
```ビューアの切り替えを「無効」にした説明項目には適用できません。リッチテキストエディタや、プリザンターの画面以外での表示・PDF出力へ自動的に適用される実装でもありません。
mathブロックを判別できる理由
本体のコードレンダラーは、指定した言語名からlanguage-mathのクラスを作り、コードをdiv.md-code-blockで包みます。数式そのものは本体が描画していません。
return `<div class="md-code-block">
<button class="md-code-copy">
<span class="md-btn-icon material-symbols-outlined">content_copy</span>
<pre class="md-code-copy-item" name="copy_data">${this.escapeHtml(text)}</pre>
</button>
<div class="md-code-copied">Copied!</div>
<pre><code class="hljs ${lang ? `language-${lang}` : ''}">${highlighted}</code></pre>
</div>`;サンプルは、その中の**表示用pre > code.language-math**からtextContentを読み、KaTeXへ渡します。コピーボタン用のpre.md-code-copy-itemとは区別します。描画に成功したら表示用preを隠し、数式の表示領域を足します。元のコードとコピーボタンは残るので、数式の表示後もTeXの元文字列をコピーできます。
ビューアの再描画へ追従する
本体は、ビューアを表示するたびにサニタイズしたHTMLをinnerHTMLへ入れます。後から追加した数式のDOMも、そのときに作り直されます。
md = DOMPurify.sanitize(md, {
ADD_ATTR: ['title']
});
md = this.restoreFromPlaceholders(md);
md = md.replace(/&#(\d+);/g, '&#$1;');
this.viewerElem!.innerHTML = md;
this.finalizeViewerDom();
this.removeAttribute('data-editable');図を読み込み中…
ここではdata-editableが外れることだけに依存せず、childListの変更を見ます。既にビューア表示中の項目も、内部のDOMが差し替われば対象にできます。Ajaxで追加されるコメントやダイアログも、同じセレクタに合えば描画します。
数式の描画自身もDOMを変更するので、そのまま監視すると再描画を繰り返します。サンプルでは、生成した数式の内部を監視対象の判断から除き、WeakMapに処理済みの元文字列を記録します。同じ要素・同じ式は再処理せず、複数の変更をrequestAnimationFrameでまとめます。スクリプトの再実行でも、グローバルの初期化済みオブジェクトを使い、監視やライブラリの読み込みを重複させません。
数値項目から式を組み立てる
HTMLに、表示先を用意します。
<div id="LatexTotal" class="pleasanter-tex" data-display="block"></div>次は、記録テーブルの編集画面で、NumAを数量、NumBを単価として表示する例です。既存の項目変更処理などからupdateLatexTotal()を呼びます。$p.getValue()の値の扱いは項目値の取得・設定を参照してください。
function updateLatexTotal() {
const target = document.getElementById('LatexTotal');
if (!target) return;
const values = ['NumA', 'NumB'].map(name => $p.getValue(name));
const numbers = values.map(value =>
value === null || value === undefined || String(value).trim() === ''
? NaN : Number(value));
const [quantity, unitPrice] = numbers;
const total = quantity * unitPrice;
target.setAttribute('data-tex', numbers.every(Number.isFinite) && Number.isFinite(total)
? String.raw`\text{Total} = ${quantity} \times ${unitPrice} = ${total}`
: '');
}JavaScriptの文字列では、通常はバックスラッシュを二重にします。上の例ではString.rawを使い、TeXをそのまま記述しています(KaTeX APIの文字列の扱い)。data-texの変更を描画スクリプトが検出するので、数式のHTMLを手で組み立てる必要はありません。数値の表示例であり、金額の丸めやレコードへの計算結果の保存は別途実装します。
エラー・入力範囲・確認方法
サンプルはkatex.render()でthrowOnError: trueを指定し、構文エラーを式ごとに捕捉します。Markdownでは元のコード表示へ戻し、HTMLでは式をtextContentで表示します。他の式の描画は続けます(エラー時のAPI動作)。
trust: falseにし、HTMLやURLを扱う信頼の必要な機能を許可しません。さらに展開数を1000、指定サイズを20emに制限し、マクロの辞書は式ごとに作ります。これらはサンプルの設定値です。別の利用者・別のレコードのマクロを共有しません(オプション、安全性、マクロの共有範囲)。
| 症状 | 確認すること |
|---|---|
| 数式が出ない | ブラウザ側スクリプトが対象画面で実行され、await window.PleasanterLatex.readyがtrueになるか |
初期化がfalseになる | NetworkとConsoleでJS・CSSの失敗を確認。CDNが使えない環境なら自分のサーバーから配信し、ページを再読み込みする |
| 数式の字形・配置が崩れる | KaTeXのJS・CSSの版が0.19.0に揃っているか。fontsがCSSからの相対URLで取得できるか |
| コードと数式が両方出る | サンプルのCSSを設定したか |
| Markdownのまま表示される | [md]とmath、説明項目のビューア設定を確認。リッチテキストへの設定ではないか |
| CSPの拒否が出る | JS・CSS・フォントの配信元が許可されているか。サンプルはページのscriptのnonceがあれば読み込み要素へ引き継ぐが、全てのCSPに対応する設定ではない |
| 一部の式だけ元文字列に戻る | Consoleの構文エラーと対応コマンドを確認 |
必要ならコンソールからwindow.PleasanterLatex.refresh()で再確認できます。初期表示だけでなく、ビューアへの切り替え、Ajaxによる差し替え、同じコードの再実行、不正な式が隣にある場合も確認してください。
検証ではKaTeX 0.19.0と、本体と同じ構造のビューアDOMを用いて、描画・再描画・元文字列の保持を確認しています。利用先のプリザンターでのCSP・配信経路・テーマは、配置後に確認してください。