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

Pikchrによる図の挿入

Pikchr(ピクチャー)はSQLiteの作者 D. Richard Hipp が開発したテキストベースの作図言語です。Markdownの fenced code block に記述した Pikchr スクリプトをクライアントサイドの WebAssembly で SVG に変換してページに埋め込む仕組みを記録します。

完成形

以下のように書きます。

markdown
```pikchr description="フローの概略"
boxwid = 3.0cm; boxht = 0.8cm;
A: box "入力"
arrow right 2cm from A.e "処理" above
box "出力"
```

するとページ上に SVG 図として表示されます。

ファイル構成

.vitepress/
├── plugins/
│   └── pikchr-plugin.js       # markdown-it プラグイン
└── theme/
    └── components/
        └── PikchrDiagram.vue  # WASM ローダー・レンダラー

docs/public/wasm/pikchr/
├── pikchr.mjs                 # Emscripten グルーコード
└── pikchr.wasm                # Pikchr バイナリ

markdown-it プラグイン

pikchr info string で始まる fenced code block を <PikchrDiagram> コンポーネントに変換します。

.vitepress/plugins/pikchr-plugin.js
js
function pikchrPlugin(md) {
  const defaultRender = md.renderer.rules.fence || function(tokens, idx, options, env, renderer) {
    return renderer.renderToken(tokens, idx, options)
  }

  md.renderer.rules.fence = function(tokens, idx, options, env, renderer) {
    const token = tokens[idx]
    const info = token.info ? md.utils.unescapeAll(token.info).trim() : ''

    if (info.startsWith('pikchr')) {
      const content = token.content.trim()

      // Base64-encode the source so it survives Vue's VNode serialization
      // without any newline collapsing. Decoded in PikchrDiagram via atob().
      const sourceB64 = Buffer.from(content).toString('base64')

      // Parse attributes from info string
      // Format: pikchr description="hello world" width="400px"
      const attributes = {}
      const attrRegex = /(\w+)=["']([^"']*)["']/g
      let match

      while ((match = attrRegex.exec(info)) !== null) {
        attributes[match[1]] = match[2]
      }

      const componentAttrs = Object.entries(attributes)
        .map(([key, value]) => {
          const vueKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
          return `${vueKey}="${value}"`
        })
        .join(' ')

      const attrString = componentAttrs
        ? `source-b64="${sourceB64}" ${componentAttrs}`
        : `source-b64="${sourceB64}"`

      return `<ClientOnly>\n  <PikchrDiagram ${attrString} />\n</ClientOnly>`
    }

    return defaultRender(tokens, idx, options, env, renderer)
  }
}

export default pikchrPlugin

config.mts でこのプラグインを登録します。

.vitepress/config.mts
ts
import pikchrPlugin from './plugins/pikchr-plugin'

export default defineConfig({
  markdown: {
    config: (md) => {
      md.use(pikchrPlugin)
    }
  }
})

スロットではなく prop を使う理由

Vue では <Component>スロット本文</Component> のスロットコンテンツが VNode ツリーを経由する際に改行が空白へ潰れます。Pikchr は行区切りを文法の一部として使うため,スロット経由では構文エラーになります。

そのため,プラグイン側でスクリプト本文を Buffer.from(content).toString('base64') で base64 エンコードし,source-b64 prop として渡します。prop は文字列を加工なしで保持するため,この問題が完全に回避できます。

PikchrDiagram コンポーネント

WASM のロードと SVG レンダリングを担当します。

ソースの受け取り(base64 デコード)

js
if (props.sourceB64) {
  // atob() はバイナリ文字列(各文字がバイト値)を返すが,
  // Unicode 文字列ではないため,TextDecoder で UTF-8 として再解釈する。
  const binaryString = atob(props.sourceB64)
  const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0))
  source = new TextDecoder('utf-8').decode(bytes)
}

atob() は base64 → バイナリ文字列の変換であり,各文字コードはバイト値(0〜255)です。そのまま TextEncoder().encode() に渡すと,バイト値をコードポイントとして再エンコードするため UTF-8 マルチバイト文字(日本語など)が二重エンコードされて文字化けします。Uint8Array を経由して TextDecoder で解釈することで正しく復元できます。

WASM のロード

WASM は Emscripten でビルドされているため wasm.md で説明した fetch + Blob URL 方式を使います。

js
const mjsPath = withBase('/wasm/pikchr/pikchr.mjs')
const response = await fetch(mjsPath)
const jsText = await response.text()
const blob = new Blob([jsText], { type: 'text/javascript' })
const blobUrl = URL.createObjectURL(blob)
let pikchrWasmModule
try {
  pikchrWasmModule = await import(/* @vite-ignore */ blobUrl)
} finally {
  URL.revokeObjectURL(blobUrl)
}
const pikchrModule = await pikchrWasmModule.default({
  locateFile: (path) => withBase('/wasm/pikchr/' + path)
})

Blob コンテキストでは import.meta.urlblob:http://... に解決されるため,locateFile.wasm バイナリの URL を明示的に指定します。

Pikchr の呼び出し

Pikchr の C API は _pikchr(sourcePtr) 一関数だけです。

js
const encoder = new TextEncoder()
const sourceBytes = encoder.encode(diagramSource + '\0')  // null 終端
const sourcePtr = pikchrModule._malloc(sourceBytes.length)
pikchrModule.HEAPU8.set(sourceBytes, sourcePtr)

const resultPtr = pikchrModule._pikchr(sourcePtr)
const svg = readStringFromWasm(pikchrModule, resultPtr)
pikchrModule._free(resultPtr)
pikchrModule._free(sourcePtr)

戻り値は WASM 線形メモリ内の SVG 文字列へのポインターです。HEAPU8 を 1 バイトずつ読んで null 終端まで収集し,TextDecoder で UTF-8 デコードします。

テーマ対応 CSS

Pikchr が生成する SVG には rgb(0,0,0) などのハードコードされた色が inline style として付いています。!important で上書きし,VitePress のテーマカラー変数(--vp-c-text-1 など)に差し替えます。

ラベルテキストが矢印線と重なって読みにくくなる問題は,paint-order で解消します。

css
.diagram-output :deep(svg.pikchr-svg text) {
  fill: var(--vp-c-text-1) !important;
  /* テキストの下に背景色のハローを描いて矢印線を隠す */
  paint-order: stroke fill;
  stroke: var(--vp-c-bg) !important;
  stroke-width: 5px;
  stroke-linejoin: round;
}

paint-order: stroke fill は,ストロークをフィルより先に描画するよう指示します。通常とは逆順なので,背景色の白いアウトラインがテキストの下に配置され,矢印線を隠すハロー効果が得られます。ダークモードでも --vp-c-bg が自動的に切り替わります。