Skip to content

HTML・MarkdownへLaTeXの数式を埋め込む ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

ブラウザ側のスクリプトへ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のブラウザ向け配布構成は公式の読み込み方法で確認できます。

描画スクリプトの全文
javascript
(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;
    });
})();
スタイルの全文
css
.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の配布方法を参照してください。

描画スクリプトより前に、次の設定を実行します。

javascript
window.PleasanterLatexConfig = {
    assetBase: document.getElementById('ApplicationPath').value
        + 'Extensions/katex-0.19.0/'
};

ApplicationPathを使うので、プリザンターをサブパスへ配置している場合にも対応します(システム変数)。ライブラリのファイルは、このマニュアルの描画スクリプトとは別に配置します。本体更新時にも配置ファイルを引き継いでください。

2. HTMLの指定場所へ数式を入れる ​

HTMLを設定できる場所へ、次を入れます。本文の途中にはspan、独立した数式にはdivとdata-display="block"を使います。

html
<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属性として&amp;・&quot;へエスケープします。JavaScriptから設定する場合はsetAttribute()へ式の文字列を渡してください。

このHTMLをMarkdownの本文へ貼る方法ではありません。 本体のMarkdown用HTMLレンダラーはHTMLをエスケープします。Markdownから使う場合は、次のmathブロックを使います(HTMLの処理)。

3. Markdownの説明項目へ数式を入れる ​

説明項目のビューアでMarkdownを使う場合は、先頭を[md]にして、言語名をmathにします。

text
[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で包みます。数式そのものは本体が描画していません。

typescript
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>`;

出典: Implem.PleasanterFrontend/wwwroot/src/scripts/modules/markdownField/markdownField.ts(250–257行・8d29c7b)

サンプルは、その中の**表示用pre > code.language-math**からtextContentを読み、KaTeXへ渡します。コピーボタン用のpre.md-code-copy-itemとは区別します。描画に成功したら表示用preを隠し、数式の表示領域を足します。元のコードとコピーボタンは残るので、数式の表示後もTeXの元文字列をコピーできます。

ビューアの再描画へ追従する ​

本体は、ビューアを表示するたびにサニタイズしたHTMLをinnerHTMLへ入れます。後から追加した数式のDOMも、そのときに作り直されます。

typescript
md = DOMPurify.sanitize(md, {
    ADD_ATTR: ['title']
});
md = this.restoreFromPlaceholders(md);
md = md.replace(/&amp;#(\d+);/g, '&#$1;');
this.viewerElem!.innerHTML = md;
this.finalizeViewerDom();
this.removeAttribute('data-editable');

出典: Implem.PleasanterFrontend/wwwroot/src/scripts/modules/markdownField/markdownField.ts(427–434行・8d29c7b)

図を読み込み中…

ここではdata-editableが外れることだけに依存せず、childListの変更を見ます。既にビューア表示中の項目も、内部のDOMが差し替われば対象にできます。Ajaxで追加されるコメントやダイアログも、同じセレクタに合えば描画します。

数式の描画自身もDOMを変更するので、そのまま監視すると再描画を繰り返します。サンプルでは、生成した数式の内部を監視対象の判断から除き、WeakMapに処理済みの元文字列を記録します。同じ要素・同じ式は再処理せず、複数の変更をrequestAnimationFrameでまとめます。スクリプトの再実行でも、グローバルの初期化済みオブジェクトを使い、監視やライブラリの読み込みを重複させません。

数値項目から式を組み立てる ​

HTMLに、表示先を用意します。

html
<div id="LatexTotal" class="pleasanter-tex" data-display="block"></div>

次は、記録テーブルの編集画面で、NumAを数量、NumBを単価として表示する例です。既存の項目変更処理などからupdateLatexTotal()を呼びます。$p.getValue()の値の扱いは項目値の取得・設定を参照してください。

javascript
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・配信経路・テーマは、配置後に確認してください。

関連ページ ​

変更履歴

第1版HTMLとMarkdownへLaTeX数式を埋め込む方法と描画スクリプトを追加