Generating Diagrams with Pikchr and WebAssembly
Pikchr is a text-based diagramming language developed by SQLite creator D. Richard Hipp. This article documents a system that converts Pikchr scripts written in Markdown fenced code blocks into SVG with client-side WebAssembly and embeds the results in a page.
End Result
Write a diagram as follows.
```pikchr description="Flow overview"
boxwid = 3.0cm; boxht = 0.8cm;
A: box "Input"
arrow right 2cm from A.e "Process" above
box "Output"
```The diagram is then rendered as SVG on the page.
File Structure
.vitepress/
├── plugins/
│ └── pikchr-plugin.js # markdown-it plugin
└── theme/
└── components/
└── PikchrDiagram.vue # WASM loader and renderer
docs/public/wasm/pikchr/
├── pikchr.mjs # Emscripten glue code
└── pikchr.wasm # Pikchr binarymarkdown-it Plugin
The plugin converts fenced code blocks whose info string begins with pikchr into <PikchrDiagram> components.
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 pikchrPluginRegister the plugin in config.mts.
import pikchrPlugin from './plugins/pikchr-plugin'
export default defineConfig({
markdown: {
config: (md) => {
md.use(pikchrPlugin)
}
}
})Why a Prop Is Used Instead of a Slot
When the content of <Component>slot content</Component> passes through the VNode tree in Vue, line breaks in the slot content are collapsed into spaces. Pikchr treats line breaks as part of its syntax, so passing the source through a slot causes syntax errors.
The plugin therefore encodes the script with Buffer.from(content).toString('base64') and passes it through the source-b64 prop. Props preserve strings without modification, which avoids the problem entirely.
PikchrDiagram Component
The component loads the WASM module and renders the SVG.
Receiving the Source by Decoding Base64
if (props.sourceB64) {
// atob() returns a binary string in which each character represents a byte.
// Decode the bytes as UTF-8 with TextDecoder to recover the Unicode string.
const binaryString = atob(props.sourceB64)
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0))
source = new TextDecoder('utf-8').decode(bytes)
}atob() converts base64 into a binary string in which every character code is a byte value from 0 through 255. Passing that string directly to TextEncoder().encode() re-encodes each byte as a code point, corrupting multibyte UTF-8 characters such as Japanese text. Converting the string through Uint8Array and interpreting it with TextDecoder restores the source correctly.
Loading WASM
Because the WASM module is built with Emscripten, it uses the fetch and Blob URL method described in wasm.md.
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)
})In a Blob context, import.meta.url resolves to blob:http://.... The locateFile option therefore specifies the URL of the .wasm binary explicitly.
Calling Pikchr
The Pikchr C API exposes a single function, _pikchr(sourcePtr).
const encoder = new TextEncoder()
const sourceBytes = encoder.encode(diagramSource + '\0') // null terminator
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)The return value is a pointer to an SVG string in WASM linear memory. The renderer reads HEAPU8 one byte at a time until the null terminator and decodes the collected bytes as UTF-8 with TextDecoder.
Theme-Aware CSS
The SVG generated by Pikchr contains hard-coded colors such as rgb(0,0,0) in inline styles. The stylesheet overrides them with !important and substitutes VitePress theme color variables such as --vp-c-text-1.
The paint-order property prevents label text from becoming difficult to read when it overlaps an arrow.
.diagram-output :deep(svg.pikchr-svg text) {
fill: var(--vp-c-text-1) !important;
/* Draw a background-colored halo beneath the text to obscure arrow lines. */
paint-order: stroke fill;
stroke: var(--vp-c-bg) !important;
stroke-width: 5px;
stroke-linejoin: round;
}paint-order: stroke fill instructs the browser to draw the stroke before the fill. This reverses the normal order, placing a background-colored outline beneath the text and creating a halo that obscures arrow lines. The value of --vp-c-bg changes automatically in dark mode.