HTML・MarkdownへPlantUMLの図を埋め込む
図の定義を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のライブラリは読み込みません。
描画スクリプトの全文
(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;
});
})();スタイルの全文
.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を利用先へ置き換えてください。
window.PleasanterDiagramsConfig = {
plantUmlServer: 'https://diagrams.example.com/plantuml'
};同じオリジンへリバースプロキシする場合は、相対URLも指定できます。
window.PleasanterDiagramsConfig = {
plantUmlServer: document.getElementById('ApplicationPath').value + 'diagram-renderer'
};これだけでバックエンドができるわけではありません。そのパスをPlantUML Serverのルートへ転送し、/svg/...を扱う配信構成を別途用意します。
描画サーバーはブラウザから接続できる必要があります。プリザンターのサーバーだけが接続できても画像は取得できません。HTTPSの画面ではHTTPSの配信先を使い、CSPのimg-srcにも合わせます。URL内のユーザー名・パスワードはサンプルで拒否します。
2. 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にします。
[md]
## データの関係
```plantuml
@startuml
class Project {
+Id : long
+Title : string
}
class Task {
+Id : long
+ProjectId : long
}
Project "1" -- "0..*" Task
@enduml
```本体は言語名からlanguage-plantumlを付けるため、表示用コードを判別できます。
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-plantumlを読み、画像の取得成功後にそのpreを隠します。コピー用コードとボタンは残します。ビューア無効、リッチテキスト、外部のMarkdown表示には適用されません。Markdownへ生のHTMLを貼る方法も、本体がエスケープするため使いません。
URLを組み立てる仕組み
TextEncoderで文字列をUTF-8へ変換し、バイトごとに2桁の16進数へ変換して、~hを先頭に付けます。日本語をJavaScriptの文字コード単位で変換する方法とは異なります。
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・フォントは、配置後に確認してください。