Skip to content

HTML・MarkdownへMermaidの図を埋め込む ​

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

ブラウザ側スクリプトでMermaidを読み込み、図の定義からSVGを作れば、本体を改修せずに図を表示できます。 HTMLで指定した場所と、説明項目のMarkdownのmermaidブロックを対象にします。

ここではプリザンター1.5.8.1と、同梱のMermaid 11.9.0を使います。元の文字列を保持し、表示用SVGをレコードへ保存する処理は行いません。PlantUMLとLaTeXの埋め込みも用途に応じて使えます。

方法描画する場所用途
Mermaid利用者のブラウザフロー図、シーケンス図、ER図など
PlantUML別途用意する描画サーバーPlantUMLで書いたUMLなどを画像表示

Mermaidでは図の定義を外部の描画サーバーへ送信しません。ライブラリの取得と、図の描画を分けて考えます。

1. スクリプトとスタイルを配置する ​

対象サイトの**ブラウザ側の「スクリプト」**へJS全文、「スタイル」へCSS全文を入れます。HTML・Markdownを表示する画面で実行してください。全サイトなら拡張スクリプトと拡張スタイルで共通化します。

このJSはMermaidとPlantUMLの両方に対応します。Mermaidだけなら設定オブジェクトは不要です。PlantUMLの追加設定は別の記事で説明します。同じファイルなので、一度だけ配置します。

描画スクリプトの全文
javascript
(function () {
    'use strict';
    if (window.PleasanterDiagrams) {
        window.PleasanterDiagrams.refresh();
        return;
    }
    const config = window.PleasanterDiagramsConfig || {};
    const states = new WeakMap();
    const nonce = (document.currentScript || document.querySelector('script[nonce]'))?.nonce;
    let library;
    let queued = false;
    let sequence = 0;

    function loadMermaid() {
        if (library) return library;
        library = (async function () {
            if (!window.mermaid) {
                const base = document.getElementById('ApplicationPath')?.value;
                if (!base && !config.mermaidUrl) throw new Error('ApplicationPathを取得できません。');
                await new Promise(function (resolve, reject) {
                    const script = document.createElement('script');
                    if (nonce) script.nonce = nonce;
                    script.src = config.mermaidUrl || base + 'Extensions/mermaid-11.9.0.min.js';
                    const timer = setTimeout(function () {
                        script.remove();
                        reject(new Error('Mermaidの読み込みがタイムアウトしました。'));
                    }, 20000);
                    script.onload = function () { clearTimeout(timer); resolve(); };
                    script.onerror = function () {
                        clearTimeout(timer);
                        script.remove();
                        reject(new Error('Mermaidを読み込めません。'));
                    };
                    document.head.appendChild(script);
                });
            }
            if (!window.mermaid?.render) throw new Error('Mermaid APIがありません。');
            window.mermaid.initialize({
                startOnLoad: false,
                securityLevel: 'strict',
                suppressErrorRendering: true,
                maxTextSize: 50000,
                theme: 'default',
                fontFamily: getComputedStyle(document.body).fontFamily
            });
            return window.mermaid;
        })();
        return library;
    }

    function plantUmlUrl(source) {
        if (!config.plantUmlServer) throw new Error('plantUmlServerを設定してください。');
        const server = new URL(config.plantUmlServer, location.href);
        if (!['https:', 'http:'].includes(server.protocol) || server.username ||
            server.password || server.search || server.hash) {
            throw new Error('PlantUMLサーバーのURLを確認してください。');
        }
        const encoded = Array.from(new TextEncoder().encode(source), function (byte) {
            return byte.toString(16).padStart(2, '0');
        }).join('');
        const url = server.href.replace(/\/$/, '') + '/svg/~h' + encoded;
        if (url.length > 8000) throw new Error('PlantUMLのURLが8000文字を超えています。');
        return url;
    }

    function loadImage(url) {
        return new Promise(function (resolve, reject) {
            const img = document.createElement('img');
            img.alt = 'PlantUMLで描画した図';
            img.referrerPolicy = 'no-referrer';
            const timer = setTimeout(function () {
                img.removeAttribute('src');
                reject(new Error('PlantUMLの画像取得がタイムアウトしました。'));
            }, 20000);
            img.onload = function () { clearTimeout(timer); resolve(img); };
            img.onerror = function () {
                clearTimeout(timer);
                reject(new Error('PlantUMLの画像を取得できません。'));
            };
            img.src = url;
        });
    }

    async function update(owner, type, source, pre) {
        const key = type + '\n' + source;
        const previous = states.get(owner);
        if (previous?.key === key) return;
        previous?.output?.remove();
        if (pre) {
            pre.hidden = false;
            pre.classList.remove('pleasanter-diagram-source');
        } else owner.textContent = source;
        const state = { key: key, output: null };
        states.set(owner, state);
        if (!source.trim()) return;
        const current = function () {
            return owner.isConnected && states.get(owner) === state &&
                (pre ? owner.textContent : owner.getAttribute('data-source') || '') === source &&
                (pre || owner.getAttribute('data-diagram') === type);
        };
        try {
            if (source.length > 50000) throw new Error('図の定義が50000文字を超えています。');
            const output = document.createElement('div');
            output.className = 'pleasanter-diagram-output';
            if (type === 'mermaid') {
                const mermaid = await loadMermaid();
                if (document.fonts) await document.fonts.ready;
                if (!current()) return;
                const result = await mermaid.render('pleasanter-diagram-' + (++sequence), source);
                if (!current()) return;
                output.innerHTML = result.svg;
            } else if (type === 'plantuml') {
                output.appendChild(await loadImage(plantUmlUrl(source)));
            } else throw new Error('図の種類を確認してください。');
            if (!current()) return;
            if (pre) {
                const block = owner.closest('.md-code-block');
                if (!block) return;
                block.appendChild(output);
                pre.hidden = true;
                pre.classList.add('pleasanter-diagram-source');
            } else {
                owner.textContent = '';
                owner.appendChild(output);
            }
            state.output = output;
        } catch (error) {
            if (current()) console.warn('[PleasanterDiagrams] 図を確認してください', error);
        }
    }

    function refresh() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(function () {
            queued = false;
            document.querySelectorAll('.pleasanter-diagram[data-diagram]').forEach(function (element) {
                void update(element, element.getAttribute('data-diagram'), element.getAttribute('data-source') || '');
            });
            document.querySelectorAll('.md-viewer .md-code-block > pre > code.language-mermaid, ' +
                '.md-viewer .md-code-block > pre > code.language-plantuml').forEach(function (code) {
                const type = code.classList.contains('language-mermaid') ? 'mermaid' : 'plantuml';
                void update(code, type, code.textContent || '', code.parentElement);
            });
        });
    }

    const api = { refresh: refresh, ready: null };
    window.PleasanterDiagrams = api;
    api.ready = new Promise(function (resolve) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', resolve, { once: true });
        } else resolve();
    }).then(function () {
        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('.pleasanter-diagram-output, .pleasanter-diagram');
            })) refresh();
        });
        observer.observe(document.body, {
            childList: true, subtree: true, attributes: true,
            attributeFilter: ['data-source', 'data-diagram']
        });
        refresh();
        return true;
    });
})();
スタイルの全文
css
.md-code-block > pre.pleasanter-diagram-source {
    display: none !important;
}
.pleasanter-diagram-output {
    max-width: 100%;
    overflow-x: auto;
    padding: 0.75em;
    background: #fff;
    color: #222;
}
.pleasanter-diagram-output > svg,
.pleasanter-diagram-output > img {
    display: block;
    max-width: 100%;
    height: auto;
    margin: 0 auto;
}

図が見つかったときに、Hidden要素ApplicationPathの値とExtensions/mermaid-11.9.0.min.jsを結合して読み込みます。サブパス配置にも対応します。ライブラリは一度だけ読み込み、既存のwindow.mermaidがあれば再利用します。

本体もサイト設定の可視化ページで、このファイルを読み込んでいます。ただし、通常のレコード画面のMarkdownを自動で図へ変換する機能ではありません(可視化ページの読み込み)。

別の配信先なら、JSより前に次を実行します。利用する版と配布構成はMermaidの読み込み方法で確認し、版を変えた場合は図も再検証します。

javascript
window.PleasanterDiagramsConfig = {
    mermaidUrl: '/diagram-assets/mermaid-11.9.0.min.js'
};

初期化ではstartOnLoad: false・securityLevel: 'strict'・theme: 'default'を指定します。同じ画面ですでにMermaidを使っている場合は、共通インスタンスの設定も変わるため、既存の図との設定を揃えます。

2. HTMLの指定場所へフロー図を入れる ​

テーブル管理のHTMLや拡張HTMLへ、次を入れます。種類はdata-diagram、図の定義はdata-sourceです。

html
<div class="pleasanter-diagram" data-diagram="mermaid"
     data-source="flowchart LR
    A[申請] --> B{確認}
    B -->|承認| C[処理]
    B -->|差戻し| A"></div>

属性の改行も図の文字列になります。属性内の&と二重引用符は、&amp;・&quot;へエスケープします。JavaScriptからはsetAttribute('data-source', source)へ文字列を渡せば、変更後に描き直します。

このHTMLをMarkdown本文へ貼っても同じ動作にはなりません。本体のMarkdownは生のHTMLをエスケープするため、次のコードブロックを使います。

3. Markdownにシーケンス図を書く ​

説明項目の先頭を[md]とし、言語名をmermaidにします。

text
[md]
## 受付の流れ

```mermaid
sequenceDiagram
    participant U as 利用者
    participant P as プリザンター
    U->>P: 申請を登録
    P-->>U: 登録結果を表示
```

ER図なら、ブロックの中を次へ置き換えます。

text
erDiagram
    PROJECT ||--o{ TASK : contains
    PROJECT {
        int id
        string title
    }
    TASK {
        int id
        int projectId
    }

説明項目のビューアが無効の場合、リッチテキストの場合、外部へのMarkdown出力では、このスクリプトは適用されません。

本体のDOMを利用する ​

本体は言語名からlanguage-mermaidのクラスを作り、表示用コードとコピー用コードを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)

.md-viewer .md-code-block > pre > code.language-mermaidからtextContentを読み、描画成功後に表示用preを隠してSVGを追加します。コピー用コードとボタンは残るので、図の表示後も元の定義をコピーできます。構文エラーの場合は元のコードを残し、Consoleへ理由を出します。

Ajax後の再描画と非同期処理 ​

本体はサニタイズしたMarkdownをinnerHTMLへ入れ直します(更新処理)。後から追加したSVGも消えるので、再描画が必要です。

図を読み込み中…

監視はdata-editableだけに依存せず、子ノードの差し替えとdata-source・data-diagramを扱います。後から追加されたコメントやダイアログも、セレクタが合えば対象になります。

WeakMapで要素ごとの状態を持ち、同じ文字列は繰り返し描きません。処理中に定義が変わったり、項目が取り外されたりした場合は、古い処理結果を適用しません。JSの再実行では、初期化済みのPleasanterDiagramsを使います。

document.fonts.readyも待ちます。日本語フォントへ切り替わる前にラベルを計測すると、文字と箱の寸法が合わない場合があるためです。

動作を確認する ​

症状確認すること
コードのまま実行画面、[md]、言語名、ビューア設定、Consoleの構文エラー
ライブラリが読めないApplicationPath、配信ファイル、Network、CSPのscript許可。修正後は再読み込み
図とコードが両方出るCSS全文を設定したか
図が暗い背景に埋もれるCSSは白背景、図はdefaultテーマ。画面と図の配色を揃える
長い定義を描けないサンプルは50000文字まで。図を分割する
クリックが動かないstrict指定のため、図のクリック機能を使う実装ではない

strictはラベル内のHTMLとクリック機能を制限します(securityLevel)。生成SVGは挿入しますが、付随するbindFunctionsは呼びません。

await window.PleasanterDiagrams.readyは監視の準備完了を示し、全ての図の描画完了を示す値ではありません。refresh()で対象を再確認できます。同じ定義で失敗した図は再試行しないので、修正するかページを再読み込みします。

配置後は初期表示、ビューア切り替え、Ajax差し替え、定義変更、構文エラー、コピーを確認します。本体改修との違いはMarkdownフィールドの拡張で説明しています。

検証では本体同梱版の描画APIで上記3種類の図を生成し、ビューアDOMを模した環境でコピー元の保持・差し替え・競合対策を確認しています。DOM検証環境ではSVGの寸法計測を補っているため、実際の文字と箱の配置は設置先のブラウザ・フォントで確認してください。

変更履歴

第1版Mermaidの図をHTMLとMarkdownへ埋め込む方法と配布スクリプトを追加