Skip to content

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

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

図の定義をPlantUMLサーバーへ渡し、返されたSVGを画像として表示する方法です。 本体のMarkdown機能を変えず、ブラウザ側スクリプトでHTMLの指定場所とplantumlブロックを後処理します。

Mermaidがブラウザ内で図を作るのに対し、この実装は描画サーバーが必要です。プリザンター1.5.8.1のビューアDOMを対象とし、PlantUML ServerのSVG APIとHEX形式を使います(サーバーAPI、HEX形式)。

構成とデータの流れ ​

図を読み込み中…

要求URLには図の定義が含まれます。HEX化は暗号化ではありません。 業務の図なら組織で管理する描画サーバーを指定します。サンプルに公開サービスへの自動送信先は入れていません。

1. 共通スクリプトとCSSを設定する ​

対象サイトのブラウザ側の「スクリプト」へJS全文、「スタイル」へCSS全文を入れます。全サイトなら拡張スクリプトと拡張スタイルを使います。

Mermaidの記事と同じファイルです。両方使う場合も一度だけ配置します。PlantUMLの図だけなら、Mermaidのライブラリは読み込みません。

描画スクリプトの全文
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;
}

描画サーバーを設定する ​

描画サーバーはPlantUML Serverの構築方法を参照して用意します。次を共通JSより前に実行し、ダミーURLを利用先へ置き換えてください。

javascript
window.PleasanterDiagramsConfig = {
    plantUmlServer: 'https://diagrams.example.com/plantuml'
};

同じオリジンへリバースプロキシする場合は、相対URLも指定できます。

javascript
window.PleasanterDiagramsConfig = {
    plantUmlServer: document.getElementById('ApplicationPath').value + 'diagram-renderer'
};

これだけでバックエンドができるわけではありません。そのパスをPlantUML Serverのルートへ転送し、/svg/...を扱う配信構成を別途用意します。

描画サーバーはブラウザから接続できる必要があります。プリザンターのサーバーだけが接続できても画像は取得できません。HTTPSの画面ではHTTPSの配信先を使い、CSPのimg-srcにも合わせます。URL内のユーザー名・パスワードはサンプルで拒否します。

2. HTMLへシーケンス図を入れる ​

テーブル管理のHTMLや拡張HTMLへ、次を入れます。

html
<div class="pleasanter-diagram" data-diagram="plantuml"
     data-source="@startuml
participant 利用者 as U
participant 受付 as R
U -> R : 申請
R --> U : 受付結果
@enduml"></div>

属性の改行を含む文字列が定義になります。属性内の&と二重引用符はエスケープします。JavaScriptからはsetAttribute('data-source', source)で更新でき、変更後に新しい画像を取得します。

3. Markdownへクラス図を書く ​

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

text
[md]
## データの関係

```plantuml
@startuml
class Project {
  +Id : long
  +Title : string
}
class Task {
  +Id : long
  +ProjectId : long
}
Project "1" -- "0..*" Task
@enduml
```

本体は言語名からlanguage-plantumlを付けるため、表示用コードを判別できます。

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-plantumlを読み、画像の取得成功後にそのpreを隠します。コピー用コードとボタンは残します。ビューア無効、リッチテキスト、外部のMarkdown表示には適用されません。Markdownへ生のHTMLを貼る方法も、本体がエスケープするため使いません。

URLを組み立てる仕組み ​

TextEncoderで文字列をUTF-8へ変換し、バイトごとに2桁の16進数へ変換して、~hを先頭に付けます。日本語をJavaScriptの文字コード単位で変換する方法とは異なります。

text
https://diagrams.example.com/plantuml/svg/~h407374617274756d6c...

407374617274756d6cはASCIIの@startumlです。実際には定義全体を変換します。通常のBase64を付けるだけではPlantUMLの既定形式にならないため、公式のHEX形式を使います。

HEXは圧縮しないので、UTF-8のバイト数の2倍だけデータ部分が長くなります。サンプルはURL全体が8000文字を超えた場合に送信を止めます。これはサンプルの上限で、配信経路が必ず8000文字を受け付ける保証ではありません。

大きな図は分割するか、圧縮形式のエンコーダーへ変更します。さらに大きな定義なら、管理するバックエンドで描画・保存し、画像URLを返す構成を検討します。そのバックエンドは配布JSに含まれません。

画像表示とAjaxへの追従 ​

SVGはimgで表示し、レスポンスをinnerHTMLへ入れません。クロスオリジンの画像を表示するだけなら、CORSによるレスポンス読み取り許可を使いません。SVG本文をJavaScriptで取得したり、Canvasで画素を読み取ったりする処理とは条件が異なります。

画像のload後にコードを隠し、取得エラーや20秒のタイムアウト時は元のコードを残します。ただし、サーバーが構文エラーを画像として返す場合は、loadだけでは判別できません。表示された図とサーバーの応答も確認します。

DOM差し替えと属性変更を監視します。取得中に元文字列が変わったり、項目が取り外されたりした場合は、古い結果を適用しません。仕組みはMermaidの記事で説明しています。

描画サーバー側の確認 ​

定義は要求URLに含まれ、サーバーやプロキシのログにも含まれ得ます。referrerPolicy: 'no-referrer'でも、要求先URLの定義は隠れません。

入力を処理するサーバーでは、外部URL・ローカルファイルへアクセスする機能の扱いを決めます。不要ならPLANTUML_SECURITY_PROFILE=SANDBOXで禁止できます。必要な場合は許可先を管理します(セキュリティプロファイル)。

配布JSはサーバーのプロファイル、認証、負荷制限を設定しません。利用するPlantUMLの版、日本語フォント、公開範囲、実行制限を利用先で確認します。

症状確認すること
コードのまま設定をJSより前に実行したか。言語名・ビューア・Consoleを確認
画像を取得できないブラウザからの接続、HTTPS、img-src、認証、Networkの応答
414などの応答URL長の上限。短い定義から確認する
日本語が四角になるUTF-8のエンコードと描画サーバーの日本語フォント
エラーの画像が出る構文、利用する版、外部リソースの許可設定
コードと図が両方出る共通CSSを設定したか

PleasanterDiagrams.readyは監視の準備完了であり、画像取得の完了ではありません。配置後は小さな図、日本語、Ajax差し替え、取得失敗、コピーを確認します。描画結果はPlantUML Serverの版と配信設定にも依存します。

検証では日本語のシーケンス図をHEX/SVG APIで取得し、ビューアDOMを模した環境でUTF-8の復元・元コードの保持・取得失敗・URL長の上限を確認しています。利用先の描画サーバーとプリザンターの認証・CSP・フォントは、配置後に確認してください。

変更履歴

第1版PlantUMLの画像埋め込みと描画サーバーの設定・制約を解説