canvasdown
v0.1.0
Published
Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.
Maintainers
Readme
canvasdown
Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.
canvasdown renders Markdown directly to HTML Canvas — no DOM, no layout reflow, no browser rendering pipeline. Powered by Pretext, which measures text ~300,000x faster than getBoundingClientRect.
Works in browser, Node.js, Web Workers, and WebGL contexts.
Why canvasdown?
| | react-markdown | Satori (Vercel) | canvasdown | |---|---|---|---| | Input | Markdown | JSX | Markdown | | Output | DOM (HTML) | SVG | Canvas / PNG | | DOM reflow | Every render | N/A | Never | | Streaming (AI chat) | Slow | No | Yes | | Node.js | SSR only | Yes | Yes | | Web Worker | No | No | Yes | | Per-char animation | No | No | Yes | | Text along path | No | No | Yes | | Virtual list heights | Estimate | No | Exact | | OG image generation | html2canvas | Yes | Yes |
Install
npm install canvasdownQuick Start
import { render, darkTheme } from 'canvasdown'
const markdown = `
# Hello canvasdown
Render **Markdown** to Canvas with *no DOM reflow*.
- Zero DOM reads
- Runs in Node.js
- Streams token-by-token
`
const canvas = document.getElementById('output') as HTMLCanvasElement
render(markdown, canvas, { width: 800, theme: darkTheme })API Reference
Core
render(markdown, canvas, options?)
Render markdown to a canvas element. Automatically sets canvas width/height and handles HiDPI.
import { render } from 'canvasdown'
render(markdown, canvas, {
width: 800, // canvas width in px (default: 800)
theme: 'dark', // 'dark' | 'light' | Theme object
devicePixelRatio: 2, // HiDPI (default: window.devicePixelRatio)
})measure(markdown, options?)
Measure layout without rendering — get exact height before touching the canvas. Essential for virtual lists.
import { measure } from 'canvasdown'
const { totalHeight, blocks } = measure(markdown, { width: 680, theme: 'dark' })
console.log(`This markdown will be ${totalHeight}px tall`)exportPNG(markdown, options?)
Export as PNG dataURL. Works in browser and Node.js (with canvasFactory).
import { exportPNG } from 'canvasdown'
const dataURL = await exportPNG(markdown, {
width: 1200,
devicePixelRatio: 2, // @2x = 2400px wide PNG
theme: 'dark',
})exportBlob(markdown, options?)
Export as Blob for upload or download.
import { exportBlob } from 'canvasdown'
const blob = await exportBlob(markdown, { width: 1200 })
const formData = new FormData()
formData.append('image', blob, 'og-image.png')Streaming (for AI Chat)
Stream markdown token-by-token with zero reflow. Built for LLM streaming responses.
import { createStream } from 'canvasdown/stream'
const canvas = document.getElementById('chat-canvas') as HTMLCanvasElement
const stream = createStream(canvas, { width: 680, theme: 'dark' })
// Connect to your LLM stream (OpenAI, Anthropic, etc.)
const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) })
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
stream.append(decoder.decode(value)) // only re-paints changed lines
}
stream.flush() // finalize
stream.destroy() // cleanup on unmountStream API:
type StreamHandle = {
append(chunk: string): void // add text, schedules rAF render
flush(): void // immediate render, skip rAF
getLayout(): DocumentLayout | null // current layout state
destroy(): void // cancel pending rAF, cleanup
}Advanced Features
Import from canvasdown/advanced:
import { shrinkwrap, fitText, createOverlay, renderTextOnPath, animateText, updateRender } from 'canvasdown/advanced'shrinkwrap(markdown, options?) — Auto-fit container
Find the tightest width that fits the text without overflow (binary search).
const { width, height } = shrinkwrap('Hello **world**', {
minWidth: 100,
maxWidth: 800,
theme: 'dark',
})
// width = smallest px where text doesn't overflowfitText(markdown, options?) — Shrink font to fit box
Find the largest font size where text fits within a container.
const { fontSize, lineHeight, height } = fitText(markdown, {
container: { width: 400, height: 200 },
fontSizeMin: 10,
fontSizeMax: 48,
theme: 'dark',
})
// fontSize = largest size that still fitscreateOverlay(canvas, layout) — Semantic highlight
Add highlight overlay without re-rendering the base canvas.
const layout = measure(markdown, { width: 680, theme: 'dark' })
render(markdown, canvas, { width: 680, theme: 'dark' })
const overlay = createOverlay(canvas, layout)
overlay.highlight('pretext', { background: '#ffff00', opacity: 0.4 })
overlay.highlight('canvas', { background: '#00ffff', opacity: 0.3 })
overlay.clear() // remove all highlights
overlay.destroy() // remove overlay elementrenderTextOnPath(ctx, text, svgPath, options?) — Text along curves
Render text following any SVG path — not possible with DOM or SVG text.
renderTextOnPath(ctx, 'canvasdown ✦ canvas text along any curve', 'M 0 100 Q 200 0 400 100', {
font: '700 20px Inter',
color: '#f97316',
offset: 10, // start offset in px
})Supports SVG path commands: M, L, Q (quadratic), C (cubic bezier).
animateText(canvas, markdown, animOptions, renderOptions?) — Per-character animation
Animate individual characters with independent transforms.
const anim = animateText(canvas, markdown, {
effect: 'wave', // 'wave' | 'fadeIn' | 'typewriter' | 'bounce' | 'explode'
stagger: 30, // ms delay between characters
duration: 600, // ms per character animation
loop: false,
onComplete: () => console.log('done'),
})
anim.start()
anim.stop()
anim.reset()updateRender(prev, next, canvas, options?) — Diff-aware re-render
Re-render only what changed. Efficient for live preview / collaborative editors.
updateRender(oldMarkdown, newMarkdown, canvas, { width: 680, theme: 'dark' })Node.js Support
Inject a canvas factory to use canvasdown server-side:
import { exportPNG } from 'canvasdown'
import { createCanvas } from '@napi-rs/canvas'
const png = await exportPNG(markdown, {
width: 1200,
devicePixelRatio: 2,
theme: 'dark',
canvasFactory: (w, h) => createCanvas(w, h),
})
// Save to file
import { writeFileSync } from 'fs'
const base64 = png.split(',')[1]!
writeFileSync('og-image.png', Buffer.from(base64, 'base64'))npm install @napi-rs/canvas # Rust-based, pre-built ARM64 binariesReact Component
import { createCanvasdownComponent } from 'canvasdown/react'
import React, { useState } from 'react'
const Canvasdown = createCanvasdownComponent(React)
function App() {
const [height, setHeight] = useState(0)
return (
<div style={{ position: 'relative' }}>
<Canvasdown
markdown={content}
width={680}
theme="dark"
className="chat-canvas"
onHeightChange={setHeight}
onRender={(layout) => console.log('blocks:', layout.blocks.length)}
/>
</div>
)
}Web Worker + OffscreenCanvas
Render off the main thread — keep UI at 60fps during heavy markdown rendering.
// main.ts
const worker = new Worker(new URL('./canvasdown.worker.js', import.meta.url))
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ type: 'render', markdown, width: 800, theme: 'dark', dpr: 2 }, [offscreen])
worker.onmessage = (e) => console.log('rendered:', e.data.height, 'px')// canvasdown.worker.js
import { handleWorkerMessage } from 'canvasdown/worker'
let offscreen: OffscreenCanvas
self.onmessage = (e) => {
if (e.data.type === 'render') {
if (!offscreen) offscreen = e.ports[0] ?? e.data.canvas
const result = handleWorkerMessage(e.data, offscreen)
self.postMessage(result)
}
}Markdown Support
| Element | Syntax | Status |
|---|---|---|
| Heading H1–H6 | # H1 … ###### H6 | ✅ |
| Paragraph | plain text | ✅ |
| Bold | **text** | ✅ |
| Italic | *text* | ✅ |
| Bold italic | ***text*** | ✅ |
| Inline code | `code` | ✅ |
| Link | [text](url) | ✅ underline + color |
| Strikethrough | ~~text~~ | ✅ |
| Code block | ```lang | ✅ |
| Blockquote | > text | ✅ |
| Unordered list | - item | ✅ |
| Ordered list | 1. item | ✅ |
| Nested list | indented items | ✅ |
| Task list | - [x] done | ✅ checkbox rendering |
| Table | \| col \| | ✅ |
| Image |  | ✅ placeholder on load |
| Horizontal rule | --- | ✅ |
| CJK (Chinese/Japanese/Korean) | 春天到了 | ✅ via Pretext |
| Emoji | 🎉🚀 | ✅ |
| Arabic / RTL | بدأت الرحلة | 🔜 planned |
| Syntax highlight | ```ts | 🔜 planned |
Themes
Built-in themes
import { darkTheme, lightTheme } from 'canvasdown'
render(markdown, canvas, { theme: 'dark' }) // #0d1117 background
render(markdown, canvas, { theme: 'light' }) // #ffffff backgroundCustom theme
import type { Theme } from 'canvasdown'
const myTheme: Theme = {
background: '#1a1b26',
text: '#c0caf5',
mutedText: '#565f89',
link: { color: '#7aa2f7' },
heading: {
color: '#c0caf5',
sizes: [36, 28, 22, 18, 16, 14],
weights: ['700', '600', '600', '600', '500', '500'],
lineHeights: [48, 38, 32, 28, 26, 24],
},
code: {
background: '#16161e',
color: '#c0caf5',
borderColor: '#292e42',
fontFamily: '"JetBrains Mono", monospace',
fontSize: 13,
lineHeight: 22,
borderRadius: 6,
padding: 16,
},
inlineCode: {
background: '#1f2335',
color: '#7aa2f7',
fontFamily: '"JetBrains Mono", monospace',
borderRadius: 4,
paddingH: 5,
},
blockquote: { borderColor: '#292e42', textColor: '#565f89', borderWidth: 4, paddingLeft: 16 },
table: {
headerBackground: '#16161e',
headerColor: '#c0caf5',
borderColor: '#292e42',
cellPadding: 12,
},
hr: { color: '#292e42' },
list: { bulletColor: '#565f89', indentX: 20, bulletGap: 8, itemGap: 4 },
image: { borderRadius: 6, maxHeight: 400 },
fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 16,
lineHeight: 28,
padding: 48,
blockGap: 20,
}
render(markdown, canvas, { theme: myTheme })Use Cases
1. AI Chat with Streaming
// Stream LLM response to canvas — no DOM reflow on every token
import { createStream } from 'canvasdown/stream'
const stream = createStream(canvas, { width: 680, theme: 'dark' })
for await (const chunk of llmStream) {
stream.append(chunk)
}
stream.flush()2. OG Image Generator (Next.js API Route)
// app/api/og/route.ts
import { exportPNG } from 'canvasdown'
import { createCanvas } from '@napi-rs/canvas'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const title = searchParams.get('title') ?? 'Hello'
const png = await exportPNG(`# ${title}\n\nGenerated with canvasdown`, {
width: 1200,
devicePixelRatio: 2,
theme: 'dark',
canvasFactory: (w, h) => createCanvas(w, h),
})
const buffer = Buffer.from(png.split(',')[1]!, 'base64')
return new Response(buffer, {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
})
}3. Virtual List with Exact Heights
import { measure } from 'canvasdown'
// Pre-compute exact heights for ALL messages (no DOM, ~0.001ms each)
const heights = messages.map(msg =>
measure(msg.content, { width: 680, theme: 'dark' }).totalHeight
)
// Pass to virtual list — no estimates, no scroll jumps
<VirtualList itemCount={messages.length} itemHeight={i => heights[i]} ... />4. Text Animation for Hero Sections
import { animateText } from 'canvasdown/advanced'
const anim = animateText(canvas, '# Welcome to the future', {
effect: 'wave',
stagger: 40,
duration: 800,
loop: true,
}, { width: 800, theme: 'dark' })
anim.start()Development
git clone https://github.com/nhoxtvt/canvasdown
cd canvasdown
npm install
npm run build # compile TypeScript → dist/
npm run typecheck # type checking only
node serve.cjs # serve demo at http://localhost:3847/demo/index.htmlProject Structure
src/
├── index.ts # public API: render, measure, exportPNG, exportBlob
├── parser.ts # markdown → RenderToken[] (via marked.js)
├── layout.ts # Pretext measurement → DocumentLayout
├── renderer.ts # DocumentLayout → Canvas 2D
├── theme.ts # Theme type + darkTheme + lightTheme
├── tokens.ts # TypeScript types for tokens
├── canvas-provider.ts # CanvasFactory abstraction (browser/Node/Worker)
├── stream.ts # streaming render API
├── advanced.ts # shrinkwrap, fitText, overlay, path text, animation
├── react.ts # React component factory
└── worker-bridge.ts # OffscreenCanvas + Web Worker bridge
demo/
└── index.html # live playgroundCredits
canvasdown is built on top of Pretext by Cheng Lou — a pure JS/TS library for multiline text measurement that avoids DOM reflow.
The original architecture concept comes from text-layout by Sebastian Markbage.
License
MIT © 2026 canvasdown contributors
