図(figure)プラグイン
標準的なMarkdown記法では,画像を挿入する手段は  しかなく,キャプションの付与や画像の横並べ,サイズ指定,テキスト回り込みといった,技術文書や学術文書で必要になる機能が欠けています。本稿では,これらを実現するカスタムmarkdown-itプラグインの実装を記録します。
完成形
完成後は,以下のように書けます。
単一画像にキャプションを付ける:
::: figure

**Figure 1:** キャプション本文。図番号がboldになります。
:::複数画像を横に並べる:
::: figure


**Figure 2:** 左右の比較。画面幅が狭いスマートフォンでは自動的に縦に並びます。
:::画像をテキストに回り込ませる:
::: figure

**Figure 3:** キャプションは画像幅で折り返されます。(省略可)
:::
後続のテキストが画像の左側に回り込みます…alt テキストに | 区切りでディレクティブを指定します。サイズと配置は順序を問わず併用できます(|200px|right でも |right|200px でも同じ)。
| 書き方 | 意味 |
|---|---|
|300 | 300px(単位省略。Obsidian互換) |
|300px | 300px |
|50% | 親要素の幅の50% |
|right | 右フロート(後続テキストが左側に回り込む) |
|left | 左フロート(後続テキストが右側に回り込む) |
キャプションは省略できます。キャプションあり・なしのいずれも,画像は中央揃えになります(フロートの場合を除く)。
実装
package.json
コンテナ構文(:::figure)の解析に markdown-it-container を使います。devDependencies に追加します。
{
"devDependencies": {
"markdown-it-container": "^3.0.0",
"vitepress": "next",
...
}
}package.json を変更したあとは,コンテナを再ビルドし直す必要があります。
$ docker compose down
$ docker volume rm vitepress_node_modules
$ docker compose build --no-cache
$ docker compose up -dプラグインコード
.vitepress/plugins/figure-plugin.js を以下の内容で作成します。
.vitepress/plugins/figure-plugin.js
import container from 'markdown-it-container'
// Parse |size and |float directives from raw alt text.
// Returns { altText, size, floatDir }.
// altText — text before the first '|' (rendered as alt attribute)
// size — CSS length string (e.g. "300px", "50%") or null
// floatDir — 'left', 'right', or null
function parseAltDirectives(rawAlt) {
const pipeIdx = rawAlt.indexOf('|')
if (pipeIdx === -1) return { altText: rawAlt, size: null, floatDir: null }
const altText = rawAlt.slice(0, pipeIdx)
const suffixes = rawAlt.slice(pipeIdx + 1).split('|')
let size = null
let floatDir = null
for (const part of suffixes) {
const p = part.trim()
if (/^(left|right)$/i.test(p)) {
floatDir = p.toLowerCase()
} else if (/^\d+(?:\.\d+)?(?:px|%|em|rem|vw|vh)?$/.test(p)) {
// Unit-less numbers → px (Obsidian compatible)
size = /^\d+(?:\.\d+)?$/.test(p) ? p + 'px' : p
}
}
return { altText, size, floatDir }
}
function figurePlugin(md) {
// ── 1. Image directives via alt text ────────────────────────────────────
// Syntax:  — width only
//  — width + float
//  — same, order-independent
//
// When float is present, width is applied to the <figure> element (by the
// container renderer below); the <img> fills it via CSS (width: 100%).
// When there is no float, width is applied as an inline style on <img>.
const defaultImageRenderer = md.renderer.rules.image
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const rawAlt = self.renderInlineAsText(token.children, options, env)
const { altText, size, floatDir } = parseAltDirectives(rawAlt)
if (size !== null || floatDir !== null) {
// Apply width to <img> only when not floating — floated figures own
// the width via an inline style on <figure>.
if (size && !floatDir) {
const existing = token.attrGet('style') || ''
token.attrSet('style', (existing ? existing + ' ' : '') + `width:${size};`)
}
// Strip all |directives from the rendered alt attribute.
const lastChild = token.children && token.children[token.children.length - 1]
if (lastChild && lastChild.type === 'text') {
lastChild.content = altText
}
}
if (defaultImageRenderer) {
return defaultImageRenderer(tokens, idx, options, env, self)
}
token.attrSet('alt', self.renderInlineAsText(token.children, options, env))
return self.renderToken(tokens, idx, options)
}
// ── 2. :::figure container ───────────────────────────────────────────────
// Wraps content in <figure class="md-figure">.
// The last <p> inside the figure is treated as the caption via CSS
// (.md-figure > p:last-child:not(:only-child)).
//
// When any image inside the figure carries a |left or |right directive,
// the <figure> element receives the md-float--{dir} class and a width
// inline style. The image's own width is then controlled by CSS
// (.md-float--right img { width: 100% }) rather than an inline style.
md.use(container, 'figure', {
render (tokens, idx) {
if (tokens[idx].nesting === 1) {
// Scan ahead in the token stream for a float directive. The image
// renderer has not yet run at this point, so token.children still
// hold the unmodified alt text.
let floatDir = null
let floatSize = null
outer: for (let i = idx + 1; i < tokens.length; i++) {
const t = tokens[i]
if (t.type === 'container_figure_close') break
if (t.type !== 'inline' || !t.children) continue
for (const child of t.children) {
if (child.type !== 'image') continue
const rawAlt = child.children
? child.children.map(c => c.content).join('')
: ''
const parsed = parseAltDirectives(rawAlt)
if (parsed.floatDir) {
floatDir = parsed.floatDir
floatSize = parsed.size
break outer
}
}
}
if (floatDir) {
const styleAttr = floatSize ? ` style="width:${floatSize};"` : ''
return `<figure class="md-figure md-float--${floatDir}"${styleAttr}>\n`
}
return '<figure class="md-figure">\n'
} else {
return '</figure>\n'
}
},
})
}
export default figurePluginプラグインは3つの処理から構成されています。
parseAltDirectives()ヘルパー。alt テキストを|で分割し,数値+単位パターンはsize(CSS 長さ値)に,left/rightはfloatDirに,それ以前の文字列は表示用altTextに振り分けます。順序非依存の設計なので|300px|rightでも|right|300pxでも正しく解析されます。画像ディレクティブの処理(
md.renderer.rules.imageのオーバーライド)。parseAltDirectives()でディレクティブを解析し,フロートがない場合は<img>にwidthを inline style として付与します。フロートがある場合,サイズは後述のとおり<figure>側に持たせるため,<img>には何も付与しません。どちらの場合も,alt テキストから|以降のディレクティブをすべて除去します。figureコンテナの処理(
markdown-it-containerを使用)。:::figure~:::の範囲を<figure class="md-figure">タグで囲みます。開きタグを生成する際,内部のトークン列を先読みしてフロートディレクティブを検出し,見つかればmd-float--right/md-float--leftクラスとstyle="width:..."を<figure>に付与します。
{width=...} 記法が使えない理由
直感的には {width=600px} という書き方も考えられますが,VitePressはMarkdown内の {...} をVueテンプレート構文として処理する都合上,markdown-itがトークンを解析する段階でその部分がすでに除去されています。|サイズ をalt テキスト内に収める方式はVitePress側の処理に影響されないため,確実に動作します。また,ObsidianがリサイズにVの  を標準サポートしていることとも一致します。
config.mts への登録
config.mts にimportとプラグインの適用を追記します。
import figurePlugin from './plugins/figure-plugin.js'
export default defineConfig({
markdown: {
config: (md) => {
md.use(figurePlugin)
}
}
})CSS
figure タグ・キャプション・複数画像の横並び・フロートをスタイリングします。.vitepress/theme/custom.css に追記します。
.vitepress/theme/custom.css
/* ── Figure ─────────────────────────────────────────────────────── */
.vp-doc .md-figure {
display: flex;
flex-direction: column;
align-items: center;
margin: 2rem auto;
text-indent: 0;
}
/* Images paragraph: flex row, wraps on narrow screens */
.vp-doc .md-figure > p:not(:last-child) {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
margin: 0;
text-indent: 0;
}
/* Hide <br> inserted by breaks:true between images in the same paragraph */
.vp-doc .md-figure > p:not(:last-child) br {
display: none;
}
.vp-doc .md-figure img {
display: block;
max-width: 100%;
height: auto;
}
/* Center block image when it is the only child (no caption, non-float) */
.vp-doc .md-figure:not(.md-float--right):not(.md-float--left) > p:only-child img {
margin-left: auto;
margin-right: auto;
}
/* Last paragraph → caption (only when there is more than one child) */
.vp-doc .md-figure > p:last-child:not(:only-child) {
margin-top: 0.6rem;
font-size: 0.88em;
line-height: 1.5;
color: var(--vp-c-text-2);
text-align: center;
text-indent: 0;
}
/* Figure number (the **bold** part) stays bold; rest is normal weight */
.vp-doc .md-figure > p:last-child:not(:only-child) strong {
font-weight: 600;
color: var(--vp-c-text-1);
}
/* ── Float figure ────────────────────────────────────────────────── */
.vp-doc .md-figure.md-float--right {
float: right;
display: block;
margin: 0 0 1rem 1.5rem;
}
.vp-doc .md-figure.md-float--left {
float: left;
display: block;
margin: 0 1.5rem 1rem 0;
}
/* All paragraphs inside a float figure use block layout */
.vp-doc .md-figure.md-float--right > p,
.vp-doc .md-figure.md-float--left > p {
display: block;
width: 100%;
margin: 0;
text-indent: 0;
}
/* Hide <br> in float figures too */
.vp-doc .md-figure.md-float--right > p br,
.vp-doc .md-figure.md-float--left > p br {
display: none;
}
/* Image fills the float figure width */
.vp-doc .md-figure.md-float--right img,
.vp-doc .md-figure.md-float--left img {
width: 100%;
margin: 0;
}
/* Caption inside float figure: restore top margin overridden by the p rule */
.vp-doc .md-figure.md-float--right > p:last-child:not(:only-child),
.vp-doc .md-figure.md-float--left > p:last-child:not(:only-child) {
margin-top: 0.4rem;
}設計上のポイントを説明します。
複数画像の横並びは,画像段落(最後の段落以外)に
display: flexを設定することで実現しています。flex-wrap: wrapにより,画面幅が足りない場合は自動的に折り返して縦に並びます。スマートフォン縦持ちで閲覧したときも自然に表示されます。キャプションは最後の
<p>要素として扱われます。<figure>タグの意味論的には<figcaption>を使うのが正確ですが,コンテナ内のMarkdownをmarkdown-itが通常どおりHTMLに変換する仕組みを利用しており,最後の段落をCSSでキャプションスタイルに見せています。キャプションセレクタを:last-child:not(:only-child)とすることで,キャプションがない場合(<p>が1つだけ)にキャプション用のfont-size: 0.88emなどが画像段落に誤って当たらないようにしています。キャプションなしでも中央揃えになります。キャプションがある場合,画像段落は
:not(:last-child)としてdisplay: flex; justify-content: centerにより中央揃えになります。キャプションがない場合,画像段落は:only-childとなりこのルールが適用されません。:only-child img { margin: 0 auto }で補います。フロート時は
<img>ではなく<figure>をフロートします。通常の figure はdisplay: flex(縦方向のフレックスコンテナ)です。CSS仕様上,フレックスアイテムにfloatは効きません。そのため,<img>に float を付けるのではなく,<figure>にdisplay: block; float: right/leftを付け,<figure>ごとフロートさせます。このとき幅は<figure>の inline style で持ち,内部の<img>はwidth: 100%でそれを満たします。図番号のboldと本文の書き分けは,キャプション内で
**Figure 1:**と書くと<strong>タグが書かれboldになります。その部分にだけfont-weight: 600と濃い文字色を指定することで,図番号がbold,それ以降のキャプション本文が通常の書体になります。