ntk 2.2.0 → 3.1.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
package/lib/path.js ADDED
@@ -0,0 +1,734 @@
1
+ // Vector path support shared by the 2d context and the SVG/TeX widgets:
2
+ //
3
+ // - affine matrix helpers ([a, b, c, d, e, f], canvas convention:
4
+ // x' = a*x + c*y + e, y' = b*x + d*y + f)
5
+ // - an SVG path-data parser (full grammar incl. elliptical arcs, which are
6
+ // converted to cubics) producing normalized M/L/C/Q/Z commands
7
+ // - Path2D: the HTML canvas retained-path class
8
+ // - flattenPath(): commands -> polylines, with adaptive (error-bounded)
9
+ // bezier subdivision in device space
10
+
11
+ const TAU = Math.PI * 2;
12
+
13
+ // --------------------------------------------------------------------------
14
+ // matrices
15
+
16
+ export const MAT_IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
17
+
18
+ /** m · n (n applied first, then m — canvas ctx.transform() order) */
19
+ export function matMultiply(m, n) {
20
+ return [
21
+ m[0] * n[0] + m[2] * n[1],
22
+ m[1] * n[0] + m[3] * n[1],
23
+ m[0] * n[2] + m[2] * n[3],
24
+ m[1] * n[2] + m[3] * n[3],
25
+ m[0] * n[4] + m[2] * n[5] + m[4],
26
+ m[1] * n[4] + m[3] * n[5] + m[5]
27
+ ];
28
+ }
29
+
30
+ export function matApply(m, x, y) {
31
+ return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
32
+ }
33
+
34
+ export function matInvert(m) {
35
+ const det = m[0] * m[3] - m[1] * m[2];
36
+ if (!det || !Number.isFinite(det)) return null;
37
+ const a = m[3] / det;
38
+ const b = -m[1] / det;
39
+ const c = -m[2] / det;
40
+ const d = m[0] / det;
41
+ return [a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])];
42
+ }
43
+
44
+ export function matIsIdentity(m) {
45
+ return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
46
+ }
47
+
48
+ // --------------------------------------------------------------------------
49
+ // elliptical arcs -> cubics
50
+
51
+ /**
52
+ * Cubic bezier segments approximating an elliptical arc: center (cx, cy),
53
+ * radii (rx, ry), rotated by phi, from angle a0 sweeping by da (signed,
54
+ * |da| <= 2π). Returns { start: {x, y}, cmds: [{type:'C', ...}, ...] };
55
+ * segments are split to <= 90° so the approximation error stays tiny.
56
+ */
57
+ export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
58
+ const cosPhi = Math.cos(phi);
59
+ const sinPhi = Math.sin(phi);
60
+ const point = (a) => {
61
+ const x = rx * Math.cos(a);
62
+ const y = ry * Math.sin(a);
63
+ return [cx + cosPhi * x - sinPhi * y, cy + sinPhi * x + cosPhi * y];
64
+ };
65
+ const derivative = (a) => {
66
+ const x = -rx * Math.sin(a);
67
+ const y = ry * Math.cos(a);
68
+ return [cosPhi * x - sinPhi * y, sinPhi * x + cosPhi * y];
69
+ };
70
+
71
+ const [sx, sy] = point(a0);
72
+ const cmds = [];
73
+ const n = Math.max(1, Math.ceil(Math.abs(da) / (Math.PI / 2)));
74
+ let a = a0;
75
+ const step = da / n;
76
+ for (let i = 0; i < n; i++) {
77
+ const b = a + step;
78
+ const k = (4 / 3) * Math.tan((b - a) / 4);
79
+ const [x0, y0] = point(a);
80
+ const [x3, y3] = point(b);
81
+ const [dx0, dy0] = derivative(a);
82
+ const [dx3, dy3] = derivative(b);
83
+ cmds.push({
84
+ type: 'C',
85
+ x1: x0 + k * dx0,
86
+ y1: y0 + k * dy0,
87
+ x2: x3 - k * dx3,
88
+ y2: y3 - k * dy3,
89
+ x: x3,
90
+ y: y3
91
+ });
92
+ a = b;
93
+ }
94
+ return { start: { x: sx, y: sy }, cmds };
95
+ }
96
+
97
+ /**
98
+ * Canvas arc/ellipse semantics (start/end angle + counterclockwise flag,
99
+ * with the spec's sweep normalization) -> { start, cmds } cubics.
100
+ */
101
+ export function ellipseSegments(x, y, rx, ry, rotation, a0, a1, counterclockwise = false) {
102
+ let da = a1 - a0;
103
+ if (!counterclockwise) {
104
+ if (da >= TAU) da = TAU;
105
+ else {
106
+ da %= TAU;
107
+ if (da < 0) da += TAU;
108
+ }
109
+ } else if (-da >= TAU) {
110
+ da = -TAU;
111
+ } else {
112
+ da %= TAU;
113
+ if (da > 0) da -= TAU;
114
+ }
115
+ return ellipseCubics(x, y, rx, ry, rotation, a0, da);
116
+ }
117
+
118
+ // SVG endpoint parameterization (spec appendix F.6.5) -> center form -> cubics
119
+ function svgArcCommands(x0, y0, rx, ry, xRotationDeg, largeArc, sweep, x, y) {
120
+ if (x0 === x && y0 === y) return [];
121
+ rx = Math.abs(rx);
122
+ ry = Math.abs(ry);
123
+ if (!rx || !ry) return [{ type: 'L', x, y }];
124
+
125
+ const phi = (xRotationDeg * Math.PI) / 180;
126
+ const cosPhi = Math.cos(phi);
127
+ const sinPhi = Math.sin(phi);
128
+ const dx = (x0 - x) / 2;
129
+ const dy = (y0 - y) / 2;
130
+ const x1p = cosPhi * dx + sinPhi * dy;
131
+ const y1p = -sinPhi * dx + cosPhi * dy;
132
+
133
+ const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
134
+ if (lambda > 1) {
135
+ const s = Math.sqrt(lambda);
136
+ rx *= s;
137
+ ry *= s;
138
+ }
139
+
140
+ const rx2 = rx * rx;
141
+ const ry2 = ry * ry;
142
+ const num = rx2 * ry2 - rx2 * y1p * y1p - ry2 * x1p * x1p;
143
+ const den = rx2 * y1p * y1p + ry2 * x1p * x1p;
144
+ let coef = Math.sqrt(Math.max(0, num / den));
145
+ if (largeArc === sweep) coef = -coef;
146
+ const cxp = (coef * rx * y1p) / ry;
147
+ const cyp = (-coef * ry * x1p) / rx;
148
+ const cx = cosPhi * cxp - sinPhi * cyp + (x0 + x) / 2;
149
+ const cy = sinPhi * cxp + cosPhi * cyp + (y0 + y) / 2;
150
+
151
+ const angle = (ux, uy, vx, vy) => {
152
+ const sign = ux * vy - uy * vx < 0 ? -1 : 1;
153
+ const d = (ux * vx + uy * vy) / Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
154
+ return sign * Math.acos(Math.min(1, Math.max(-1, d)));
155
+ };
156
+ const a0 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
157
+ let da = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
158
+ if (!sweep && da > 0) da -= TAU;
159
+ if (sweep && da < 0) da += TAU;
160
+
161
+ const { cmds } = ellipseCubics(cx, cy, rx, ry, phi, a0, da);
162
+ if (cmds.length) {
163
+ // land exactly on the endpoint (kill accumulated fp error)
164
+ cmds[cmds.length - 1].x = x;
165
+ cmds[cmds.length - 1].y = y;
166
+ }
167
+ return cmds;
168
+ }
169
+
170
+ // --------------------------------------------------------------------------
171
+ // SVG path data
172
+
173
+ const CMD_ARGC = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
174
+
175
+ /**
176
+ * Parse SVG path data (`d` attribute) into normalized commands:
177
+ * `{type:'M',x,y}`, `{type:'L',x,y}`, `{type:'C',x1,y1,x2,y2,x,y}`,
178
+ * `{type:'Q',x1,y1,x,y}`, `{type:'Z'}`. H/V/S/T/A and all relative forms
179
+ * are resolved; elliptical arcs are converted to cubics.
180
+ *
181
+ * The scanner is position-based (not a global number regex) so compact
182
+ * arc flags like `a1 1 0 011 0` parse correctly.
183
+ */
184
+ export function parseSvgPath(d) {
185
+ const cmds = [];
186
+ let i = 0;
187
+ const n = d.length;
188
+
189
+ const isWs = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f';
190
+ const skipSep = () => {
191
+ while (i < n && (isWs(d[i]) || d[i] === ',')) i++;
192
+ };
193
+ const readNumber = () => {
194
+ skipSep();
195
+ const start = i;
196
+ if (d[i] === '+' || d[i] === '-') i++;
197
+ while (i < n && d[i] >= '0' && d[i] <= '9') i++;
198
+ if (d[i] === '.') {
199
+ i++;
200
+ while (i < n && d[i] >= '0' && d[i] <= '9') i++;
201
+ }
202
+ if (d[i] === 'e' || d[i] === 'E') {
203
+ i++;
204
+ if (d[i] === '+' || d[i] === '-') i++;
205
+ while (i < n && d[i] >= '0' && d[i] <= '9') i++;
206
+ }
207
+ if (i === start) return null;
208
+ const v = parseFloat(d.slice(start, i));
209
+ return Number.isFinite(v) ? v : null;
210
+ };
211
+ const readFlag = () => {
212
+ skipSep();
213
+ if (d[i] === '0' || d[i] === '1') return d[i++] === '1';
214
+ return null;
215
+ };
216
+
217
+ let cmd = null;
218
+ let x = 0;
219
+ let y = 0;
220
+ let sx = 0;
221
+ let sy = 0;
222
+ let px = null; // previous cubic/quad control point, for S/T reflection
223
+ let py = null;
224
+
225
+ while (i < n) {
226
+ skipSep();
227
+ if (i >= n) break;
228
+ const ch = d[i];
229
+ if (/[a-zA-Z]/.test(ch)) {
230
+ if (!(ch.toUpperCase() in CMD_ARGC)) break; // invalid command: stop
231
+ cmd = ch;
232
+ i++;
233
+ } else if (cmd === null) {
234
+ break; // leading garbage
235
+ }
236
+ const rel = cmd >= 'a';
237
+ const C = cmd.toUpperCase();
238
+
239
+ if (C === 'Z') {
240
+ cmds.push({ type: 'Z' });
241
+ x = sx;
242
+ y = sy;
243
+ px = py = null;
244
+ continue;
245
+ }
246
+
247
+ // one argument group; number-led repeats loop back via the while
248
+ const before = i;
249
+ let curved = false;
250
+ switch (C) {
251
+ case 'M': {
252
+ const ax = readNumber();
253
+ const ay = readNumber();
254
+ if (ax === null || ay === null) return cmds;
255
+ x = rel ? x + ax : ax;
256
+ y = rel ? y + ay : ay;
257
+ sx = x;
258
+ sy = y;
259
+ cmds.push({ type: 'M', x, y });
260
+ cmd = rel ? 'l' : 'L'; // subsequent pairs are linetos
261
+ break;
262
+ }
263
+ case 'L': {
264
+ const ax = readNumber();
265
+ const ay = readNumber();
266
+ if (ax === null || ay === null) return cmds;
267
+ x = rel ? x + ax : ax;
268
+ y = rel ? y + ay : ay;
269
+ cmds.push({ type: 'L', x, y });
270
+ break;
271
+ }
272
+ case 'H': {
273
+ const ax = readNumber();
274
+ if (ax === null) return cmds;
275
+ x = rel ? x + ax : ax;
276
+ cmds.push({ type: 'L', x, y });
277
+ break;
278
+ }
279
+ case 'V': {
280
+ const ay = readNumber();
281
+ if (ay === null) return cmds;
282
+ y = rel ? y + ay : ay;
283
+ cmds.push({ type: 'L', x, y });
284
+ break;
285
+ }
286
+ case 'C':
287
+ case 'S': {
288
+ let x1;
289
+ let y1;
290
+ if (C === 'C') {
291
+ const a = readNumber();
292
+ const b = readNumber();
293
+ if (a === null || b === null) return cmds;
294
+ x1 = rel ? x + a : a;
295
+ y1 = rel ? y + b : b;
296
+ } else {
297
+ x1 = px !== null ? 2 * x - px : x;
298
+ y1 = py !== null ? 2 * y - py : y;
299
+ }
300
+ const c = readNumber();
301
+ const dnum = readNumber();
302
+ const e = readNumber();
303
+ const f = readNumber();
304
+ if (c === null || dnum === null || e === null || f === null) return cmds;
305
+ const x2 = rel ? x + c : c;
306
+ const y2 = rel ? y + dnum : dnum;
307
+ x = rel ? x + e : e;
308
+ y = rel ? y + f : f;
309
+ cmds.push({ type: 'C', x1, y1, x2, y2, x, y });
310
+ px = x2;
311
+ py = y2;
312
+ curved = true;
313
+ break;
314
+ }
315
+ case 'Q':
316
+ case 'T': {
317
+ let x1;
318
+ let y1;
319
+ if (C === 'Q') {
320
+ const a = readNumber();
321
+ const b = readNumber();
322
+ if (a === null || b === null) return cmds;
323
+ x1 = rel ? x + a : a;
324
+ y1 = rel ? y + b : b;
325
+ } else {
326
+ x1 = px !== null ? 2 * x - px : x;
327
+ y1 = py !== null ? 2 * y - py : y;
328
+ }
329
+ const e = readNumber();
330
+ const f = readNumber();
331
+ if (e === null || f === null) return cmds;
332
+ x = rel ? x + e : e;
333
+ y = rel ? y + f : f;
334
+ cmds.push({ type: 'Q', x1, y1, x, y });
335
+ px = x1;
336
+ py = y1;
337
+ curved = true;
338
+ break;
339
+ }
340
+ case 'A': {
341
+ const rx = readNumber();
342
+ const ry = readNumber();
343
+ const rot = readNumber();
344
+ const large = readFlag();
345
+ const sweep = readFlag();
346
+ const e = readNumber();
347
+ const f = readNumber();
348
+ if (rx === null || ry === null || rot === null || large === null || sweep === null || e === null || f === null) {
349
+ return cmds;
350
+ }
351
+ const nx = rel ? x + e : e;
352
+ const ny = rel ? y + f : f;
353
+ for (const c of svgArcCommands(x, y, rx, ry, rot, large, sweep, nx, ny)) cmds.push(c);
354
+ x = nx;
355
+ y = ny;
356
+ break;
357
+ }
358
+ }
359
+ if (!curved) px = py = null;
360
+ if (i === before) break; // no progress: malformed input
361
+ }
362
+ return cmds;
363
+ }
364
+
365
+ // --------------------------------------------------------------------------
366
+ // Path2D
367
+
368
+ function normalizeRadii(w, h, radii) {
369
+ if (radii === undefined) radii = 0;
370
+ if (typeof radii === 'number') radii = [radii];
371
+ const take = (v) => (typeof v === 'number' ? { x: v, y: v } : { x: v.x ?? 0, y: v.y ?? 0 });
372
+ let tl;
373
+ let tr;
374
+ let br;
375
+ let bl;
376
+ if (radii.length === 1) tl = tr = br = bl = take(radii[0]);
377
+ else if (radii.length === 2) {
378
+ tl = br = take(radii[0]);
379
+ tr = bl = take(radii[1]);
380
+ } else if (radii.length === 3) {
381
+ tl = take(radii[0]);
382
+ tr = bl = take(radii[1]);
383
+ br = take(radii[2]);
384
+ } else {
385
+ tl = take(radii[0]);
386
+ tr = take(radii[1]);
387
+ br = take(radii[2]);
388
+ bl = take(radii[3]);
389
+ }
390
+ // scale down so opposite corners never overlap
391
+ const s = Math.min(
392
+ 1,
393
+ w / (tl.x + tr.x) || 1,
394
+ h / (tr.y + br.y) || 1,
395
+ w / (br.x + bl.x) || 1,
396
+ h / (bl.y + tl.y) || 1
397
+ );
398
+ const f = (r) => ({ x: Math.max(0, r.x * s), y: Math.max(0, r.y * s) });
399
+ return [f(tl), f(tr), f(br), f(bl)];
400
+ }
401
+
402
+ /**
403
+ * Retained path, HTML canvas compatible. Coordinates are in the space the
404
+ * path is built in; the 2d context applies its current transform when the
405
+ * path is filled/stroked/clipped (per the canvas spec).
406
+ *
407
+ * new Path2D() empty
408
+ * new Path2D(otherPath) copy
409
+ * new Path2D('M0 0L10 0...') from SVG path data
410
+ */
411
+ export class Path2D {
412
+ constructor(init) {
413
+ /** normalized commands (M/L/C/Q/Z); treat as read-only */
414
+ this._cmds = [];
415
+ this._x = null; // current point
416
+ this._y = null;
417
+ this._sx = null; // subpath start
418
+ this._sy = null;
419
+ if (init instanceof Path2D) {
420
+ this._cmds = init._cmds.map((c) => ({ ...c }));
421
+ this._x = init._x;
422
+ this._y = init._y;
423
+ this._sx = init._sx;
424
+ this._sy = init._sy;
425
+ } else if (typeof init === 'string') {
426
+ this._append(parseSvgPath(init));
427
+ }
428
+ }
429
+
430
+ _track(c) {
431
+ if (c.type === 'M') {
432
+ this._sx = c.x;
433
+ this._sy = c.y;
434
+ this._x = c.x;
435
+ this._y = c.y;
436
+ } else if (c.type === 'Z') {
437
+ this._x = this._sx;
438
+ this._y = this._sy;
439
+ } else {
440
+ this._x = c.x;
441
+ this._y = c.y;
442
+ }
443
+ }
444
+
445
+ _append(cmds) {
446
+ for (const c of cmds) {
447
+ this._cmds.push(c);
448
+ this._track(c);
449
+ }
450
+ }
451
+
452
+ // append segment commands, connecting to the current subpath: a leading
453
+ // implicit start point becomes lineTo when a current point exists
454
+ _appendSegments(start, cmds) {
455
+ if (this._x === null) this.moveTo(start.x, start.y);
456
+ else if (this._x !== start.x || this._y !== start.y) this.lineTo(start.x, start.y);
457
+ this._append(cmds);
458
+ }
459
+
460
+ moveTo(x, y) {
461
+ this._append([{ type: 'M', x, y }]);
462
+ }
463
+
464
+ lineTo(x, y) {
465
+ if (this._x === null) return this.moveTo(x, y);
466
+ this._append([{ type: 'L', x, y }]);
467
+ }
468
+
469
+ closePath() {
470
+ if (this._cmds.length) this._append([{ type: 'Z' }]);
471
+ }
472
+
473
+ bezierCurveTo(x1, y1, x2, y2, x, y) {
474
+ if (this._x === null) this.moveTo(x1, y1);
475
+ this._append([{ type: 'C', x1, y1, x2, y2, x, y }]);
476
+ }
477
+
478
+ quadraticCurveTo(x1, y1, x, y) {
479
+ if (this._x === null) this.moveTo(x1, y1);
480
+ this._append([{ type: 'Q', x1, y1, x, y }]);
481
+ }
482
+
483
+ arc(x, y, r, a0, a1, counterclockwise = false) {
484
+ this.ellipse(x, y, r, r, 0, a0, a1, counterclockwise);
485
+ }
486
+
487
+ ellipse(x, y, rx, ry, rotation, a0, a1, counterclockwise = false) {
488
+ if (rx < 0 || ry < 0) throw new RangeError('ellipse: negative radius');
489
+ const { start, cmds } = ellipseSegments(x, y, rx, ry, rotation, a0, a1, counterclockwise);
490
+ this._appendSegments(start, cmds);
491
+ }
492
+
493
+ arcTo(x1, y1, x2, y2, r) {
494
+ if (r < 0) throw new RangeError('arcTo: negative radius');
495
+ if (this._x === null) return this.moveTo(x1, y1);
496
+ const x0 = this._x;
497
+ const y0 = this._y;
498
+ const ax = x0 - x1;
499
+ const ay = y0 - y1;
500
+ const bx = x2 - x1;
501
+ const by = y2 - y1;
502
+ const cross = ax * by - ay * bx;
503
+ const la = Math.hypot(ax, ay);
504
+ const lb = Math.hypot(bx, by);
505
+ if (!r || !la || !lb || Math.abs(cross) < 1e-9) return this.lineTo(x1, y1);
506
+
507
+ const theta = Math.acos(Math.min(1, Math.max(-1, (ax * bx + ay * by) / (la * lb))));
508
+ const dist = r / Math.tan(theta / 2);
509
+ const t1x = x1 + (ax / la) * dist;
510
+ const t1y = y1 + (ay / la) * dist;
511
+ const t2x = x1 + (bx / lb) * dist;
512
+ const t2y = y1 + (by / lb) * dist;
513
+ // center: offset from the tangent point, perpendicular to P0->P1
514
+ const sign = cross < 0 ? -1 : 1;
515
+ const cx = t1x - (ay / la) * r * sign;
516
+ const cy = t1y + (ax / la) * r * sign;
517
+ const startAngle = Math.atan2(t1y - cy, t1x - cx);
518
+ const endAngle = Math.atan2(t2y - cy, t2x - cx);
519
+ this.lineTo(t1x, t1y);
520
+ this.ellipse(cx, cy, r, r, 0, startAngle, endAngle, cross < 0);
521
+ }
522
+
523
+ rect(x, y, w, h) {
524
+ this._append([
525
+ { type: 'M', x, y },
526
+ { type: 'L', x: x + w, y },
527
+ { type: 'L', x: x + w, y: y + h },
528
+ { type: 'L', x, y: y + h },
529
+ { type: 'Z' },
530
+ { type: 'M', x, y } // spec: rect() leaves the current point at (x, y)
531
+ ]);
532
+ }
533
+
534
+ roundRect(x, y, w, h, radii) {
535
+ if (typeof radii === 'number' && radii === 0) return this.rect(x, y, w, h);
536
+ const [tl, tr, br, bl] = normalizeRadii(Math.abs(w), Math.abs(h), radii);
537
+ if (w < 0 || h < 0) {
538
+ // degenerate: fall back to a plain rect on flipped geometry
539
+ return this.rect(x, y, w, h);
540
+ }
541
+ this.moveTo(x + tl.x, y);
542
+ this.lineTo(x + w - tr.x, y);
543
+ if (tr.x || tr.y) this.ellipse(x + w - tr.x, y + tr.y, tr.x, tr.y, 0, -Math.PI / 2, 0);
544
+ this.lineTo(x + w, y + h - br.y);
545
+ if (br.x || br.y) this.ellipse(x + w - br.x, y + h - br.y, br.x, br.y, 0, 0, Math.PI / 2);
546
+ this.lineTo(x + bl.x, y + h);
547
+ if (bl.x || bl.y) this.ellipse(x + bl.x, y + h - bl.y, bl.x, bl.y, 0, Math.PI / 2, Math.PI);
548
+ this.lineTo(x, y + tl.y);
549
+ if (tl.x || tl.y) this.ellipse(x + tl.x, y + tl.y, tl.x, tl.y, 0, Math.PI, Math.PI * 1.5);
550
+ this.closePath();
551
+ this.moveTo(x, y);
552
+ }
553
+
554
+ /** append another path, optionally transformed ([a,b,c,d,e,f] or {a..f}) */
555
+ addPath(path, transform) {
556
+ if (!(path instanceof Path2D)) throw new TypeError('addPath: expected a Path2D');
557
+ const m = transform
558
+ ? Array.isArray(transform)
559
+ ? transform
560
+ : [transform.a, transform.b, transform.c, transform.d, transform.e, transform.f]
561
+ : null;
562
+ this._append(m ? transformCommands(path._cmds, m) : path._cmds.map((c) => ({ ...c })));
563
+ }
564
+ }
565
+
566
+ /** deep-copy commands with an affine transform applied to every point */
567
+ export function transformCommands(cmds, m) {
568
+ const out = [];
569
+ for (const c of cmds) {
570
+ switch (c.type) {
571
+ case 'M':
572
+ case 'L': {
573
+ const [x, y] = matApply(m, c.x, c.y);
574
+ out.push({ type: c.type, x, y });
575
+ break;
576
+ }
577
+ case 'C': {
578
+ const [x1, y1] = matApply(m, c.x1, c.y1);
579
+ const [x2, y2] = matApply(m, c.x2, c.y2);
580
+ const [x, y] = matApply(m, c.x, c.y);
581
+ out.push({ type: 'C', x1, y1, x2, y2, x, y });
582
+ break;
583
+ }
584
+ case 'Q': {
585
+ const [x1, y1] = matApply(m, c.x1, c.y1);
586
+ const [x, y] = matApply(m, c.x, c.y);
587
+ out.push({ type: 'Q', x1, y1, x, y });
588
+ break;
589
+ }
590
+ default:
591
+ out.push({ type: 'Z' });
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+
597
+ // --------------------------------------------------------------------------
598
+ // flattening
599
+
600
+ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
601
+ const dx = x3 - x0;
602
+ const dy = y3 - y0;
603
+ const chord2 = dx * dx + dy * dy;
604
+ // cross products = (distance of control point from the chord) * |chord|
605
+ const d1 = (x1 - x0) * dy - (y1 - y0) * dx;
606
+ const d2 = (x2 - x0) * dy - (y2 - y0) * dx;
607
+ const err = (Math.abs(d1) + Math.abs(d2)) ** 2;
608
+ let flat;
609
+ if (chord2 < 1e-9) {
610
+ // degenerate chord: flat only if the control points are also close
611
+ const c1 = (x1 - x0) ** 2 + (y1 - y0) ** 2;
612
+ const c2 = (x2 - x0) ** 2 + (y2 - y0) ** 2;
613
+ flat = Math.max(c1, c2) <= tol2;
614
+ } else {
615
+ // (dist1 + dist2)² <= tol² — absolute flatness in output pixels
616
+ flat = err <= tol2 * chord2;
617
+ }
618
+ if (flat || depth >= 18) {
619
+ pts.push(x3, y3);
620
+ return;
621
+ }
622
+ // de Casteljau split at t = 0.5
623
+ const ax = (x0 + x1) / 2;
624
+ const ay = (y0 + y1) / 2;
625
+ const bx = (x1 + x2) / 2;
626
+ const by = (y1 + y2) / 2;
627
+ const cx = (x2 + x3) / 2;
628
+ const cy = (y2 + y3) / 2;
629
+ const abx = (ax + bx) / 2;
630
+ const aby = (ay + by) / 2;
631
+ const bcx = (bx + cx) / 2;
632
+ const bcy = (by + cy) / 2;
633
+ const mx = (abx + bcx) / 2;
634
+ const my = (aby + bcy) / 2;
635
+ addCubic(pts, x0, y0, ax, ay, abx, aby, mx, my, tol2, depth + 1);
636
+ addCubic(pts, mx, my, bcx, bcy, cx, cy, x3, y3, tol2, depth + 1);
637
+ }
638
+
639
+ /**
640
+ * Flatten normalized commands into polylines. `m` (optional affine) is
641
+ * applied to control points before subdivision, so the flatness tolerance
642
+ * `tol` (default 0.25) is measured in output/device pixels.
643
+ *
644
+ * @returns {Array<{pts: number[], closed: boolean}>} flat [x0,y0,x1,y1,…]
645
+ */
646
+ export function flattenPath(cmds, m = null, tol = 0.25) {
647
+ if (m && matIsIdentity(m)) m = null;
648
+ const tol2 = tol * tol;
649
+ const polys = [];
650
+ let pts = null;
651
+ const p = (x, y) => (m ? matApply(m, x, y) : [x, y]);
652
+
653
+ for (const c of cmds) {
654
+ switch (c.type) {
655
+ case 'M': {
656
+ if (pts && pts.pts.length >= 4) polys.push(pts);
657
+ const [x, y] = p(c.x, c.y);
658
+ pts = { pts: [x, y], closed: false };
659
+ break;
660
+ }
661
+ case 'L': {
662
+ if (!pts) break;
663
+ const [x, y] = p(c.x, c.y);
664
+ pts.pts.push(x, y);
665
+ break;
666
+ }
667
+ case 'C': {
668
+ if (!pts) break;
669
+ const x0 = pts.pts[pts.pts.length - 2];
670
+ const y0 = pts.pts[pts.pts.length - 1];
671
+ const [x1, y1] = p(c.x1, c.y1);
672
+ const [x2, y2] = p(c.x2, c.y2);
673
+ const [x, y] = p(c.x, c.y);
674
+ addCubic(pts.pts, x0, y0, x1, y1, x2, y2, x, y, tol2, 0);
675
+ break;
676
+ }
677
+ case 'Q': {
678
+ if (!pts) break;
679
+ const x0 = pts.pts[pts.pts.length - 2];
680
+ const y0 = pts.pts[pts.pts.length - 1];
681
+ const [qx, qy] = p(c.x1, c.y1);
682
+ const [x, y] = p(c.x, c.y);
683
+ // degree elevation: quadratic -> cubic
684
+ addCubic(
685
+ pts.pts,
686
+ x0, y0,
687
+ x0 + (2 / 3) * (qx - x0), y0 + (2 / 3) * (qy - y0),
688
+ x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y),
689
+ x, y,
690
+ tol2, 0
691
+ );
692
+ break;
693
+ }
694
+ case 'Z': {
695
+ if (pts) {
696
+ if (pts.pts.length >= 4) {
697
+ pts.closed = true;
698
+ polys.push(pts);
699
+ }
700
+ // a new subpath continues from the subpath start
701
+ pts = { pts: [pts.pts[0], pts.pts[1]], closed: false };
702
+ }
703
+ break;
704
+ }
705
+ }
706
+ }
707
+ if (pts && pts.pts.length >= 4) polys.push(pts);
708
+ return polys;
709
+ }
710
+
711
+ /** point-in-path test on flattened polylines (device space) */
712
+ export function polysContain(polys, x, y, rule = 'nonzero') {
713
+ let winding = 0;
714
+ let crossings = 0;
715
+ for (const poly of polys) {
716
+ const pts = poly.pts;
717
+ const n = pts.length / 2;
718
+ for (let i = 0; i < n; i++) {
719
+ const j = (i + 1) % n;
720
+ const y0 = pts[i * 2 + 1];
721
+ const y1 = pts[j * 2 + 1];
722
+ if (y0 === y1) continue;
723
+ if (y < Math.min(y0, y1) || y >= Math.max(y0, y1)) continue;
724
+ const x0 = pts[i * 2];
725
+ const x1 = pts[j * 2];
726
+ const xc = x0 + ((y - y0) * (x1 - x0)) / (y1 - y0);
727
+ if (xc > x) {
728
+ crossings++;
729
+ winding += y1 > y0 ? 1 : -1;
730
+ }
731
+ }
732
+ }
733
+ return rule === 'evenodd' ? (crossings & 1) === 1 : winding !== 0;
734
+ }