Pikchrによる図の挿入
Pikchr(ピクチャー)はSQLiteの作者 D. Richard Hipp が開発したテキストベースの作図言語です。Markdownの fenced code block に記述した Pikchr スクリプトをクライアントサイドの WebAssembly で SVG に変換してページに埋め込む仕組みを記録します。
完成形
以下のように書きます。
```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> コンポーネントに変換します。
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 pikchrPluginconfig.mts でこのプラグインを登録します。
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 デコード)
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 方式を使います。
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.url が blob:http://... に解決されるため,locateFile で .wasm バイナリの URL を明示的に指定します。
Pikchr の呼び出し
Pikchr の C API は _pikchr(sourcePtr) 一関数だけです。
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 で解消します。
.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 が自動的に切り替わります。