@zseven-w/pen-renderer 0.6.0 → 0.7.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/src/path-utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CanvasKit, Path } from 'canvaskit-wasm'
1
+ import type { CanvasKit, Path } from 'canvaskit-wasm';
2
2
 
3
3
  /**
4
4
  * Normalize SVG path data for CanvasKit's parser:
@@ -17,7 +17,7 @@ export function sanitizeSvgPath(d: string): string {
17
17
  .replace(/,/g, ' ')
18
18
  // Collapse multiple spaces
19
19
  .replace(/\s+/g, ' ')
20
- .trim()
20
+ .trim();
21
21
 
22
22
  // Separate concatenated arc flags: in SVG arc commands, the large-arc and
23
23
  // sweep flags are single digits (0 or 1) that may be concatenated with each
@@ -25,20 +25,20 @@ export function sanitizeSvgPath(d: string): string {
25
25
  result = result.replace(
26
26
  /([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s+([01])([01])([\d.+-])/g,
27
27
  '$1 $2 $3 $4 $5 $6 $7',
28
- )
28
+ );
29
29
  // Handle the case where all three (rotation + flags) are concatenated without spaces,
30
30
  // e.g. "a4 4 0100-8" where 0100 = rotation=0, large-arc=1, sweep=0, then 0 is start of x
31
31
  result = result.replace(
32
32
  /([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+(\d)([01])([01])([\d.+-])/g,
33
33
  '$1 $2 $3 $4 $5 $6 $7',
34
- )
34
+ );
35
35
 
36
- return result
36
+ return result;
37
37
  }
38
38
 
39
39
  /** Returns true if the path string contains NaN or Infinity values. */
40
40
  export function hasInvalidNumbers(d: string): boolean {
41
- return /NaN|Infinity/i.test(d)
41
+ return /NaN|Infinity/i.test(d);
42
42
  }
43
43
 
44
44
  /**
@@ -47,89 +47,93 @@ export function hasInvalidNumbers(d: string): boolean {
47
47
  */
48
48
  function arcToCubics(
49
49
  path: Path,
50
- x1: number, y1: number,
51
- rxIn: number, ryIn: number,
52
- largeArc: boolean, sweep: boolean,
53
- x2: number, y2: number,
50
+ x1: number,
51
+ y1: number,
52
+ rxIn: number,
53
+ ryIn: number,
54
+ largeArc: boolean,
55
+ sweep: boolean,
56
+ x2: number,
57
+ y2: number,
54
58
  ): void {
55
59
  // Degenerate: start == end
56
- if (x1 === x2 && y1 === y2) return
60
+ if (x1 === x2 && y1 === y2) return;
57
61
 
58
- let rx = Math.abs(rxIn)
59
- let ry = Math.abs(ryIn)
62
+ let rx = Math.abs(rxIn);
63
+ let ry = Math.abs(ryIn);
60
64
 
61
- const dx = (x1 - x2) / 2
62
- const dy = (y1 - y2) / 2
65
+ const dx = (x1 - x2) / 2;
66
+ const dy = (y1 - y2) / 2;
63
67
  // Simplified: ignore rotation (most icons use rotation=0)
64
- const x1p = dx
65
- const y1p = dy
68
+ const x1p = dx;
69
+ const y1p = dy;
66
70
 
67
71
  // Correct radii
68
- let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
72
+ let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
69
73
  if (lambda > 1) {
70
- const s = Math.sqrt(lambda)
71
- rx *= s
72
- ry *= s
74
+ const s = Math.sqrt(lambda);
75
+ rx *= s;
76
+ ry *= s;
73
77
  }
74
78
 
75
- const rxSq = rx * rx
76
- const rySq = ry * ry
77
- const x1pSq = x1p * x1p
78
- const y1pSq = y1p * y1p
79
+ const rxSq = rx * rx;
80
+ const rySq = ry * ry;
81
+ const x1pSq = x1p * x1p;
82
+ const y1pSq = y1p * y1p;
79
83
 
80
- let sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq)
81
- if (sq < 0) sq = 0
82
- let root = Math.sqrt(sq)
83
- if (largeArc === sweep) root = -root
84
+ let sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq);
85
+ if (sq < 0) sq = 0;
86
+ let root = Math.sqrt(sq);
87
+ if (largeArc === sweep) root = -root;
84
88
 
85
- const cxp = root * rx * y1p / ry
86
- const cyp = -root * ry * x1p / rx
89
+ const cxp = (root * rx * y1p) / ry;
90
+ const cyp = (-root * ry * x1p) / rx;
87
91
 
88
- const cx = cxp + (x1 + x2) / 2
89
- const cy = cyp + (y1 + y2) / 2
92
+ const cx = cxp + (x1 + x2) / 2;
93
+ const cy = cyp + (y1 + y2) / 2;
90
94
 
91
95
  const angle = (ux: number, uy: number, vx: number, vy: number) => {
92
- const n = Math.sqrt(ux * ux + uy * uy)
93
- const d = Math.sqrt(vx * vx + vy * vy)
94
- const c = (ux * vx + uy * vy) / (n * d)
95
- const clamped = Math.max(-1, Math.min(1, c))
96
- let a = Math.acos(clamped)
97
- if (ux * vy - uy * vx < 0) a = -a
98
- return a
99
- }
96
+ const n = Math.sqrt(ux * ux + uy * uy);
97
+ const d = Math.sqrt(vx * vx + vy * vy);
98
+ const c = (ux * vx + uy * vy) / (n * d);
99
+ const clamped = Math.max(-1, Math.min(1, c));
100
+ let a = Math.acos(clamped);
101
+ if (ux * vy - uy * vx < 0) a = -a;
102
+ return a;
103
+ };
100
104
 
101
- const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
102
- let dTheta = angle(
103
- (x1p - cxp) / rx, (y1p - cyp) / ry,
104
- (-x1p - cxp) / rx, (-y1p - cyp) / ry,
105
- )
105
+ const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
106
+ let dTheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
106
107
 
107
- if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI
108
- if (sweep && dTheta < 0) dTheta += 2 * Math.PI
108
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
109
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
109
110
 
110
111
  // Split into segments of at most PI/2
111
- const segments = Math.ceil(Math.abs(dTheta) / (Math.PI / 2))
112
- const segAngle = dTheta / segments
112
+ const segments = Math.ceil(Math.abs(dTheta) / (Math.PI / 2));
113
+ const segAngle = dTheta / segments;
113
114
 
114
115
  for (let i = 0; i < segments; i++) {
115
- const t1 = theta1 + i * segAngle
116
- const t2 = t1 + segAngle
117
- const alpha = Math.sin(segAngle) * (Math.sqrt(4 + 3 * Math.pow(Math.tan(segAngle / 2), 2)) - 1) / 3
116
+ const t1 = theta1 + i * segAngle;
117
+ const t2 = t1 + segAngle;
118
+ const alpha =
119
+ (Math.sin(segAngle) * (Math.sqrt(4 + 3 * Math.pow(Math.tan(segAngle / 2), 2)) - 1)) / 3;
118
120
 
119
- const cos1 = Math.cos(t1), sin1 = Math.sin(t1)
120
- const cos2 = Math.cos(t2), sin2 = Math.sin(t2)
121
+ const cos1 = Math.cos(t1),
122
+ sin1 = Math.sin(t1);
123
+ const cos2 = Math.cos(t2),
124
+ sin2 = Math.sin(t2);
121
125
 
122
- const p1x = cx + rx * cos1
123
- const p1y = cy + ry * sin1
124
- const p2x = cx + rx * cos2
125
- const p2y = cy + ry * sin2
126
+ const p1x = cx + rx * cos1;
127
+ const p1y = cy + ry * sin1;
128
+ const p2x = cx + rx * cos2;
129
+ const p2y = cy + ry * sin2;
126
130
 
127
- const cp1x = p1x - alpha * rx * sin1
128
- const cp1y = p1y + alpha * ry * cos1
129
- const cp2x = p2x + alpha * rx * sin2
130
- const cp2y = p2y - alpha * ry * cos2
131
+ const cp1x = p1x - alpha * rx * sin1;
132
+ const cp1y = p1y + alpha * ry * cos1;
133
+ const cp2x = p2x + alpha * rx * sin2;
134
+ const cp2y = p2y - alpha * ry * cos2;
131
135
 
132
- path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y)
136
+ path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y);
133
137
  }
134
138
  }
135
139
 
@@ -140,86 +144,202 @@ function arcToCubics(
140
144
  */
141
145
  export function tryManualPathParse(ck: CanvasKit, d: string): Path | null {
142
146
  try {
143
- const path = new ck.Path()
147
+ const path = new ck.Path();
144
148
  // Replace NaN/Infinity with 0 so commands keep their parameter count.
145
- const cleaned = d.replace(/-?NaN/g, '0').replace(/-?Infinity/g, '0')
149
+ const cleaned = d.replace(/-?NaN/g, '0').replace(/-?Infinity/g, '0');
146
150
  // Tokenize: split on commands and extract numbers
147
- const tokens = cleaned.match(/[MLCQZAHVSmlcqzahvs]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g)
148
- if (!tokens || tokens.length === 0) return null
151
+ const tokens = cleaned.match(/[MLCQZAHVSmlcqzahvs]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g);
152
+ if (!tokens || tokens.length === 0) return null;
149
153
 
150
- let i = 0
151
- let lastCmd = ''
152
- let cx = 0, cy = 0 // current point
154
+ let i = 0;
155
+ let lastCmd = '';
156
+ let cx = 0,
157
+ cy = 0; // current point
153
158
 
154
159
  while (i < tokens.length) {
155
- let cmd = tokens[i]
160
+ let cmd = tokens[i];
156
161
  if (/^[MLCQZAHVSmlcqzahvs]$/.test(cmd)) {
157
- lastCmd = cmd
158
- i++
162
+ lastCmd = cmd;
163
+ i++;
159
164
  } else if (lastCmd) {
160
165
  // Implicit repeat of last command (M becomes L after first pair)
161
- cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd
166
+ cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd;
162
167
  } else {
163
- i++
164
- continue
168
+ i++;
169
+ continue;
165
170
  }
166
171
 
167
172
  const nums = (count: number): number[] => {
168
- const result: number[] = []
173
+ const result: number[] = [];
169
174
  for (let j = 0; j < count && i < tokens.length; j++) {
170
- const n = parseFloat(tokens[i])
171
- if (isNaN(n)) break
172
- result.push(n)
173
- i++
175
+ const n = parseFloat(tokens[i]);
176
+ if (isNaN(n)) break;
177
+ result.push(n);
178
+ i++;
174
179
  }
175
- return result
176
- }
180
+ return result;
181
+ };
177
182
 
178
183
  switch (cmd) {
179
- case 'M': { const p = nums(2); if (p.length === 2) { path.moveTo(p[0], p[1]); cx = p[0]; cy = p[1]; lastCmd = 'L' } break }
180
- case 'm': { const p = nums(2); if (p.length === 2) { path.moveTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1]; lastCmd = 'l' } break }
181
- case 'L': { const p = nums(2); if (p.length === 2) { path.lineTo(p[0], p[1]); cx = p[0]; cy = p[1] } break }
182
- case 'l': { const p = nums(2); if (p.length === 2) { path.lineTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1] } break }
183
- case 'H': { const p = nums(1); if (p.length === 1) { path.lineTo(p[0], cy); cx = p[0] } break }
184
- case 'h': { const p = nums(1); if (p.length === 1) { path.lineTo(cx + p[0], cy); cx += p[0] } break }
185
- case 'V': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, p[0]); cy = p[0] } break }
186
- case 'v': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, cy + p[0]); cy += p[0] } break }
187
- case 'C': { const p = nums(6); if (p.length === 6) { path.cubicTo(p[0], p[1], p[2], p[3], p[4], p[5]); cx = p[4]; cy = p[5] } break }
188
- case 'c': { const p = nums(6); if (p.length === 6) { path.cubicTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3], cx+p[4], cy+p[5]); cx += p[4]; cy += p[5] } break }
189
- case 'Q': { const p = nums(4); if (p.length === 4) { path.quadTo(p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
190
- case 'q': { const p = nums(4); if (p.length === 4) { path.quadTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
191
- case 'S': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
192
- case 's': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
193
- case 'Z': case 'z': path.close(); break
194
- case 'A': case 'a': {
184
+ case 'M': {
185
+ const p = nums(2);
186
+ if (p.length === 2) {
187
+ path.moveTo(p[0], p[1]);
188
+ cx = p[0];
189
+ cy = p[1];
190
+ lastCmd = 'L';
191
+ }
192
+ break;
193
+ }
194
+ case 'm': {
195
+ const p = nums(2);
196
+ if (p.length === 2) {
197
+ path.moveTo(cx + p[0], cy + p[1]);
198
+ cx += p[0];
199
+ cy += p[1];
200
+ lastCmd = 'l';
201
+ }
202
+ break;
203
+ }
204
+ case 'L': {
205
+ const p = nums(2);
206
+ if (p.length === 2) {
207
+ path.lineTo(p[0], p[1]);
208
+ cx = p[0];
209
+ cy = p[1];
210
+ }
211
+ break;
212
+ }
213
+ case 'l': {
214
+ const p = nums(2);
215
+ if (p.length === 2) {
216
+ path.lineTo(cx + p[0], cy + p[1]);
217
+ cx += p[0];
218
+ cy += p[1];
219
+ }
220
+ break;
221
+ }
222
+ case 'H': {
223
+ const p = nums(1);
224
+ if (p.length === 1) {
225
+ path.lineTo(p[0], cy);
226
+ cx = p[0];
227
+ }
228
+ break;
229
+ }
230
+ case 'h': {
231
+ const p = nums(1);
232
+ if (p.length === 1) {
233
+ path.lineTo(cx + p[0], cy);
234
+ cx += p[0];
235
+ }
236
+ break;
237
+ }
238
+ case 'V': {
239
+ const p = nums(1);
240
+ if (p.length === 1) {
241
+ path.lineTo(cx, p[0]);
242
+ cy = p[0];
243
+ }
244
+ break;
245
+ }
246
+ case 'v': {
247
+ const p = nums(1);
248
+ if (p.length === 1) {
249
+ path.lineTo(cx, cy + p[0]);
250
+ cy += p[0];
251
+ }
252
+ break;
253
+ }
254
+ case 'C': {
255
+ const p = nums(6);
256
+ if (p.length === 6) {
257
+ path.cubicTo(p[0], p[1], p[2], p[3], p[4], p[5]);
258
+ cx = p[4];
259
+ cy = p[5];
260
+ }
261
+ break;
262
+ }
263
+ case 'c': {
264
+ const p = nums(6);
265
+ if (p.length === 6) {
266
+ path.cubicTo(cx + p[0], cy + p[1], cx + p[2], cy + p[3], cx + p[4], cy + p[5]);
267
+ cx += p[4];
268
+ cy += p[5];
269
+ }
270
+ break;
271
+ }
272
+ case 'Q': {
273
+ const p = nums(4);
274
+ if (p.length === 4) {
275
+ path.quadTo(p[0], p[1], p[2], p[3]);
276
+ cx = p[2];
277
+ cy = p[3];
278
+ }
279
+ break;
280
+ }
281
+ case 'q': {
282
+ const p = nums(4);
283
+ if (p.length === 4) {
284
+ path.quadTo(cx + p[0], cy + p[1], cx + p[2], cy + p[3]);
285
+ cx += p[2];
286
+ cy += p[3];
287
+ }
288
+ break;
289
+ }
290
+ case 'S': {
291
+ const p = nums(4);
292
+ if (p.length === 4) {
293
+ path.cubicTo(cx, cy, p[0], p[1], p[2], p[3]);
294
+ cx = p[2];
295
+ cy = p[3];
296
+ }
297
+ break;
298
+ }
299
+ case 's': {
300
+ const p = nums(4);
301
+ if (p.length === 4) {
302
+ path.cubicTo(cx, cy, cx + p[0], cy + p[1], cx + p[2], cy + p[3]);
303
+ cx += p[2];
304
+ cy += p[3];
305
+ }
306
+ break;
307
+ }
308
+ case 'Z':
309
+ case 'z':
310
+ path.close();
311
+ break;
312
+ case 'A':
313
+ case 'a': {
195
314
  // Arc: rx, ry, rotation, largeArc, sweep, x, y
196
- const p = nums(7)
315
+ const p = nums(7);
197
316
  if (p.length === 7) {
198
- const [rx, ry, , largeArc, sweep, ex, ey] = p
199
- const endX = cmd === 'a' ? cx + ex : ex
200
- const endY = cmd === 'a' ? cy + ey : ey
317
+ const [rx, ry, , largeArc, sweep, ex, ey] = p;
318
+ const endX = cmd === 'a' ? cx + ex : ex;
319
+ const endY = cmd === 'a' ? cy + ey : ey;
201
320
  if (rx > 0 && ry > 0) {
202
- arcToCubics(path, cx, cy, rx, ry, largeArc !== 0, sweep !== 0, endX, endY)
321
+ arcToCubics(path, cx, cy, rx, ry, largeArc !== 0, sweep !== 0, endX, endY);
203
322
  } else {
204
- path.lineTo(endX, endY)
323
+ path.lineTo(endX, endY);
205
324
  }
206
- cx = endX
207
- cy = endY
325
+ cx = endX;
326
+ cy = endY;
208
327
  }
209
- break
328
+ break;
210
329
  }
211
- default: i++
330
+ default:
331
+ i++;
212
332
  }
213
333
  }
214
334
 
215
335
  // Check if path has any geometry
216
- const bounds = path.getBounds()
336
+ const bounds = path.getBounds();
217
337
  if (bounds[2] - bounds[0] < 0.001 && bounds[3] - bounds[1] < 0.001) {
218
- path.delete()
219
- return null
338
+ path.delete();
339
+ return null;
220
340
  }
221
- return path
341
+ return path;
222
342
  } catch {
223
- return null
343
+ return null;
224
344
  }
225
345
  }
@@ -0,0 +1,155 @@
1
+ // packages/pen-renderer/src/render-node-thumbnail.ts
2
+ //
3
+ // Offscreen thumbnail helper for individual PenNodes. Used by the git conflict
4
+ // UI to render side-by-side ours/theirs previews without mounting a full
5
+ // PenRenderer instance.
6
+ //
7
+ // Design goals:
8
+ // - Accept full document context so ref-type nodes resolve correctly.
9
+ // - Return a data-URL string on success, null on any failure (graceful).
10
+ // - Never throw — all errors are caught and converted to null.
11
+ // - CanvasKit is not available in tests / SSR: detect and fall back to null.
12
+ // - The output shape (data URL | null) is stable for test assertions.
13
+
14
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types';
15
+ import { getAllChildren, getDefaultTheme, resolveNodeForCanvas } from '@zseven-w/pen-core';
16
+ import { flattenToRenderNodes, resolveRefs } from './document-flattener.js';
17
+
18
+ export interface ThumbnailContext {
19
+ /** Full document used for ref node resolution. */
20
+ document: PenDocument;
21
+ /** Page id used to locate the node in a multi-page doc (unused today; reserved for future per-page context). */
22
+ pageId: string | null;
23
+ /** Output canvas size in logical pixels (square, default: 128). */
24
+ size?: number;
25
+ }
26
+
27
+ /**
28
+ * Render a single PenNode into a data URL at the requested size.
29
+ *
30
+ * Returns a data URL string when rendering succeeded, or `null` when:
31
+ * - CanvasKit / OffscreenCanvas is unavailable (Node.js / test env)
32
+ * - The node or document is invalid
33
+ * - Any rendering step throws
34
+ *
35
+ * Callers MUST handle the `null` case and display a placeholder.
36
+ */
37
+ export async function renderNodeThumbnail(
38
+ node: PenNode,
39
+ ctx: ThumbnailContext,
40
+ ): Promise<string | null> {
41
+ try {
42
+ // Guard: need a valid node object.
43
+ if (!node || typeof node !== 'object') return null;
44
+
45
+ const size = ctx.size ?? 128;
46
+ if (!Number.isFinite(size) || size <= 0) return null;
47
+
48
+ // Resolve ref nodes using the root document tree as the component registry.
49
+ // resolveRefs walks the node tree and substitutes `ref` nodes with their
50
+ // component originals. For non-ref nodes it is a shallow identity pass.
51
+ // getAllChildren handles both single-page (document.children) and multi-page
52
+ // (document.pages[i].children) layouts — refs can cross pages so we need all.
53
+ const rootNodes: PenNode[] = ctx.document ? getAllChildren(ctx.document) : [];
54
+ let resolvedNodes: PenNode[];
55
+ try {
56
+ resolvedNodes = resolveRefs([node], rootNodes);
57
+ } catch {
58
+ // If ref resolution fails (e.g. circular ref), fall back to raw node.
59
+ resolvedNodes = [node];
60
+ }
61
+
62
+ const resolvedNode = resolvedNodes[0] ?? node;
63
+
64
+ // Resolve design $variable references so fill colors, stroke widths, etc.
65
+ // render with their concrete values rather than the raw "$color-primary"
66
+ // strings. Mirrors the same step in renderer.ts (line ~279).
67
+ const variables = ctx.document?.variables ?? {};
68
+ const themes = ctx.document?.themes;
69
+ const activeTheme = getDefaultTheme(themes);
70
+ const variableResolved = resolveNodeForCanvas(resolvedNode, variables, activeTheme);
71
+
72
+ // Flatten to RenderNode array so we have absolute coordinates, auto-layout
73
+ // positions, and text pre-measurements for all descendants.
74
+ let renderNodes;
75
+ try {
76
+ renderNodes = flattenToRenderNodes([variableResolved]);
77
+ } catch {
78
+ return null;
79
+ }
80
+
81
+ if (!renderNodes || renderNodes.length === 0) return null;
82
+
83
+ // Detect CanvasKit availability — not available in tests or SSR.
84
+ let ck: import('canvaskit-wasm').CanvasKit | null = null;
85
+ try {
86
+ const { getCanvasKit } = await import('./init.js');
87
+ ck = getCanvasKit();
88
+ } catch {
89
+ return null;
90
+ }
91
+
92
+ if (!ck) {
93
+ // CanvasKit not initialised yet.
94
+ return null;
95
+ }
96
+
97
+ // OffscreenCanvas guard — not available in Node.js.
98
+ if (typeof OffscreenCanvas === 'undefined') return null;
99
+
100
+ // Determine scaling: fit the node's bounding box into the requested size.
101
+ const rootRenderNode = renderNodes[0];
102
+ const nodeW = rootRenderNode.absW > 0 ? rootRenderNode.absW : size;
103
+ const nodeH = rootRenderNode.absH > 0 ? rootRenderNode.absH : size;
104
+ const scale = Math.min(size / nodeW, size / nodeH);
105
+
106
+ const canvasW = Math.max(1, Math.round(nodeW * scale));
107
+ const canvasH = Math.max(1, Math.round(nodeH * scale));
108
+
109
+ // Software SkSurface (no WebGL required — safe for offscreen use).
110
+ const skSurface = ck.MakeSurface(canvasW, canvasH);
111
+ if (!skSurface) return null;
112
+
113
+ try {
114
+ const skCanvas = skSurface.getCanvas();
115
+ skCanvas.clear(ck.TRANSPARENT);
116
+ skCanvas.scale(scale, scale);
117
+
118
+ const { SkiaNodeRenderer } = await import('./node-renderer.js');
119
+ const renderer = new SkiaNodeRenderer(ck);
120
+ for (const rn of renderNodes) {
121
+ renderer.drawNode(skCanvas, rn);
122
+ }
123
+
124
+ skSurface.flush();
125
+ const imgSnapshot = skSurface.makeImageSnapshot();
126
+ if (!imgSnapshot) return null;
127
+
128
+ const pngBytes = imgSnapshot.encodeToBytes();
129
+ if (!pngBytes) return null;
130
+
131
+ // Convert raw PNG bytes to a data URL via Blob + FileReader.
132
+ const blob = new Blob([pngBytes as Uint8Array<ArrayBuffer>], { type: 'image/png' });
133
+ const dataUrl = await blobToDataUrl(blob);
134
+ return dataUrl;
135
+ } finally {
136
+ skSurface.delete();
137
+ }
138
+ } catch {
139
+ // Any unexpected error → graceful null
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /** Convert a Blob to a data URL using FileReader. */
145
+ async function blobToDataUrl(blob: Blob): Promise<string | null> {
146
+ return new Promise<string | null>((resolve) => {
147
+ const reader = new FileReader();
148
+ reader.onload = () => {
149
+ const result = reader.result;
150
+ resolve(typeof result === 'string' ? result : null);
151
+ };
152
+ reader.onerror = () => resolve(null);
153
+ reader.readAsDataURL(blob);
154
+ });
155
+ }