@zseven-w/pen-renderer 0.0.1

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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @zseven-w/pen-renderer
2
+
3
+ Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/nicepkg/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @zseven-w/pen-renderer canvaskit-wasm
9
+ ```
10
+
11
+ `canvaskit-wasm` is a peer dependency — you provide the WASM binary.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
17
+
18
+ // Initialize CanvasKit
19
+ await loadCanvasKit()
20
+
21
+ // Create renderer on a canvas element
22
+ const renderer = new PenRenderer(canvas, document, {
23
+ width: 1920,
24
+ height: 1080,
25
+ })
26
+
27
+ // Render
28
+ renderer.render()
29
+ ```
30
+
31
+ ## API
32
+
33
+ ### High-level
34
+
35
+ - **`loadCanvasKit()`** — Initialize the CanvasKit WASM module
36
+ - **`PenRenderer`** — Full-featured renderer with viewport, selection, and interaction support
37
+
38
+ ### Document Flattening
39
+
40
+ Pre-process documents for rendering:
41
+
42
+ ```ts
43
+ import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer'
44
+ ```
45
+
46
+ ### Viewport Utilities
47
+
48
+ ```ts
49
+ import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer'
50
+ ```
51
+
52
+ ### Low-level Renderers
53
+
54
+ For custom rendering pipelines:
55
+
56
+ - `SkiaNodeRenderer` — Renders individual nodes to a Skia canvas
57
+ - `SkiaTextRenderer` — Text layout and rendering
58
+ - `SkiaFontManager` — Font loading and management
59
+ - `SkiaImageLoader` — Async image loading with caching
60
+ - `SpatialIndex` — R-tree spatial index for hit testing
61
+
62
+ ## License
63
+
64
+ MIT
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@zseven-w/pen-renderer",
3
+ "version": "0.0.1",
4
+ "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "dependencies": {
19
+ "@zseven-w/pen-types": "0.0.1",
20
+ "@zseven-w/pen-core": "0.0.1",
21
+ "rbush": "^4.0.1"
22
+ },
23
+ "peerDependencies": {
24
+ "canvaskit-wasm": "^0.40.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/rbush": "^4.0.0",
28
+ "canvaskit-wasm": "^0.40.0",
29
+ "typescript": "^5.7.2"
30
+ }
31
+ }
@@ -0,0 +1,340 @@
1
+ import type { PenNode, ContainerProps, RefNode } from '@zseven-w/pen-types'
2
+ import {
3
+ resolvePadding,
4
+ isNodeVisible,
5
+ getNodeWidth,
6
+ getNodeHeight,
7
+ computeLayoutPositions,
8
+ inferLayout,
9
+ parseSizing,
10
+ defaultLineHeight,
11
+ findNodeInTree,
12
+ cssFontFamily,
13
+ } from '@zseven-w/pen-core'
14
+ import { wrapLine } from './paint-utils.js'
15
+ import type { RenderNode } from './types.js'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Pre-measure text widths using Canvas 2D (browser fonts)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ let _measureCtx: CanvasRenderingContext2D | null = null
22
+ function getMeasureCtx(): CanvasRenderingContext2D {
23
+ if (!_measureCtx) {
24
+ const c = document.createElement('canvas')
25
+ _measureCtx = c.getContext('2d')!
26
+ }
27
+ return _measureCtx
28
+ }
29
+
30
+ /**
31
+ * Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
32
+ *
33
+ * Only targets fixed-width text with auto height — these are the cases where
34
+ * estimateTextHeight may underestimate because its width estimation differs
35
+ * from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
36
+ *
37
+ * IMPORTANT: This function never touches WIDTH or container-relative sizing
38
+ * strings (fill_container / fit_content). Changing widths breaks layout
39
+ * resolution in computeLayoutPositions.
40
+ */
41
+ export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
42
+ return nodes.map((node) => {
43
+ let result = node
44
+
45
+ if (node.type === 'text') {
46
+ const tNode = node as PenNode & { width?: number | string; height?: number | string; fontSize?: number; fontWeight?: string; fontFamily?: string; lineHeight?: number; textAlign?: string; textGrowth?: string; content?: string | { text?: string }[] }
47
+ const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0
48
+ const isContainerHeight = typeof tNode.height === 'string'
49
+ && (tNode.height === 'fill_container' || tNode.height === 'fit_content')
50
+ const textGrowth = tNode.textGrowth
51
+ const content = typeof tNode.content === 'string'
52
+ ? tNode.content
53
+ : Array.isArray(tNode.content)
54
+ ? tNode.content.map((s) => s.text ?? '').join('')
55
+ : ''
56
+
57
+ const textAlign = tNode.textAlign
58
+ const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
59
+ || (textGrowth !== 'auto' && textAlign != null && textAlign !== 'left')
60
+ if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
61
+ const fontSize = tNode.fontSize ?? 16
62
+ const fontWeight = tNode.fontWeight ?? '400'
63
+ const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
64
+ const ctx = getMeasureCtx()
65
+ ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
66
+
67
+ const wrapWidth = (tNode.width as number) + fontSize * 0.2
68
+ const rawLines = content.split('\n')
69
+ const wrappedLines: string[] = []
70
+ for (const raw of rawLines) {
71
+ if (!raw) { wrappedLines.push(''); continue }
72
+ wrapLine(ctx, raw, wrapWidth, wrappedLines)
73
+ }
74
+ const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
75
+ const lineHeight = lineHeightMul * fontSize
76
+ const glyphH = fontSize * 1.13
77
+ const measuredHeight = Math.ceil(
78
+ wrappedLines.length <= 1
79
+ ? glyphH + 2
80
+ : (wrappedLines.length - 1) * lineHeight + glyphH + 2,
81
+ )
82
+ const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0
83
+ const explicitLineCount = rawLines.length
84
+ const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount
85
+ if (needsHeight && measuredHeight > currentHeight) {
86
+ result = { ...node, height: measuredHeight } as unknown as PenNode
87
+ }
88
+ }
89
+ }
90
+
91
+ // Recurse into children
92
+ if ('children' in result && result.children) {
93
+ const children = result.children
94
+ const measured = premeasureTextHeights(children)
95
+ if (measured !== children) {
96
+ result = { ...result, children: measured } as unknown as PenNode
97
+ }
98
+ }
99
+
100
+ return result
101
+ })
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Flatten document tree -> absolute-positioned RenderNode list
106
+ // ---------------------------------------------------------------------------
107
+
108
+ interface ClipInfo {
109
+ x: number; y: number; w: number; h: number; rx: number
110
+ }
111
+
112
+ function sizeToNumber(val: number | string | undefined, fallback: number): number {
113
+ if (typeof val === 'number') return val
114
+ if (typeof val === 'string') {
115
+ const m = val.match(/\((\d+(?:\.\d+)?)\)/)
116
+ if (m) return parseFloat(m[1])
117
+ const n = parseFloat(val)
118
+ if (!isNaN(n)) return n
119
+ }
120
+ return fallback
121
+ }
122
+
123
+ function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
124
+ if (cr === undefined) return 0
125
+ if (typeof cr === 'number') return cr
126
+ return cr[0]
127
+ }
128
+
129
+ export function flattenToRenderNodes(
130
+ nodes: PenNode[],
131
+ offsetX = 0,
132
+ offsetY = 0,
133
+ parentAvailW?: number,
134
+ parentAvailH?: number,
135
+ clipCtx?: ClipInfo,
136
+ depth = 0,
137
+ ): RenderNode[] {
138
+ const result: RenderNode[] = []
139
+
140
+ // Reverse order: children[0] = top layer = rendered last (frontmost)
141
+ for (let i = nodes.length - 1; i >= 0; i--) {
142
+ const node = nodes[i]
143
+ if (!isNodeVisible(node)) continue
144
+
145
+ // Resolve fill_container / fit_content
146
+ let resolved = node
147
+ if (parentAvailW !== undefined || parentAvailH !== undefined) {
148
+ let changed = false
149
+ const r: Record<string, unknown> = { ...node }
150
+ if ('width' in node && typeof node.width !== 'number') {
151
+ const s = parseSizing(node.width)
152
+ if (s === 'fill' && parentAvailW) { r.width = parentAvailW; changed = true }
153
+ else if (s === 'fit') { r.width = getNodeWidth(node, parentAvailW); changed = true }
154
+ }
155
+ if ('height' in node && typeof node.height !== 'number') {
156
+ const s = parseSizing(node.height)
157
+ if (s === 'fill' && parentAvailH) { r.height = parentAvailH; changed = true }
158
+ else if (s === 'fit') { r.height = getNodeHeight(node, parentAvailH, parentAvailW); changed = true }
159
+ }
160
+ if (changed) resolved = r as unknown as PenNode
161
+ }
162
+
163
+ // Compute height for frames without explicit numeric height
164
+ if (
165
+ node.type === 'frame'
166
+ && 'children' in node && node.children?.length
167
+ && (!('height' in resolved) || typeof resolved.height !== 'number')
168
+ ) {
169
+ const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW)
170
+ if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode
171
+ }
172
+
173
+ const absX = (resolved.x ?? 0) + offsetX
174
+ const absY = (resolved.y ?? 0) + offsetY
175
+ const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
176
+ const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
177
+
178
+ result.push({
179
+ node: { ...resolved, x: absX, y: absY } as PenNode,
180
+ absX, absY, absW, absH,
181
+ clipRect: clipCtx,
182
+ })
183
+
184
+ // Recurse into children
185
+ const children = 'children' in node ? node.children : undefined
186
+ if (children && children.length > 0) {
187
+ const nodeW = getNodeWidth(resolved, parentAvailW)
188
+ const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
189
+ const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
190
+ const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
191
+ const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
192
+
193
+ const layout = ('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node)
194
+ const positioned = layout && layout !== 'none'
195
+ ? computeLayoutPositions(resolved, children)
196
+ : children
197
+
198
+ // Clipping — only clip for root frames (artboard behavior).
199
+ let childClip = clipCtx
200
+ const isRootFrame = node.type === 'frame' && depth === 0
201
+ if (isRootFrame) {
202
+ const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
203
+ const cr = Math.min(crRaw, nodeH / 2)
204
+ childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr }
205
+ }
206
+
207
+ const childRNs = flattenToRenderNodes(positioned, absX, absY, childAvailW, childAvailH, childClip, depth + 1)
208
+
209
+ // Propagate parent flip to children
210
+ const parentFlipX = node.flipX === true
211
+ const parentFlipY = node.flipY === true
212
+ if (parentFlipX || parentFlipY) {
213
+ const pcx = absX + nodeW / 2
214
+ const pcy = absY + nodeH / 2
215
+ for (const crn of childRNs) {
216
+ const updates: Record<string, unknown> = {}
217
+ if (parentFlipX) {
218
+ const ccx = crn.absX + crn.absW / 2
219
+ crn.absX = 2 * pcx - ccx - crn.absW / 2
220
+ const childFlip = crn.node.flipX === true
221
+ updates.flipX = !childFlip || undefined
222
+ }
223
+ if (parentFlipY) {
224
+ const ccy = crn.absY + crn.absH / 2
225
+ crn.absY = 2 * pcy - ccy - crn.absH / 2
226
+ const childFlip = crn.node.flipY === true
227
+ updates.flipY = !childFlip || undefined
228
+ }
229
+ crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode
230
+ }
231
+ }
232
+
233
+ // Propagate parent rotation to children
234
+ const parentRot = node.rotation ?? 0
235
+ if (parentRot !== 0) {
236
+ const cx = absX + nodeW / 2
237
+ const cy = absY + nodeH / 2
238
+ const rad = parentRot * Math.PI / 180
239
+ const cosA = Math.cos(rad)
240
+ const sinA = Math.sin(rad)
241
+
242
+ for (const crn of childRNs) {
243
+ const ccx = crn.absX + crn.absW / 2
244
+ const ccy = crn.absY + crn.absH / 2
245
+ const dx = ccx - cx
246
+ const dy = ccy - cy
247
+ const newCx = cx + dx * cosA - dy * sinA
248
+ const newCy = cy + dx * sinA + dy * cosA
249
+ crn.absX = newCx - crn.absW / 2
250
+ crn.absY = newCy - crn.absH / 2
251
+ const childRot = crn.node.rotation ?? 0
252
+ crn.node = { ...crn.node, x: crn.absX, y: crn.absY, rotation: childRot + parentRot } as PenNode
253
+ }
254
+ }
255
+
256
+ result.push(...childRNs)
257
+ }
258
+ }
259
+
260
+ return result
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Ref resolution — resolve RefNodes to their target components
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
268
+ export function resolveRefs(
269
+ nodes: PenNode[],
270
+ rootNodes: PenNode[],
271
+ findInTree?: (nodes: PenNode[], id: string) => PenNode | null,
272
+ visited = new Set<string>(),
273
+ ): PenNode[] {
274
+ const finder = findInTree ?? ((ns: PenNode[], id: string) => findNodeInTree(ns, id) ?? null)
275
+ return nodes.flatMap((node) => {
276
+ if (node.type !== 'ref') {
277
+ if ('children' in node && node.children) {
278
+ return [{ ...node, children: resolveRefs(node.children, rootNodes, finder, visited) } as PenNode]
279
+ }
280
+ return [node]
281
+ }
282
+ if (visited.has(node.ref)) return []
283
+ const component = finder(rootNodes, node.ref)
284
+ if (!component) return []
285
+ visited.add(node.ref)
286
+ const resolved: Record<string, unknown> = { ...component }
287
+ for (const [key, val] of Object.entries(node)) {
288
+ if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
289
+ if (val !== undefined) resolved[key] = val
290
+ }
291
+ resolved.type = component.type
292
+ if (!resolved.name) resolved.name = component.name
293
+ delete resolved.reusable
294
+ const resolvedNode = resolved as unknown as PenNode
295
+ if ('children' in component && component.children) {
296
+ const refNode = node as RefNode
297
+ ;(resolvedNode as PenNode & ContainerProps).children = remapIds(component.children, node.id, refNode.descendants)
298
+ }
299
+ visited.delete(node.ref)
300
+ return [resolvedNode]
301
+ })
302
+ }
303
+
304
+ export function remapIds(children: PenNode[], refId: string, overrides?: Record<string, Partial<PenNode>>): PenNode[] {
305
+ return children.map((child) => {
306
+ const virtualId = `${refId}__${child.id}`
307
+ const ov = overrides?.[child.id] ?? {}
308
+ const mapped = { ...child, ...ov, id: virtualId } as PenNode
309
+ if ('children' in mapped && mapped.children) {
310
+ (mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides)
311
+ }
312
+ return mapped
313
+ })
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Component / instance ID collection (from raw tree, before ref resolution)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ export function collectReusableIds(nodes: PenNode[], result: Set<string>) {
321
+ for (const node of nodes) {
322
+ if (node.type === 'frame' && node.reusable === true) {
323
+ result.add(node.id)
324
+ }
325
+ if ('children' in node && node.children) {
326
+ collectReusableIds(node.children, result)
327
+ }
328
+ }
329
+ }
330
+
331
+ export function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
332
+ for (const node of nodes) {
333
+ if (node.type === 'ref') {
334
+ result.add(node.id)
335
+ }
336
+ if ('children' in node && node.children) {
337
+ collectInstanceIds(node.children, result)
338
+ }
339
+ }
340
+ }