@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.1",
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.1",
20
- "@zseven-w/pen-core": "0.0.1",
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
- const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
176
- const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
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)
@@ -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
 
@@ -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
- const imgScale = Math.min(this._dpr, 2)
293
- let cachedImg = this.paraImageCache.get(cacheKey)
294
- if (cachedImg === undefined) {
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