社内でスライドやドキュメントを作るとき、フローチャートやシーケンス図を入れたいことってありますよね。 mermaid.js を使えばMarkdownっぽい記法でサクッと図が描けます。
そこで、Googleスライドやドキュメントにそのまま貼れるPNGとしてコピーできる仕組みを、Google Apps Script のウェブアプリで実現してみました。Google Workspace 環境内で安心して使えます。
☝️ Mermaid記法を入力すると右側にプレビューされ、ワンクリックでPNGコピーできます
ただ実際にやってみると意外に難しく、技術的なハマりポイントがありました。特に重要だったのが次の2つです。
- Mermaid.js が生成する SVG はそのままでは foreignObject を使うケースがあり、Apps Script の環境で描画できない
- Webフォントを読み込めないので図から テキストが抜け落ちる
この記事では、ハマりポイントの解決方法だけでなく、アプリを使いやすくする工夫も合わせてご紹介します。そのまま動くコードをすべて掲載しているので、ぜひお試しください。
コピペで動く最小サンプル
実物のApps Scriptはこちらです。
GoogleドライブでGoogle Apps Scriptを作り、HTMLとしてindex
を追加します。
以下のソースコードをコード.gs
とindex.html
にコピペして、ウェブアプリとしてデプロイしてください。
ソースコードを開く
コード.gs
function doGet() {
// index.html を返す。タイトルとスマホ対応のviewportは HtmlService で設定する。
return HtmlService.createHtmlOutputFromFile('index.html')
.setTitle('Mermaid作図ツール')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<style>
:root {
/* 日本語環境でも潰れにくいシステムフォントを指定 */
--ja-font: system-ui, -apple-system, BlinkMacSystemFont,
"Hiragino Sans", "Yu Gothic UI", "Meiryo", sans-serif;
--gap: 16px;
}
body { font-family: var(--ja-font); margin: 16px; }
h1 { font-size: 1.1rem; margin: 0 0 12px; }
/* レイアウト:スマホは縦、PCは2カラム */
.grid { display: grid; grid-template-columns: 1fr; gap: var(--gap); }
@media (min-width: 900px) {
.grid { grid-template-columns: 1fr 1fr; align-items: start; }
}
/* 入力欄 */
textarea {
width: 100%; max-width: 95%; min-height: 200px;
padding: 10px; border: 1px solid #bbb; border-radius: 8px;
font-family: ui-monospace, Menlo, Consolas, monospace;
resize: vertical;
}
/* パネル枠(Mermaid記法・プレビューを囲む) */
.panel { border: 1px solid #ddd; border-radius: 10px; padding: 12px; }
.panel h2 { margin: 0 0 10px; font-size: 1rem; }
/* ボタン */
.btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid #999;
background: #fff; cursor: pointer;
}
.btn:disabled { opacity: .6; cursor: default; }
/* プレビュー画像(PNG表示用 <img>) */
#preview {
display: block;
max-width: 100%; height: auto; background: #fff;
}
/* エラー表示エリア */
#err {
margin-top: 8px; padding: 8px 10px;
border-left: 4px solid #d93025; background: #fdecea; color: #b31412;
border-radius: 6px; display: none; white-space: pre-wrap;
}
/* 裏方要素は非表示(描画用Canvasや生成SVG) */
#canvas, #hidden-img, #stage { display: none; }
</style>
</head>
<body>
<h1>Mermaidで作図してPNGにコピーします</h1>
<div class="grid">
<!-- 左:入力 -->
<div class="panel">
<h2>Mermaid 記法</h2>
<textarea id="src">graph TD;
A[開始] --> B{条件}
B -->|Yes| C[処理1]
B -->|No| D[処理2]
C --> E[終了]
D --> E</textarea>
</div>
<!-- 右:プレビュー+コピー -->
<div class="panel">
<h2>プレビュー</h2>
<!-- 表示用は <img> のみ -->
<img id="preview" alt="preview" />
<!-- エラーを出す場所 -->
<div id="err" role="status" aria-live="polite"></div>
<div style="margin-top:10px;">
<button id="btn-copy" class="btn">PNGでコピー</button>
</div>
</div>
</div>
<!-- 裏方(非表示:MermaidのSVG、Canvas、Image) -->
<div id="stage"></div>
<canvas id="canvas"></canvas>
<img id="hidden-img" alt="" />
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
const $ = (s) => document.querySelector(s);
const state = { lastW: 0, lastH: 0, job: 0 };
// Mermaidの初期化
function initMermaid() {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
themeVariables: { fontFamily: 'var(--ja-font)', fontSize: '12px' },
htmlLabels: false,
flowchart: { htmlLabels: false, useMaxWidth: false }, // foreignObjectを使わない
sequence: { useMaxWidth: false },
er: { useMaxWidth: false },
journey: { useMaxWidth: false }
});
}
// エラーメッセージの表示/クリア
function showError(msg) {
$('#err').textContent = String(msg);
$('#err').style.display = 'block';
$('#btn-copy').disabled = true;
}
function clearError() {
$('#err').textContent = '';
$('#err').style.display = 'none';
$('#btn-copy').disabled = false;
}
// SVGの大きさを測る
function measure(svgEl) {
const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
if (vb && (vb.width || vb.height)) return { width: vb.width, height: vb.height };
const bbox = svgEl.getBBox();
return { width: Math.ceil(bbox.width + bbox.x), height: Math.ceil(bbox.height + bbox.y) };
}
// コピー用ボタンの状態切替
function resetCopyButton() {
const btn = $('#btn-copy');
btn.textContent = 'PNGでコピー';
btn.disabled = false;
}
function setCopyButtonDone() {
const btn = $('#btn-copy');
btn.textContent = 'コピーしました!';
btn.disabled = true;
}
// Mermaid描画処理
async function renderMermaid(text, jobId) {
try {
initMermaid();
$('#stage').innerHTML = '';
try { await document.fonts.ready; } catch {}
const { svg } = await mermaid.render('mm1', text);
if (jobId !== state.job) return; // 古いジョブは破棄
$('#stage').innerHTML = svg;
const svgEl = $('#stage > svg');
if (!svgEl) throw new Error('SVGの生成に失敗しました');
const { width, height } = measure(svgEl);
state.lastW = width; state.lastH = height;
const scale = 2;
const canvas = $('#canvas');
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, width, height);
const svgText = new XMLSerializer().serializeToString(svgEl);
// Blob URLではなくdata:URLを使うことでTaintエラー回避
const svgDataUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgText);
await new Promise((resolve, reject) => {
const img = $('#hidden-img');
img.onload = () => { ctx.drawImage(img, 0, 0); resolve(); };
img.onerror = reject;
img.src = svgDataUrl;
});
if (jobId !== state.job) return;
// Canvas→PNG(base64) を <img> に表示
const dataUrl = canvas.toDataURL('image/png');
$('#preview').src = dataUrl;
$('#preview').style.width = width + 'px';
$('#preview').style.height = height + 'px';
clearError();
} catch (e) {
showError(e?.message || String(e));
$('#preview').removeAttribute('src');
}
}
// デバウンス(タイピング中に無駄描画しない)
function debounce(fn, delay=300) {
let t; return (...args) => { clearTimeout(t); t=setTimeout(()=>fn(...args), delay); };
}
const debouncedRender = debounce(() => {
state.job++;
renderMermaid($('#src').value, state.job);
resetCopyButton();
}, 300);
// クリップボードにPNGコピー
async function copyPng() {
const w = state.lastW, h = state.lastH;
if (!w || !h) return;
const srcCanvas = $('#canvas');
// 高解像度Canvasを等倍に縮小してコピー(貼り付け時に大きすぎないように)
const dstCanvas = document.createElement('canvas');
dstCanvas.width = w; dstCanvas.height = h;
dstCanvas.getContext('2d').drawImage(srcCanvas, 0, 0, w, h);
const blob = await new Promise((res) => dstCanvas.toBlob(res, 'image/png'));
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
setCopyButtonDone();
} catch (e) {
showError('コピーに失敗しました: ' + e);
}
}
// イベント登録
$('#src').addEventListener('input', debouncedRender);
$('#btn-copy').addEventListener('click', copyPng);
// 初期描画
debouncedRender();
</script>
</body>
</html>
技術的ポイント
foreignObject を避けるのと、システムフォントを使うのが一番大事なポイントです。
foreignObjectを避ける
Mermaidが出力するSVGは、デフォルトではテキストの描画に<foreignObject>
を使います。
しかし、Apps Scriptのiframe sandboxはこれを描画できません。図から 文字が消えて しまいます。
👉 対策:htmlLabels: false
を設定して、純粋な<text>
でテキストを描画する。
なお、今回の実装では Mermaid.js v10 を利用しています。
理由は htmlLabels: false
が効きやすく、Apps Script環境で <foreignObject>
を避けるのに安定しているためです(v11で使えるようになった、Kanban / Packet / Architecture などは使えません)。
システムフォントを使う
Apps Scriptのiframe sandbox内ではWebフォントが読み込めないようです。これも図から 文字が消えて しまいます。
👉 対策:システムフォントを指定しておく。
mermaid.initialize({
startOnLoad: false,
theme: 'default',
themeVariables: { fontFamily: 'var(--ja-font)', fontSize: '12px' },
htmlLabels: false,
flowchart: { htmlLabels: false, useMaxWidth: false }, // foreignObjectを使わない
sequence: { useMaxWidth: false },
er: { useMaxWidth: false },
journey: { useMaxWidth: false }
});
これで OS 標準フォントから適切に選ばれるので安定します。
SVG → Canvas → PNG
- SVG文字列を Blob 化
-
<img>
に読み込む - Canvas に
drawImage
-
canvas.toDataURL('image/png')
で PNG 化
一般的に SVG → Canvas の変換には canvg がよく使われます。
しかし今回は <img>
と <canvas>
の標準機能だけで変換できるので、追加ライブラリなしで軽量に実装 できました。 依存を減らせる分、メンテナンス性も高まります。
PNGであれば、Apps ScriptのSlidesApp.insertImage(blob)
を使ってGoogleスライドに挿入することも可能です。
なお、PNGではなくWEBPに変換することも可能です。
より軽量の画像データになりますが、SlidesApp.insertImage(blob)
では使えません(GIF / JPEG / PNGのみ)。
高解像度Canvasでくっきり出力
ディスプレイ上では綺麗に見えても、そのままコピーするとスライドやドキュメント上でぼやけてしまうことがあります。
今回の実装では、canvas
に 2倍の解像度(scale=2)でレンダリング し、最後に 等倍に縮小してコピー することで、「貼り付け時に大きすぎないのに文字はくっきり」したPNGを実現しています。
PNGとしてコピー
navigator.clipboard.write
を使えば画像もコピー可能です。
canvas.toBlob(async (blob) => {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
});
ワンクリックでPNGがコピーでき、GoogleスライドやGoogleドキュメントに貼り付けられます。
はい、承知いたしました。
Qiita記事の「技術的ポイント」セクションに追加する、今回の修正(data: URL
の利用)に関する解説文をご提案します。
「SVG → Canvas → PNG」の項目の後に追加するのがおすすめです。
data:URL
を使い、CanvasのTaintエラーを回避する
生成したSVGをBlob
URLに変換して<img>
タグに読み込ませる方法も動作します。特定の環境下ではCanvasが「汚染された(Tainted)」と判定されるセキュリティエラーを引き起こす可能性があります。
これは、ブラウザがBlob
URLを別オリジンからのリソースとして扱い、セキュリティのためにCanvasからのデータ読み出し(.toDataURL()
)をブロックするために起こります。
👉 対策: Blob
URLの代わりに**data:URL
**を利用する。
data:URL
は、リソースのデータを直接URL文字列に埋め込む方式です。これにより、オリジンの問題を根本的に回避でき、どんな環境でも安定してCanvasへの描画とPNGへのエクスポートが可能になります。
リアルタイムプレビュー(デバウンス付き)
テキスト入力に応じて自動でプレビューを更新します。
ただし、入力のたびに即座に描画すると負荷が高くなるため、300msのデバウンスをかけて「タイピングが一段落した時にまとめて描画」するようにしています。
エラー処理
Mermaid記法に誤りがある場合は、プレビュー下にエラーメッセージを表示するようにしています。
これにより、コピー前に入力の間違いに気づけるため、利用者に優しい仕組みになっています。
ブラウザーで完結し組織外にデータが出ていかない
今回の仕組みでは お使いのGoogle Workspace環境の外にデータは飛びません。
- 描画はブラウザ内で完結
- コピーはローカルのクリップボードへ
ライブラリ本体(mermaid.js)はCDN経由で読み込んでいますが、作成した図の内容が外部に送信されることはありません。
社外にデータが出ない仕組みなので、社内利用でも安心です。もし気になる場合は、npm等でバンドルしてApps Script内に取り込むこともできます。
テキストから図を描画する別のツールでPlantUMLがありますが、PlantUMLはサーバーで描画するアーキテクチャーでした。組織内での利用に閉じるためにはサーバーを準備する必要がありましたが、今回のやり方ではその必要がありません。
まとめ
Apps Script × Mermaid.js で 図の自動生成 + PNGコピー を実現できました。
ハマりどころは foreignObject とフォントです。WebフォントをすべてSVGに埋め込む荒技もありますが、今回はシステムフォントを指定して軽量にしています。
PNGとしてコピーできるだけでもいろいろな用途に使えますが、Apps Scriptを使うことで、Googleスライドへの画像挿入などにも応用できます。スプレッドシートと連携して、Mermaidのテキストを保存しておけば再利用もしやすくなります。
Mermaidで作図の時短、ぜひお試しください。