
Security News
vlt 1.5.0 Adds Agent Skills Management
vlt 1.5.0 links skills from JavaScript dependencies to coding agents, with lockfile versioning and Socket data to filter out known malicious packages.
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.
| 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 |
npm install canvasdown
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 })
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')
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 unmount
Stream 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
}
Import from canvasdown/advanced:
import { shrinkwrap, fitText, createOverlay, renderTextOnPath, animateText, updateRender } from 'canvasdown/advanced'
shrinkwrap(markdown, options?) — Auto-fit containerFind 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 overflow
fitText(markdown, options?) — Shrink font to fit boxFind 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 fits
createOverlay(canvas, layout) — Semantic highlightAdd 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 element
renderTextOnPath(ctx, text, svgPath, options?) — Text along curvesRender 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 animationAnimate 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-renderRe-render only what changed. Efficient for live preview / collaborative editors.
updateRender(oldMarkdown, newMarkdown, canvas, { width: 680, theme: 'dark' })
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 binaries
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>
)
}
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)
}
}
| 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 |
import { darkTheme, lightTheme } from 'canvasdown'
render(markdown, canvas, { theme: 'dark' }) // #0d1117 background
render(markdown, canvas, { theme: 'light' }) // #ffffff background
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 })
// 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()
// 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' },
})
}
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]} ... />
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()
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.html
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 playground
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.
MIT © 2026 canvasdown contributors
FAQs
Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.
We found that canvasdown demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
vlt 1.5.0 links skills from JavaScript dependencies to coding agents, with lockfile versioning and Socket data to filter out known malicious packages.

Security News
Rust 1.100 will add opt-in dependency cooldowns to Cargo, giving security scanners and maintainers time to catch malicious crate releases.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.