@zseven-w/pen-renderer 0.0.1 → 0.6.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zseven-w/pen-renderer",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zseven-w/pen-types": "0.0
|
|
20
|
-
"@zseven-w/pen-core": "0.0
|
|
19
|
+
"@zseven-w/pen-types": "0.6.0",
|
|
20
|
+
"@zseven-w/pen-core": "0.6.0",
|
|
21
21
|
"rbush": "^4.0.1"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
3
|
+
import { flattenToRenderNodes } from '../document-flattener'
|
|
4
|
+
|
|
5
|
+
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode => ({
|
|
6
|
+
id: 'f1', type: 'frame', x: 0, y: 0, ...props,
|
|
7
|
+
} as PenNode)
|
|
8
|
+
|
|
9
|
+
const text = (id: string, content: string, props: Partial<PenNode> = {}): PenNode => ({
|
|
10
|
+
id, type: 'text', x: 0, y: 0, content, fontSize: 16, ...props,
|
|
11
|
+
} as PenNode)
|
|
12
|
+
|
|
13
|
+
describe('flattenToRenderNodes — dimension consistency', () => {
|
|
14
|
+
it('absH uses getNodeHeight for text without height, not sizeToNumber 100 fallback', () => {
|
|
15
|
+
// Simulates text after fixTextHeights deleted height
|
|
16
|
+
const root = frame({
|
|
17
|
+
id: 'root', width: 400, height: 600, layout: 'vertical' as any,
|
|
18
|
+
children: [
|
|
19
|
+
// Text with no height property (deleted by fixTextHeights)
|
|
20
|
+
{ id: 't1', type: 'text', content: 'Hello world', fontSize: 16,
|
|
21
|
+
width: 'fill_container' as any } as PenNode,
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const nodes = flattenToRenderNodes([root])
|
|
26
|
+
const t1 = nodes.find(rn => rn.node.id === 't1')!
|
|
27
|
+
|
|
28
|
+
// absH should reflect estimated text height (~18-24px for single line at 16px),
|
|
29
|
+
// NOT the 100px sizeToNumber fallback
|
|
30
|
+
expect(t1.absH).toBeLessThan(50)
|
|
31
|
+
expect(t1.absH).toBeGreaterThan(10)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('absW matches child layout width for frame with no explicit width', () => {
|
|
35
|
+
const root = frame({
|
|
36
|
+
id: 'root', width: 400, height: 600, layout: 'vertical' as any,
|
|
37
|
+
children: [
|
|
38
|
+
frame({
|
|
39
|
+
id: 'inner',
|
|
40
|
+
// No explicit width — getNodeWidth should compute from children
|
|
41
|
+
height: 100,
|
|
42
|
+
children: [
|
|
43
|
+
{ id: 'r1', type: 'rectangle', x: 0, y: 0, width: 200, height: 50 } as PenNode,
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const nodes = flattenToRenderNodes([root])
|
|
50
|
+
const inner = nodes.find(rn => rn.node.id === 'inner')!
|
|
51
|
+
|
|
52
|
+
// inner absW should come from getNodeWidth (fitContentWidth → 200),
|
|
53
|
+
// not the sizeToNumber fallback of 100
|
|
54
|
+
expect(inner.absW).toBeGreaterThanOrEqual(200)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('nested text nodes get correct positions and non-zero dimensions', () => {
|
|
58
|
+
const root = frame({
|
|
59
|
+
id: 'root', width: 375, height: 812, layout: 'vertical' as any,
|
|
60
|
+
padding: [20, 16],
|
|
61
|
+
gap: 8,
|
|
62
|
+
children: [
|
|
63
|
+
frame({
|
|
64
|
+
id: 'card', width: 'fill_container' as any, height: 'fit_content' as any,
|
|
65
|
+
layout: 'vertical' as any, padding: [16, 16], gap: 8,
|
|
66
|
+
children: [
|
|
67
|
+
text('title', 'Card Title', { width: 'fill_container' as any, fontSize: 18, fontWeight: '600' }),
|
|
68
|
+
text('desc', 'Description text that may wrap.', { width: 'fill_container' as any, fontSize: 14 }),
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const nodes = flattenToRenderNodes([root])
|
|
75
|
+
|
|
76
|
+
for (const rn of nodes) {
|
|
77
|
+
expect(rn.absW, `${rn.node.id} width > 0`).toBeGreaterThan(0)
|
|
78
|
+
expect(rn.absH, `${rn.node.id} height > 0`).toBeGreaterThan(0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const card = nodes.find(rn => rn.node.id === 'card')!
|
|
82
|
+
const title = nodes.find(rn => rn.node.id === 'title')!
|
|
83
|
+
const desc = nodes.find(rn => rn.node.id === 'desc')!
|
|
84
|
+
|
|
85
|
+
// title inside card
|
|
86
|
+
expect(title.absX).toBeGreaterThan(card.absX)
|
|
87
|
+
expect(title.absY).toBeGreaterThan(card.absY)
|
|
88
|
+
|
|
89
|
+
// desc below title
|
|
90
|
+
expect(desc.absY).toBeGreaterThan(title.absY)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('absW/absH match nodeW/nodeH for frame without explicit dimensions', () => {
|
|
94
|
+
// Frame has children but no explicit width — not inside a layout parent,
|
|
95
|
+
// so computeLayoutPositions does NOT set width. This exposes the divergence
|
|
96
|
+
// between sizeToNumber (fallback 100) and getNodeWidth (fitContent → 200).
|
|
97
|
+
const root = frame({
|
|
98
|
+
id: 'root', width: 400, height: 600,
|
|
99
|
+
// No layout, gap, padding, or fill_container children → inferLayout returns undefined
|
|
100
|
+
children: [
|
|
101
|
+
frame({
|
|
102
|
+
id: 'inner',
|
|
103
|
+
// No explicit width or height
|
|
104
|
+
children: [
|
|
105
|
+
{ id: 'r1', type: 'rectangle', x: 10, y: 10, width: 200, height: 50 } as PenNode,
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
],
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const nodes = flattenToRenderNodes([root])
|
|
112
|
+
const inner = nodes.find(rn => rn.node.id === 'inner')!
|
|
113
|
+
|
|
114
|
+
// getNodeWidth → fitContentWidth → 200 (from child rectangle)
|
|
115
|
+
// Before fix: absW = 100 (sizeToNumber fallback). After fix: absW = 200.
|
|
116
|
+
expect(inner.absW).toBeGreaterThanOrEqual(200)
|
|
117
|
+
// getNodeHeight → fitContentHeight → 50 (from child rectangle)
|
|
118
|
+
// Before fix: absH = 100 (fallback). After fix: absH = 50 (or greater).
|
|
119
|
+
expect(inner.absH).toBeGreaterThanOrEqual(50)
|
|
120
|
+
expect(inner.absH).toBeLessThan(100) // not the 100 fallback
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('children with stripped x/y in layout frame get correct positions', () => {
|
|
124
|
+
const root = frame({
|
|
125
|
+
id: 'root', width: 400, height: 600, layout: 'vertical' as any,
|
|
126
|
+
padding: [20, 16],
|
|
127
|
+
gap: 12,
|
|
128
|
+
children: [
|
|
129
|
+
// x/y stripped by sanitizeLayoutChildPositions
|
|
130
|
+
{ id: 't1', type: 'text', content: 'First', fontSize: 16,
|
|
131
|
+
width: 'fill_container' as any } as PenNode,
|
|
132
|
+
{ id: 't2', type: 'text', content: 'Second', fontSize: 16,
|
|
133
|
+
width: 'fill_container' as any } as PenNode,
|
|
134
|
+
],
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const nodes = flattenToRenderNodes([root])
|
|
138
|
+
const t1 = nodes.find(rn => rn.node.id === 't1')!
|
|
139
|
+
const t2 = nodes.find(rn => rn.node.id === 't2')!
|
|
140
|
+
|
|
141
|
+
// t1 at padding offset
|
|
142
|
+
expect(t1.absX).toBe(16) // pad.left
|
|
143
|
+
expect(t1.absY).toBe(20) // pad.top
|
|
144
|
+
|
|
145
|
+
// t2 below t1 + gap
|
|
146
|
+
expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('root frame clipRect matches absW/absH, not a divergent nodeW/nodeH', () => {
|
|
150
|
+
// Root frame (depth=0) creates a clipRect for its children.
|
|
151
|
+
// clipRect must use the same dimensions as the RenderNode's absW/absH.
|
|
152
|
+
const root = frame({
|
|
153
|
+
id: 'root', width: 400, height: 600,
|
|
154
|
+
cornerRadius: 12,
|
|
155
|
+
layout: 'vertical' as any,
|
|
156
|
+
children: [
|
|
157
|
+
text('t1', 'Hello', { width: 'fill_container' as any }),
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const nodes = flattenToRenderNodes([root])
|
|
162
|
+
const rootRN = nodes.find(rn => rn.node.id === 'root')!
|
|
163
|
+
const t1 = nodes.find(rn => rn.node.id === 't1')!
|
|
164
|
+
|
|
165
|
+
// Root frame itself has no clipRect (it IS the clip source)
|
|
166
|
+
expect(rootRN.clipRect).toBeUndefined()
|
|
167
|
+
|
|
168
|
+
// Child inherits root's clip — must match root's rendered dimensions
|
|
169
|
+
expect(t1.clipRect).toBeDefined()
|
|
170
|
+
expect(t1.clipRect!.w).toBe(rootRN.absW)
|
|
171
|
+
expect(t1.clipRect!.h).toBe(rootRN.absH)
|
|
172
|
+
expect(t1.clipRect!.x).toBe(rootRN.absX)
|
|
173
|
+
expect(t1.clipRect!.y).toBe(rootRN.absY)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('root frame clipRect matches absW/absH for frame without explicit height', () => {
|
|
177
|
+
// Frame with fit_content height — getNodeHeight computes from children.
|
|
178
|
+
// clipRect.h must equal the RenderNode's absH, not a stale fallback.
|
|
179
|
+
const root = frame({
|
|
180
|
+
id: 'root', width: 375,
|
|
181
|
+
// No explicit height — relies on getNodeHeight → fitContentHeight
|
|
182
|
+
layout: 'vertical' as any,
|
|
183
|
+
padding: [20, 16],
|
|
184
|
+
children: [
|
|
185
|
+
text('t1', 'Card title', { width: 'fill_container' as any, fontSize: 18 }),
|
|
186
|
+
],
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const nodes = flattenToRenderNodes([root])
|
|
190
|
+
const rootRN = nodes.find(rn => rn.node.id === 'root')!
|
|
191
|
+
const t1 = nodes.find(rn => rn.node.id === 't1')!
|
|
192
|
+
|
|
193
|
+
// absH should be computed from content, not 100 fallback
|
|
194
|
+
expect(rootRN.absH).toBeGreaterThan(0)
|
|
195
|
+
|
|
196
|
+
// clipRect must match absH
|
|
197
|
+
expect(t1.clipRect).toBeDefined()
|
|
198
|
+
expect(t1.clipRect!.h).toBe(rootRN.absH)
|
|
199
|
+
expect(t1.clipRect!.w).toBe(rootRN.absW)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -52,7 +52,7 @@ export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
|
|
|
52
52
|
? tNode.content
|
|
53
53
|
: Array.isArray(tNode.content)
|
|
54
54
|
? tNode.content.map((s) => s.text ?? '').join('')
|
|
55
|
-
: ''
|
|
55
|
+
: (tNode as unknown as Record<string, unknown>).text as string ?? ''
|
|
56
56
|
|
|
57
57
|
const textAlign = tNode.textAlign
|
|
58
58
|
const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
|
|
@@ -172,8 +172,16 @@ export function flattenToRenderNodes(
|
|
|
172
172
|
|
|
173
173
|
const absX = (resolved.x ?? 0) + offsetX
|
|
174
174
|
const absY = (resolved.y ?? 0) + offsetY
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
|
|
176
|
+
// Compute authoritative dimensions once via getNodeWidth/getNodeHeight.
|
|
177
|
+
// Used for: RenderNode absW/absH, child available space, and clip rect.
|
|
178
|
+
// This replaces the prior split where absW/absH used sizeToNumber (raw
|
|
179
|
+
// parse + 100 fallback) while child layout used getNodeWidth/getNodeHeight,
|
|
180
|
+
// causing divergence when nodes lacked numeric dimensions.
|
|
181
|
+
const nodeW = getNodeWidth(resolved, parentAvailW)
|
|
182
|
+
const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
|
|
183
|
+
const absW = nodeW > 0 ? nodeW : ('width' in resolved ? sizeToNumber(resolved.width, 100) : 100)
|
|
184
|
+
const absH = nodeH > 0 ? nodeH : ('height' in resolved ? sizeToNumber(resolved.height, 100) : 100)
|
|
177
185
|
|
|
178
186
|
result.push({
|
|
179
187
|
node: { ...resolved, x: absX, y: absY } as PenNode,
|
|
@@ -184,8 +192,6 @@ export function flattenToRenderNodes(
|
|
|
184
192
|
// Recurse into children
|
|
185
193
|
const children = 'children' in node ? node.children : undefined
|
|
186
194
|
if (children && children.length > 0) {
|
|
187
|
-
const nodeW = getNodeWidth(resolved, parentAvailW)
|
|
188
|
-
const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
|
|
189
195
|
const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
|
|
190
196
|
const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
|
|
191
197
|
const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
|
package/src/paint-utils.ts
CHANGED
|
@@ -71,6 +71,7 @@ export function resolveFillColor(fills?: PenFill[] | string): string {
|
|
|
71
71
|
if (typeof fills === 'string') return fills
|
|
72
72
|
if (!fills || fills.length === 0) return DEFAULT_FILL
|
|
73
73
|
const first = fills[0]
|
|
74
|
+
if (!first) return DEFAULT_FILL
|
|
74
75
|
if (first.type === 'solid') return first.color
|
|
75
76
|
if (first.type === 'linear_gradient' || first.type === 'radial_gradient') {
|
|
76
77
|
return first.stops[0]?.color ?? DEFAULT_FILL
|
|
@@ -80,8 +81,10 @@ export function resolveFillColor(fills?: PenFill[] | string): string {
|
|
|
80
81
|
|
|
81
82
|
export function resolveStrokeColor(stroke?: PenStroke): string | undefined {
|
|
82
83
|
if (!stroke) return undefined
|
|
84
|
+
if (typeof stroke === 'string') return stroke
|
|
83
85
|
if (typeof stroke.fill === 'string') return stroke.fill
|
|
84
86
|
if (stroke.fill && stroke.fill.length > 0) return resolveFillColor(stroke.fill)
|
|
87
|
+
if ('color' in stroke && typeof (stroke as any).color === 'string') return (stroke as any).color
|
|
85
88
|
return undefined
|
|
86
89
|
}
|
|
87
90
|
|
package/src/text-renderer.ts
CHANGED
|
@@ -153,7 +153,7 @@ export class SkiaTextRenderer {
|
|
|
153
153
|
? tNode.content
|
|
154
154
|
: Array.isArray(tNode.content)
|
|
155
155
|
? tNode.content.map((s) => s.text ?? '').join('')
|
|
156
|
-
: ''
|
|
156
|
+
: (tNode as unknown as Record<string, unknown>).text as string ?? ''
|
|
157
157
|
if (!content) return true
|
|
158
158
|
|
|
159
159
|
const fontSize = tNode.fontSize ?? 16
|
|
@@ -288,10 +288,15 @@ export class SkiaTextRenderer {
|
|
|
288
288
|
}
|
|
289
289
|
const surfaceH = para.getHeight() + 2
|
|
290
290
|
|
|
291
|
-
// Try paragraph image cache: drawImageRect is far cheaper than drawParagraph per frame
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
// Try paragraph image cache: drawImageRect is far cheaper than drawParagraph per frame.
|
|
292
|
+
// Skip cache when zoomed in (> 1x) or significantly zoomed out (< 0.5x) — cached
|
|
293
|
+
// bitmaps are at fixed DPR resolution and produce jagged edges when scaled by the
|
|
294
|
+
// viewport transform. At normal zoom (0.5–1x), bitmap cache is safe and fast.
|
|
295
|
+
const useParaImageCache = this.zoom >= 0.5 && this.zoom <= 1
|
|
296
|
+
// Always rasterize at 2x minimum — 1x bitmaps produce jagged text on low-DPR displays
|
|
297
|
+
const imgScale = Math.max(this._dpr, 2)
|
|
298
|
+
let cachedImg: any = useParaImageCache ? this.paraImageCache.get(cacheKey) : null
|
|
299
|
+
if (useParaImageCache && cachedImg === undefined) {
|
|
295
300
|
cachedImg = null
|
|
296
301
|
const sw = Math.min(Math.ceil(surfaceW * imgScale), 4096)
|
|
297
302
|
const sh = Math.min(Math.ceil(surfaceH * imgScale), 4096)
|
|
@@ -310,7 +315,7 @@ export class SkiaTextRenderer {
|
|
|
310
315
|
}
|
|
311
316
|
}
|
|
312
317
|
}
|
|
313
|
-
this.paraImageCache.set(cacheKey, cachedImg)
|
|
318
|
+
if (useParaImageCache) this.paraImageCache.set(cacheKey, cachedImg)
|
|
314
319
|
}
|
|
315
320
|
|
|
316
321
|
if (cachedImg) {
|
|
@@ -399,7 +404,7 @@ export class SkiaTextRenderer {
|
|
|
399
404
|
? tNode.content
|
|
400
405
|
: Array.isArray(tNode.content)
|
|
401
406
|
? tNode.content.map((s) => s.text ?? '').join('')
|
|
402
|
-
: ''
|
|
407
|
+
: (tNode as unknown as Record<string, unknown>).text as string ?? ''
|
|
403
408
|
|
|
404
409
|
if (!content) return
|
|
405
410
|
|