Skip to content
Copied!
published on 2026-04-05

図(figure)プラグイン

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

完成形

完成後は,以下のように書けます。

単一画像にキャプションを付ける:

markdown
::: figure
![図の説明|600px](./image.png)

**Figure 1:** キャプション本文。図番号がboldになります。
:::

複数画像を横に並べる:

markdown
::: figure
![左の図|45%](./img1.png)
![右の図|45%](./img2.png)

**Figure 2:** 左右の比較。画面幅が狭いスマートフォンでは自動的に縦に並びます。
:::

画像をテキストに回り込ませる:

markdown
::: figure
![サムネイル|200px|right](./thumbnail.png)

**Figure 3:** キャプションは画像幅で折り返されます。(省略可)
:::

後続のテキストが画像の左側に回り込みます…

alt テキストに | 区切りでディレクティブを指定します。サイズと配置は順序を問わず併用できます(|200px|right でも |right|200px でも同じ)。

書き方意味
|300300px(単位省略。Obsidian互換)
|300px300px
|50%親要素の幅の50%
|right右フロート(後続テキストが左側に回り込む)
|left左フロート(後続テキストが右側に回り込む)

キャプションは省略できます。キャプションあり・なしのいずれも,画像は中央揃えになります(フロートの場合を除く)。

実装

package.json

コンテナ構文(:::figure)の解析に markdown-it-container を使います。devDependencies に追加します。

package.json
json
{
  "devDependencies": {
    "markdown-it-container": "^3.0.0",
    "vitepress": "next",
    ...
  }
}

package.json を変更したあとは,コンテナを再ビルドし直す必要があります。

sh
$ 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
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: ![alt|300px](src)       — width only
  //         ![alt|300px|right](src) — width + float
  //         ![alt|right|300px](src) — 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つの処理から構成されています。

  1. parseAltDirectives() ヘルパー。alt テキストを | で分割し,数値+単位パターンは size(CSS 長さ値)に,left / rightfloatDir に,それ以前の文字列は表示用 altText に振り分けます。順序非依存の設計なので |300px|right でも |right|300px でも正しく解析されます。

  2. 画像ディレクティブの処理md.renderer.rules.image のオーバーライド)。parseAltDirectives() でディレクティブを解析し,フロートがない場合は <img>width を inline style として付与します。フロートがある場合,サイズは後述のとおり <figure> 側に持たせるため,<img> には何も付与しません。どちらの場合も,alt テキストから | 以降のディレクティブをすべて除去します。

  3. figureコンテナの処理markdown-it-container を使用)。:::figure::: の範囲を <figure class="md-figure"> タグで囲みます。開きタグを生成する際,内部のトークン列を先読みしてフロートディレクティブを検出し,見つかれば md-float--right / md-float--left クラスと style="width:..."<figure> に付与します。

{width=...} 記法が使えない理由

直感的には ![alt](src){width=600px} という書き方も考えられますが,VitePressはMarkdown内の {...} をVueテンプレート構文として処理する都合上,markdown-itがトークンを解析する段階でその部分がすでに除去されています。|サイズ をalt テキスト内に収める方式はVitePress側の処理に影響されないため,確実に動作します。また,ObsidianがリサイズにVの ![alt|幅](src) を標準サポートしていることとも一致します。

config.mts への登録

config.mts にimportとプラグインの適用を追記します。

.vitepress/config.mts
ts
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
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,それ以降のキャプション本文が通常の書体になります。