@zseven-w/pen-renderer 0.5.2 → 0.7.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/README.md +6 -6
- package/package.json +9 -9
- package/src/__tests__/document-flattener.test.ts +277 -0
- package/src/__tests__/font-manager.test.ts +65 -0
- package/src/__tests__/image-loader.test.ts +136 -0
- package/src/__tests__/paint-utils.test.ts +61 -0
- package/src/__tests__/render-node-thumbnail.test.ts +312 -0
- package/src/document-flattener.ts +228 -159
- package/src/font-manager.ts +221 -190
- package/src/image-loader.ts +138 -51
- package/src/index.ts +18 -17
- package/src/init.ts +50 -21
- package/src/node-renderer.ts +957 -386
- package/src/paint-utils.ts +99 -71
- package/src/path-utils.ts +235 -115
- package/src/render-node-thumbnail.ts +155 -0
- package/src/renderer.ts +196 -175
- package/src/spatial-index.ts +139 -27
- package/src/text-renderer.ts +367 -304
- package/src/types.ts +18 -22
- package/src/viewport.ts +28 -29
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,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
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),
|
|
120
|
-
|
|
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,
|
|
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': {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
case '
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
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
|
+
}
|