canvasdown 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +541 -0
- package/dist/advanced.d.ts +65 -0
- package/dist/advanced.d.ts.map +1 -0
- package/dist/advanced.js +442 -0
- package/dist/advanced.js.map +1 -0
- package/dist/canvas-provider.d.ts +11 -0
- package/dist/canvas-provider.d.ts.map +1 -0
- package/dist/canvas-provider.js +15 -0
- package/dist/canvas-provider.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +95 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +179 -0
- package/dist/layout.js.map +1 -0
- package/dist/parser.d.ts +4 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +252 -0
- package/dist/parser.js.map +1 -0
- package/dist/react.d.ts +33 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +43 -0
- package/dist/react.js.map +1 -0
- package/dist/renderer.d.ts +4 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +238 -0
- package/dist/renderer.js.map +1 -0
- package/dist/stream.d.ts +10 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +72 -0
- package/dist/stream.js.map +1 -0
- package/dist/theme.d.ts +75 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +123 -0
- package/dist/theme.js.map +1 -0
- package/dist/tokens.d.ts +66 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +2 -0
- package/dist/tokens.js.map +1 -0
- package/dist/worker-bridge.d.ts +26 -0
- package/dist/worker-bridge.d.ts.map +1 -0
- package/dist/worker-bridge.js +31 -0
- package/dist/worker-bridge.js.map +1 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 canvasdown contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
# canvasdown
|
|
2
|
+
|
|
3
|
+
> Markdown renderer for Canvas. No DOM. No reflow. Runs anywhere.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/canvasdown)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](https://github.com/chenglou/pretext)
|
|
9
|
+
|
|
10
|
+
canvasdown renders Markdown directly to HTML Canvas — no DOM, no layout reflow, no browser rendering pipeline. Powered by [Pretext](https://github.com/chenglou/pretext), which measures text ~300,000x faster than `getBoundingClientRect`.
|
|
11
|
+
|
|
12
|
+
Works in **browser**, **Node.js**, **Web Workers**, and **WebGL** contexts.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why canvasdown?
|
|
17
|
+
|
|
18
|
+
| | react-markdown | Satori (Vercel) | **canvasdown** |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| Input | Markdown | JSX | **Markdown** |
|
|
21
|
+
| Output | DOM (HTML) | SVG | **Canvas / PNG** |
|
|
22
|
+
| DOM reflow | Every render | N/A | **Never** |
|
|
23
|
+
| Streaming (AI chat) | Slow | No | **Yes** |
|
|
24
|
+
| Node.js | SSR only | Yes | **Yes** |
|
|
25
|
+
| Web Worker | No | No | **Yes** |
|
|
26
|
+
| Per-char animation | No | No | **Yes** |
|
|
27
|
+
| Text along path | No | No | **Yes** |
|
|
28
|
+
| Virtual list heights | Estimate | No | **Exact** |
|
|
29
|
+
| OG image generation | html2canvas | Yes | **Yes** |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install canvasdown
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { render, darkTheme } from 'canvasdown'
|
|
45
|
+
|
|
46
|
+
const markdown = `
|
|
47
|
+
# Hello canvasdown
|
|
48
|
+
|
|
49
|
+
Render **Markdown** to Canvas with *no DOM reflow*.
|
|
50
|
+
|
|
51
|
+
- Zero DOM reads
|
|
52
|
+
- Runs in Node.js
|
|
53
|
+
- Streams token-by-token
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const canvas = document.getElementById('output') as HTMLCanvasElement
|
|
57
|
+
render(markdown, canvas, { width: 800, theme: darkTheme })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
### Core
|
|
65
|
+
|
|
66
|
+
#### `render(markdown, canvas, options?)`
|
|
67
|
+
|
|
68
|
+
Render markdown to a canvas element. Automatically sets canvas width/height and handles HiDPI.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { render } from 'canvasdown'
|
|
72
|
+
|
|
73
|
+
render(markdown, canvas, {
|
|
74
|
+
width: 800, // canvas width in px (default: 800)
|
|
75
|
+
theme: 'dark', // 'dark' | 'light' | Theme object
|
|
76
|
+
devicePixelRatio: 2, // HiDPI (default: window.devicePixelRatio)
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### `measure(markdown, options?)`
|
|
81
|
+
|
|
82
|
+
Measure layout **without rendering** — get exact height before touching the canvas. Essential for virtual lists.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { measure } from 'canvasdown'
|
|
86
|
+
|
|
87
|
+
const { totalHeight, blocks } = measure(markdown, { width: 680, theme: 'dark' })
|
|
88
|
+
console.log(`This markdown will be ${totalHeight}px tall`)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### `exportPNG(markdown, options?)`
|
|
92
|
+
|
|
93
|
+
Export as PNG dataURL. Works in browser and Node.js (with `canvasFactory`).
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { exportPNG } from 'canvasdown'
|
|
97
|
+
|
|
98
|
+
const dataURL = await exportPNG(markdown, {
|
|
99
|
+
width: 1200,
|
|
100
|
+
devicePixelRatio: 2, // @2x = 2400px wide PNG
|
|
101
|
+
theme: 'dark',
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### `exportBlob(markdown, options?)`
|
|
106
|
+
|
|
107
|
+
Export as `Blob` for upload or download.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { exportBlob } from 'canvasdown'
|
|
111
|
+
|
|
112
|
+
const blob = await exportBlob(markdown, { width: 1200 })
|
|
113
|
+
const formData = new FormData()
|
|
114
|
+
formData.append('image', blob, 'og-image.png')
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### Streaming (for AI Chat)
|
|
120
|
+
|
|
121
|
+
Stream markdown token-by-token with zero reflow. Built for LLM streaming responses.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { createStream } from 'canvasdown/stream'
|
|
125
|
+
|
|
126
|
+
const canvas = document.getElementById('chat-canvas') as HTMLCanvasElement
|
|
127
|
+
const stream = createStream(canvas, { width: 680, theme: 'dark' })
|
|
128
|
+
|
|
129
|
+
// Connect to your LLM stream (OpenAI, Anthropic, etc.)
|
|
130
|
+
const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) })
|
|
131
|
+
const reader = response.body!.getReader()
|
|
132
|
+
const decoder = new TextDecoder()
|
|
133
|
+
|
|
134
|
+
while (true) {
|
|
135
|
+
const { done, value } = await reader.read()
|
|
136
|
+
if (done) break
|
|
137
|
+
stream.append(decoder.decode(value)) // only re-paints changed lines
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
stream.flush() // finalize
|
|
141
|
+
stream.destroy() // cleanup on unmount
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Stream API:**
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
type StreamHandle = {
|
|
148
|
+
append(chunk: string): void // add text, schedules rAF render
|
|
149
|
+
flush(): void // immediate render, skip rAF
|
|
150
|
+
getLayout(): DocumentLayout | null // current layout state
|
|
151
|
+
destroy(): void // cancel pending rAF, cleanup
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
### Advanced Features
|
|
158
|
+
|
|
159
|
+
Import from `canvasdown/advanced`:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { shrinkwrap, fitText, createOverlay, renderTextOnPath, animateText, updateRender } from 'canvasdown/advanced'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### `shrinkwrap(markdown, options?)` — Auto-fit container
|
|
166
|
+
|
|
167
|
+
Find the **tightest width** that fits the text without overflow (binary search).
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
const { width, height } = shrinkwrap('Hello **world**', {
|
|
171
|
+
minWidth: 100,
|
|
172
|
+
maxWidth: 800,
|
|
173
|
+
theme: 'dark',
|
|
174
|
+
})
|
|
175
|
+
// width = smallest px where text doesn't overflow
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### `fitText(markdown, options?)` — Shrink font to fit box
|
|
179
|
+
|
|
180
|
+
Find the **largest font size** where text fits within a container.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
const { fontSize, lineHeight, height } = fitText(markdown, {
|
|
184
|
+
container: { width: 400, height: 200 },
|
|
185
|
+
fontSizeMin: 10,
|
|
186
|
+
fontSizeMax: 48,
|
|
187
|
+
theme: 'dark',
|
|
188
|
+
})
|
|
189
|
+
// fontSize = largest size that still fits
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### `createOverlay(canvas, layout)` — Semantic highlight
|
|
193
|
+
|
|
194
|
+
Add highlight overlay **without re-rendering** the base canvas.
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const layout = measure(markdown, { width: 680, theme: 'dark' })
|
|
198
|
+
render(markdown, canvas, { width: 680, theme: 'dark' })
|
|
199
|
+
|
|
200
|
+
const overlay = createOverlay(canvas, layout)
|
|
201
|
+
overlay.highlight('pretext', { background: '#ffff00', opacity: 0.4 })
|
|
202
|
+
overlay.highlight('canvas', { background: '#00ffff', opacity: 0.3 })
|
|
203
|
+
overlay.clear() // remove all highlights
|
|
204
|
+
overlay.destroy() // remove overlay element
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### `renderTextOnPath(ctx, text, svgPath, options?)` — Text along curves
|
|
208
|
+
|
|
209
|
+
Render text following any SVG path — **not possible with DOM or SVG text**.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
renderTextOnPath(ctx, 'canvasdown ✦ canvas text along any curve', 'M 0 100 Q 200 0 400 100', {
|
|
213
|
+
font: '700 20px Inter',
|
|
214
|
+
color: '#f97316',
|
|
215
|
+
offset: 10, // start offset in px
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Supports SVG path commands: `M`, `L`, `Q` (quadratic), `C` (cubic bezier).
|
|
220
|
+
|
|
221
|
+
#### `animateText(canvas, markdown, animOptions, renderOptions?)` — Per-character animation
|
|
222
|
+
|
|
223
|
+
Animate individual characters with independent transforms.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const anim = animateText(canvas, markdown, {
|
|
227
|
+
effect: 'wave', // 'wave' | 'fadeIn' | 'typewriter' | 'bounce' | 'explode'
|
|
228
|
+
stagger: 30, // ms delay between characters
|
|
229
|
+
duration: 600, // ms per character animation
|
|
230
|
+
loop: false,
|
|
231
|
+
onComplete: () => console.log('done'),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
anim.start()
|
|
235
|
+
anim.stop()
|
|
236
|
+
anim.reset()
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### `updateRender(prev, next, canvas, options?)` — Diff-aware re-render
|
|
240
|
+
|
|
241
|
+
Re-render only what changed. Efficient for live preview / collaborative editors.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
updateRender(oldMarkdown, newMarkdown, canvas, { width: 680, theme: 'dark' })
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
### Node.js Support
|
|
250
|
+
|
|
251
|
+
Inject a canvas factory to use canvasdown server-side:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
import { exportPNG } from 'canvasdown'
|
|
255
|
+
import { createCanvas } from '@napi-rs/canvas'
|
|
256
|
+
|
|
257
|
+
const png = await exportPNG(markdown, {
|
|
258
|
+
width: 1200,
|
|
259
|
+
devicePixelRatio: 2,
|
|
260
|
+
theme: 'dark',
|
|
261
|
+
canvasFactory: (w, h) => createCanvas(w, h),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// Save to file
|
|
265
|
+
import { writeFileSync } from 'fs'
|
|
266
|
+
const base64 = png.split(',')[1]!
|
|
267
|
+
writeFileSync('og-image.png', Buffer.from(base64, 'base64'))
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
npm install @napi-rs/canvas # Rust-based, pre-built ARM64 binaries
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
### React Component
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { createCanvasdownComponent } from 'canvasdown/react'
|
|
280
|
+
import React, { useState } from 'react'
|
|
281
|
+
|
|
282
|
+
const Canvasdown = createCanvasdownComponent(React)
|
|
283
|
+
|
|
284
|
+
function App() {
|
|
285
|
+
const [height, setHeight] = useState(0)
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div style={{ position: 'relative' }}>
|
|
289
|
+
<Canvasdown
|
|
290
|
+
markdown={content}
|
|
291
|
+
width={680}
|
|
292
|
+
theme="dark"
|
|
293
|
+
className="chat-canvas"
|
|
294
|
+
onHeightChange={setHeight}
|
|
295
|
+
onRender={(layout) => console.log('blocks:', layout.blocks.length)}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
### Web Worker + OffscreenCanvas
|
|
305
|
+
|
|
306
|
+
Render off the main thread — keep UI at 60fps during heavy markdown rendering.
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// main.ts
|
|
310
|
+
const worker = new Worker(new URL('./canvasdown.worker.js', import.meta.url))
|
|
311
|
+
const offscreen = canvas.transferControlToOffscreen()
|
|
312
|
+
|
|
313
|
+
worker.postMessage({ type: 'render', markdown, width: 800, theme: 'dark', dpr: 2 }, [offscreen])
|
|
314
|
+
worker.onmessage = (e) => console.log('rendered:', e.data.height, 'px')
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// canvasdown.worker.js
|
|
319
|
+
import { handleWorkerMessage } from 'canvasdown/worker'
|
|
320
|
+
|
|
321
|
+
let offscreen: OffscreenCanvas
|
|
322
|
+
|
|
323
|
+
self.onmessage = (e) => {
|
|
324
|
+
if (e.data.type === 'render') {
|
|
325
|
+
if (!offscreen) offscreen = e.ports[0] ?? e.data.canvas
|
|
326
|
+
const result = handleWorkerMessage(e.data, offscreen)
|
|
327
|
+
self.postMessage(result)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Markdown Support
|
|
335
|
+
|
|
336
|
+
| Element | Syntax | Status |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| Heading H1–H6 | `# H1` … `###### H6` | ✅ |
|
|
339
|
+
| Paragraph | plain text | ✅ |
|
|
340
|
+
| Bold | `**text**` | ✅ |
|
|
341
|
+
| Italic | `*text*` | ✅ |
|
|
342
|
+
| Bold italic | `***text***` | ✅ |
|
|
343
|
+
| Inline code | `` `code` `` | ✅ |
|
|
344
|
+
| Link | `[text](url)` | ✅ underline + color |
|
|
345
|
+
| Strikethrough | `~~text~~` | ✅ |
|
|
346
|
+
| Code block | ` ```lang ` | ✅ |
|
|
347
|
+
| Blockquote | `> text` | ✅ |
|
|
348
|
+
| Unordered list | `- item` | ✅ |
|
|
349
|
+
| Ordered list | `1. item` | ✅ |
|
|
350
|
+
| Nested list | indented items | ✅ |
|
|
351
|
+
| Task list | `- [x] done` | ✅ checkbox rendering |
|
|
352
|
+
| Table | `\| col \|` | ✅ |
|
|
353
|
+
| Image | `` | ✅ placeholder on load |
|
|
354
|
+
| Horizontal rule | `---` | ✅ |
|
|
355
|
+
| CJK (Chinese/Japanese/Korean) | 春天到了 | ✅ via Pretext |
|
|
356
|
+
| Emoji | 🎉🚀 | ✅ |
|
|
357
|
+
| Arabic / RTL | بدأت الرحلة | 🔜 planned |
|
|
358
|
+
| Syntax highlight | ` ```ts ` | 🔜 planned |
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Themes
|
|
363
|
+
|
|
364
|
+
### Built-in themes
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { darkTheme, lightTheme } from 'canvasdown'
|
|
368
|
+
|
|
369
|
+
render(markdown, canvas, { theme: 'dark' }) // #0d1117 background
|
|
370
|
+
render(markdown, canvas, { theme: 'light' }) // #ffffff background
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Custom theme
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
import type { Theme } from 'canvasdown'
|
|
377
|
+
|
|
378
|
+
const myTheme: Theme = {
|
|
379
|
+
background: '#1a1b26',
|
|
380
|
+
text: '#c0caf5',
|
|
381
|
+
mutedText: '#565f89',
|
|
382
|
+
link: { color: '#7aa2f7' },
|
|
383
|
+
heading: {
|
|
384
|
+
color: '#c0caf5',
|
|
385
|
+
sizes: [36, 28, 22, 18, 16, 14],
|
|
386
|
+
weights: ['700', '600', '600', '600', '500', '500'],
|
|
387
|
+
lineHeights: [48, 38, 32, 28, 26, 24],
|
|
388
|
+
},
|
|
389
|
+
code: {
|
|
390
|
+
background: '#16161e',
|
|
391
|
+
color: '#c0caf5',
|
|
392
|
+
borderColor: '#292e42',
|
|
393
|
+
fontFamily: '"JetBrains Mono", monospace',
|
|
394
|
+
fontSize: 13,
|
|
395
|
+
lineHeight: 22,
|
|
396
|
+
borderRadius: 6,
|
|
397
|
+
padding: 16,
|
|
398
|
+
},
|
|
399
|
+
inlineCode: {
|
|
400
|
+
background: '#1f2335',
|
|
401
|
+
color: '#7aa2f7',
|
|
402
|
+
fontFamily: '"JetBrains Mono", monospace',
|
|
403
|
+
borderRadius: 4,
|
|
404
|
+
paddingH: 5,
|
|
405
|
+
},
|
|
406
|
+
blockquote: { borderColor: '#292e42', textColor: '#565f89', borderWidth: 4, paddingLeft: 16 },
|
|
407
|
+
table: {
|
|
408
|
+
headerBackground: '#16161e',
|
|
409
|
+
headerColor: '#c0caf5',
|
|
410
|
+
borderColor: '#292e42',
|
|
411
|
+
cellPadding: 12,
|
|
412
|
+
},
|
|
413
|
+
hr: { color: '#292e42' },
|
|
414
|
+
list: { bulletColor: '#565f89', indentX: 20, bulletGap: 8, itemGap: 4 },
|
|
415
|
+
image: { borderRadius: 6, maxHeight: 400 },
|
|
416
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
417
|
+
fontSize: 16,
|
|
418
|
+
lineHeight: 28,
|
|
419
|
+
padding: 48,
|
|
420
|
+
blockGap: 20,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
render(markdown, canvas, { theme: myTheme })
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Use Cases
|
|
429
|
+
|
|
430
|
+
### 1. AI Chat with Streaming
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
// Stream LLM response to canvas — no DOM reflow on every token
|
|
434
|
+
import { createStream } from 'canvasdown/stream'
|
|
435
|
+
|
|
436
|
+
const stream = createStream(canvas, { width: 680, theme: 'dark' })
|
|
437
|
+
for await (const chunk of llmStream) {
|
|
438
|
+
stream.append(chunk)
|
|
439
|
+
}
|
|
440
|
+
stream.flush()
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### 2. OG Image Generator (Next.js API Route)
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
// app/api/og/route.ts
|
|
447
|
+
import { exportPNG } from 'canvasdown'
|
|
448
|
+
import { createCanvas } from '@napi-rs/canvas'
|
|
449
|
+
|
|
450
|
+
export async function GET(req: Request) {
|
|
451
|
+
const { searchParams } = new URL(req.url)
|
|
452
|
+
const title = searchParams.get('title') ?? 'Hello'
|
|
453
|
+
|
|
454
|
+
const png = await exportPNG(`# ${title}\n\nGenerated with canvasdown`, {
|
|
455
|
+
width: 1200,
|
|
456
|
+
devicePixelRatio: 2,
|
|
457
|
+
theme: 'dark',
|
|
458
|
+
canvasFactory: (w, h) => createCanvas(w, h),
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const buffer = Buffer.from(png.split(',')[1]!, 'base64')
|
|
462
|
+
return new Response(buffer, {
|
|
463
|
+
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### 3. Virtual List with Exact Heights
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
import { measure } from 'canvasdown'
|
|
472
|
+
|
|
473
|
+
// Pre-compute exact heights for ALL messages (no DOM, ~0.001ms each)
|
|
474
|
+
const heights = messages.map(msg =>
|
|
475
|
+
measure(msg.content, { width: 680, theme: 'dark' }).totalHeight
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
// Pass to virtual list — no estimates, no scroll jumps
|
|
479
|
+
<VirtualList itemCount={messages.length} itemHeight={i => heights[i]} ... />
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### 4. Text Animation for Hero Sections
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
import { animateText } from 'canvasdown/advanced'
|
|
486
|
+
|
|
487
|
+
const anim = animateText(canvas, '# Welcome to the future', {
|
|
488
|
+
effect: 'wave',
|
|
489
|
+
stagger: 40,
|
|
490
|
+
duration: 800,
|
|
491
|
+
loop: true,
|
|
492
|
+
}, { width: 800, theme: 'dark' })
|
|
493
|
+
|
|
494
|
+
anim.start()
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Development
|
|
500
|
+
|
|
501
|
+
```bash
|
|
502
|
+
git clone https://github.com/nhoxtvt/canvasdown
|
|
503
|
+
cd canvasdown
|
|
504
|
+
npm install
|
|
505
|
+
npm run build # compile TypeScript → dist/
|
|
506
|
+
npm run typecheck # type checking only
|
|
507
|
+
node serve.cjs # serve demo at http://localhost:3847/demo/index.html
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Project Structure
|
|
511
|
+
|
|
512
|
+
```
|
|
513
|
+
src/
|
|
514
|
+
├── index.ts # public API: render, measure, exportPNG, exportBlob
|
|
515
|
+
├── parser.ts # markdown → RenderToken[] (via marked.js)
|
|
516
|
+
├── layout.ts # Pretext measurement → DocumentLayout
|
|
517
|
+
├── renderer.ts # DocumentLayout → Canvas 2D
|
|
518
|
+
├── theme.ts # Theme type + darkTheme + lightTheme
|
|
519
|
+
├── tokens.ts # TypeScript types for tokens
|
|
520
|
+
├── canvas-provider.ts # CanvasFactory abstraction (browser/Node/Worker)
|
|
521
|
+
├── stream.ts # streaming render API
|
|
522
|
+
├── advanced.ts # shrinkwrap, fitText, overlay, path text, animation
|
|
523
|
+
├── react.ts # React component factory
|
|
524
|
+
└── worker-bridge.ts # OffscreenCanvas + Web Worker bridge
|
|
525
|
+
demo/
|
|
526
|
+
└── index.html # live playground
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Credits
|
|
532
|
+
|
|
533
|
+
canvasdown is built on top of [Pretext](https://github.com/chenglou/pretext) by Cheng Lou — a pure JS/TS library for multiline text measurement that avoids DOM reflow.
|
|
534
|
+
|
|
535
|
+
The original architecture concept comes from [text-layout](https://github.com/Automattic/text-layout) by Sebastian Markbage.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## License
|
|
540
|
+
|
|
541
|
+
MIT © 2026 canvasdown contributors
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DocumentLayout } from './layout.js';
|
|
2
|
+
import type { RenderOptions } from './index.js';
|
|
3
|
+
export type ShrinkwrapOptions = {
|
|
4
|
+
minWidth?: number;
|
|
5
|
+
maxWidth?: number;
|
|
6
|
+
padding?: number;
|
|
7
|
+
theme?: RenderOptions['theme'];
|
|
8
|
+
};
|
|
9
|
+
export type ShrinkwrapResult = {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function shrinkwrap(markdown: string, options?: ShrinkwrapOptions): ShrinkwrapResult;
|
|
14
|
+
export type FitTextOptions = {
|
|
15
|
+
container: {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
fontSizeMin?: number;
|
|
20
|
+
fontSizeMax?: number;
|
|
21
|
+
theme?: RenderOptions['theme'];
|
|
22
|
+
};
|
|
23
|
+
export type FitTextResult = {
|
|
24
|
+
fontSize: number;
|
|
25
|
+
lineHeight: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function fitText(markdown: string, options: FitTextOptions): FitTextResult;
|
|
29
|
+
export type HighlightStyle = {
|
|
30
|
+
background?: string | undefined;
|
|
31
|
+
color?: string | undefined;
|
|
32
|
+
opacity?: number | undefined;
|
|
33
|
+
};
|
|
34
|
+
export type HighlightHandle = {
|
|
35
|
+
highlight(query: string, style: HighlightStyle): void;
|
|
36
|
+
clear(): void;
|
|
37
|
+
destroy(): void;
|
|
38
|
+
};
|
|
39
|
+
export declare function createOverlay(canvas: HTMLCanvasElement, layout: DocumentLayout, theme?: RenderOptions['theme']): HighlightHandle;
|
|
40
|
+
export type PathTextOptions = {
|
|
41
|
+
font?: string;
|
|
42
|
+
color?: string;
|
|
43
|
+
theme?: RenderOptions['theme'];
|
|
44
|
+
offset?: number;
|
|
45
|
+
};
|
|
46
|
+
export declare function renderTextOnPath(ctx: CanvasRenderingContext2D, text: string, svgPath: string, options?: PathTextOptions): void;
|
|
47
|
+
export type AnimationEffect = 'wave' | 'fadeIn' | 'typewriter' | 'bounce' | 'explode';
|
|
48
|
+
export type AnimationOptions = {
|
|
49
|
+
effect: AnimationEffect;
|
|
50
|
+
stagger?: number;
|
|
51
|
+
duration?: number;
|
|
52
|
+
loop?: boolean;
|
|
53
|
+
onComplete?: () => void;
|
|
54
|
+
};
|
|
55
|
+
export type AnimationHandle = {
|
|
56
|
+
start(): void;
|
|
57
|
+
stop(): void;
|
|
58
|
+
reset(): void;
|
|
59
|
+
};
|
|
60
|
+
export declare function animateText(canvas: HTMLCanvasElement, markdown: string, animOptions: AnimationOptions, renderOptions?: RenderOptions): AnimationHandle;
|
|
61
|
+
export type DiffRenderOptions = RenderOptions & {
|
|
62
|
+
canvas: HTMLCanvasElement;
|
|
63
|
+
};
|
|
64
|
+
export declare function updateRender(prevMarkdown: string, nextMarkdown: string, canvas: HTMLCanvasElement, options?: RenderOptions): void;
|
|
65
|
+
//# sourceMappingURL=advanced.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"advanced.d.ts","sourceRoot":"","sources":["../src/advanced.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAW/C,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAgC9F;AAID,MAAM,MAAM,cAAc,GAAG;IAC3B,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;CACf,CAAA;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,GAAG,aAAa,CAqChF;AAID,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,GAAG,IAAI,CAAA;IACrD,KAAK,IAAI,IAAI,CAAA;IACb,OAAO,IAAI,IAAI,CAAA;CAChB,CAAA;AAED,wBAAgB,aAAa,CAC3B,MAAM,EAAE,iBAAiB,EACzB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,GAC7B,eAAe,CAwEjB;AAID,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,wBAAwB,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAgCN;AAqGD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,QAAQ,GAAG,YAAY,GAAG,QAAQ,GAAG,SAAS,CAAA;AAErF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,eAAe,CAAA;IACvB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;IACZ,KAAK,IAAI,IAAI,CAAA;CACd,CAAA;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,gBAAgB,EAC7B,aAAa,GAAE,aAAkB,GAChC,eAAe,CA4JjB;AAID,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG;IAC9C,MAAM,EAAE,iBAAiB,CAAA;CAC1B,CAAA;AAED,wBAAgB,YAAY,CAC1B,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,iBAAiB,EACzB,OAAO,GAAE,aAAkB,GAC1B,IAAI,CA6BN"}
|