@gjsify/canvas2d 0.4.0 → 0.4.4

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/index.spec.ts DELETED
@@ -1,888 +0,0 @@
1
- // Canvas 2D context tests for GJS
2
- // Ported from refs/node-canvas/test/ and refs/headless-gl/test/
3
- // Original: BSD-2-Clause (headless-gl), MIT (node-canvas)
4
- // Tests use @gjsify/unit and verify pixel output via getImageData.
5
-
6
- import { describe, it, expect } from '@gjsify/unit';
7
- import { HTMLCanvasElement } from '@gjsify/dom-elements';
8
-
9
- // Import canvas2d to register the '2d' context factory
10
- import '@gjsify/canvas2d';
11
- import { CanvasRenderingContext2D, Path2D, ImageData, parseColor } from '@gjsify/canvas2d';
12
-
13
- /** Helper: create a canvas with a 2D context. */
14
- function createCanvas(width = 100, height = 100) {
15
- const canvas = new HTMLCanvasElement();
16
- canvas.width = width;
17
- canvas.height = height;
18
- const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
19
- return { canvas, ctx };
20
- }
21
-
22
- /** Helper: get pixel RGBA at (x, y). */
23
- function getPixel(ctx: CanvasRenderingContext2D, x: number, y: number): [number, number, number, number] {
24
- const data = ctx.getImageData(x, y, 1, 1);
25
- return [data.data[0], data.data[1], data.data[2], data.data[3]];
26
- }
27
-
28
- export default async () => {
29
-
30
- // ---- Color parser ----
31
-
32
- await describe('parseColor', async () => {
33
- await it('should parse hex colors', async () => {
34
- const c = parseColor('#ff0000')!;
35
- expect(c.r).toBe(1);
36
- expect(c.g).toBe(0);
37
- expect(c.b).toBe(0);
38
- expect(c.a).toBe(1);
39
- });
40
-
41
- await it('should parse short hex', async () => {
42
- const c = parseColor('#f00')!;
43
- expect(c.r).toBe(1);
44
- expect(c.g).toBe(0);
45
- expect(c.b).toBe(0);
46
- });
47
-
48
- await it('should parse hex with alpha', async () => {
49
- const c = parseColor('#ff000080')!;
50
- expect(c.r).toBe(1);
51
- expect(c.a).toBeGreaterThan(0.49);
52
- expect(c.a).toBeLessThan(0.51);
53
- });
54
-
55
- await it('should parse rgb()', async () => {
56
- const c = parseColor('rgb(0, 128, 255)')!;
57
- expect(c.r).toBe(0);
58
- expect(c.g).toBeGreaterThan(0.49);
59
- expect(c.g).toBeLessThan(0.51);
60
- expect(c.b).toBe(1);
61
- });
62
-
63
- await it('should parse rgba()', async () => {
64
- const c = parseColor('rgba(255, 0, 0, 0.5)')!;
65
- expect(c.r).toBe(1);
66
- expect(c.a).toBe(0.5);
67
- });
68
-
69
- await it('should parse named colors', async () => {
70
- const c = parseColor('red')!;
71
- expect(c.r).toBe(1);
72
- expect(c.g).toBe(0);
73
- expect(c.b).toBe(0);
74
- });
75
-
76
- await it('should parse transparent', async () => {
77
- const c = parseColor('transparent')!;
78
- expect(c.a).toBe(0);
79
- });
80
-
81
- await it('should return null for invalid colors', async () => {
82
- expect(parseColor('notacolor')).toBeNull();
83
- expect(parseColor('')).toBeNull();
84
- });
85
- });
86
-
87
- // ---- Context creation ----
88
-
89
- await describe('Context creation', async () => {
90
- await it('should return a CanvasRenderingContext2D for "2d"', async () => {
91
- const { ctx } = createCanvas();
92
- expect(ctx).not.toBeNull();
93
- expect(ctx).toBeDefined();
94
- });
95
-
96
- await it('should return the same context on repeated calls', async () => {
97
- const canvas = new HTMLCanvasElement();
98
- canvas.width = 50;
99
- canvas.height = 50;
100
- const ctx1 = canvas.getContext('2d');
101
- const ctx2 = canvas.getContext('2d');
102
- expect(ctx1).toBe(ctx2);
103
- });
104
-
105
- await it('should have canvas reference', async () => {
106
- const { canvas, ctx } = createCanvas();
107
- expect(ctx.canvas).toBe(canvas);
108
- });
109
- });
110
-
111
- // ---- ImageData ----
112
-
113
- await describe('ImageData', async () => {
114
- await it('should create with width and height', async () => {
115
- const img = new ImageData(10, 20);
116
- expect(img.width).toBe(10);
117
- expect(img.height).toBe(20);
118
- expect(img.data.length).toBe(10 * 20 * 4);
119
- });
120
-
121
- await it('should create from existing data', async () => {
122
- const data = new Uint8ClampedArray(4 * 2 * 2);
123
- data[0] = 255; // first pixel R
124
- const img = new ImageData(data, 2, 2);
125
- expect(img.width).toBe(2);
126
- expect(img.height).toBe(2);
127
- expect(img.data[0]).toBe(255);
128
- });
129
-
130
- await it('should throw on mismatched data length', async () => {
131
- const data = new Uint8ClampedArray(10); // not a multiple of 4*width
132
- let threw = false;
133
- try {
134
- new ImageData(data, 3, 1);
135
- } catch {
136
- threw = true;
137
- }
138
- expect(threw).toBe(true);
139
- });
140
- });
141
-
142
- // ---- fillRect + getImageData pixel verification ----
143
-
144
- await describe('fillRect', async () => {
145
- await it('should fill a rectangle with solid color', async () => {
146
- const { ctx } = createCanvas(10, 10);
147
- ctx.fillStyle = '#ff0000';
148
- ctx.fillRect(0, 0, 10, 10);
149
- const [r, g, b, a] = getPixel(ctx, 5, 5);
150
- expect(r).toBe(255);
151
- expect(g).toBe(0);
152
- expect(b).toBe(0);
153
- expect(a).toBe(255);
154
- });
155
-
156
- await it('should fill partial rectangle', async () => {
157
- const { ctx } = createCanvas(20, 20);
158
- ctx.fillStyle = '#00ff00';
159
- ctx.fillRect(5, 5, 10, 10);
160
- // Inside the rect
161
- const [, g1, , a1] = getPixel(ctx, 10, 10);
162
- expect(g1).toBe(255);
163
- expect(a1).toBe(255);
164
- // Outside the rect (should be transparent)
165
- const [, , , a2] = getPixel(ctx, 0, 0);
166
- expect(a2).toBe(0);
167
- });
168
-
169
- await it('should fill with rgba color', async () => {
170
- const { ctx } = createCanvas(10, 10);
171
- ctx.fillStyle = 'rgba(0, 0, 255, 0.5)';
172
- ctx.fillRect(0, 0, 10, 10);
173
- const [, g, , a] = getPixel(ctx, 5, 5);
174
- expect(g).toBe(0);
175
- // Alpha should be approximately 128 (0.5 * 255)
176
- expect(a).toBeGreaterThan(120);
177
- expect(a).toBeLessThan(140);
178
- });
179
-
180
- await it('should not affect the current path', async () => {
181
- // Regression: fillRect added a rectangle to the existing path.
182
- // Since fill() uses fillPreserve(), the preserved path was
183
- // repainted with fillRect's color.
184
- const { ctx } = createCanvas(40, 40);
185
- // Draw a red circle
186
- ctx.beginPath();
187
- ctx.arc(10, 10, 8, 0, Math.PI * 2);
188
- ctx.fillStyle = '#ff0000';
189
- ctx.fill();
190
- // fillRect with green in a different area — must not repaint the circle
191
- ctx.fillStyle = '#00ff00';
192
- ctx.fillRect(25, 25, 10, 10);
193
- // Circle should still be red, not green
194
- const [r, g, _b, a] = getPixel(ctx, 10, 10);
195
- expect(r).toBe(255);
196
- expect(g).toBe(0);
197
- expect(a).toBe(255);
198
- // Rectangle should be green
199
- const [r2, g2, _b2, a2] = getPixel(ctx, 30, 30);
200
- expect(r2).toBe(0);
201
- expect(g2).toBe(255);
202
- expect(a2).toBe(255);
203
- });
204
- });
205
-
206
- // ---- clearRect ----
207
-
208
- await describe('clearRect', async () => {
209
- await it('should clear a region to transparent', async () => {
210
- const { ctx } = createCanvas(20, 20);
211
- ctx.fillStyle = '#ff0000';
212
- ctx.fillRect(0, 0, 20, 20);
213
- ctx.clearRect(5, 5, 10, 10);
214
- // Inside cleared region
215
- const [_r, _g, _b, a] = getPixel(ctx, 10, 10);
216
- expect(a).toBe(0);
217
- // Outside cleared region (still red)
218
- const [r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
219
- expect(r2).toBe(255);
220
- expect(a2).toBe(255);
221
- });
222
-
223
- await it('should not affect the current path', async () => {
224
- // Regression: clearRect added a rectangle to the current path.
225
- // Per spec, clearRect must not affect the current path.
226
- const { ctx } = createCanvas(40, 40);
227
- ctx.fillStyle = '#ff0000';
228
- ctx.beginPath();
229
- ctx.arc(20, 20, 10, 0, Math.PI * 2);
230
- ctx.fill();
231
- // clearRect a different region — path should survive
232
- ctx.clearRect(0, 0, 5, 5);
233
- // Fill again with the preserved path — should still be a circle
234
- ctx.fillStyle = '#0000ff';
235
- ctx.fill();
236
- // Center of circle should now be blue
237
- const [r, _g, b, a] = getPixel(ctx, 20, 20);
238
- expect(r).toBe(0);
239
- expect(b).toBe(255);
240
- expect(a).toBe(255);
241
- });
242
- });
243
-
244
- // ---- strokeRect ----
245
-
246
- await describe('strokeRect', async () => {
247
- await it('should stroke a rectangle outline', async () => {
248
- const { ctx } = createCanvas(20, 20);
249
- ctx.strokeStyle = '#0000ff';
250
- ctx.lineWidth = 2;
251
- ctx.strokeRect(2, 2, 16, 16);
252
- // On the stroke border (top edge around y=2)
253
- const [_r, _g, b, a] = getPixel(ctx, 10, 2);
254
- expect(b).toBe(255);
255
- expect(a).toBe(255);
256
- // Center should be empty
257
- const [_r2, _g2, _b2, a2] = getPixel(ctx, 10, 10);
258
- expect(a2).toBe(0);
259
- });
260
- });
261
-
262
- // ---- Path operations ----
263
-
264
- await describe('Path operations', async () => {
265
- await it('should fill a triangle', async () => {
266
- const { ctx } = createCanvas(20, 20);
267
- ctx.fillStyle = '#00ff00';
268
- ctx.beginPath();
269
- ctx.moveTo(10, 0);
270
- ctx.lineTo(20, 20);
271
- ctx.lineTo(0, 20);
272
- ctx.closePath();
273
- ctx.fill();
274
- // Bottom center should be filled
275
- const [_r, g, _b, a] = getPixel(ctx, 10, 15);
276
- expect(g).toBe(255);
277
- expect(a).toBe(255);
278
- });
279
-
280
- await it('should fill a circle (arc)', async () => {
281
- const { ctx } = createCanvas(20, 20);
282
- ctx.fillStyle = '#ff0000';
283
- ctx.beginPath();
284
- ctx.arc(10, 10, 8, 0, Math.PI * 2);
285
- ctx.fill();
286
- // Center should be filled
287
- const [r, _g, _b, a] = getPixel(ctx, 10, 10);
288
- expect(r).toBe(255);
289
- expect(a).toBe(255);
290
- // Corner should be empty
291
- const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
292
- expect(a2).toBe(0);
293
- });
294
-
295
- await it('should fill a full circle with counterclockwise=true', async () => {
296
- // Regression: Cairo arcNegative(x,y,r,0,2π) normalizes endAngle to
297
- // startAngle, producing a zero-length arc. Browsers draw a full circle.
298
- const { ctx } = createCanvas(20, 20);
299
- ctx.fillStyle = '#ff0000';
300
- ctx.beginPath();
301
- ctx.arc(10, 10, 8, 0, Math.PI * 2, true);
302
- ctx.fill();
303
- const [r, _g, _b, a] = getPixel(ctx, 10, 10);
304
- expect(r).toBe(255);
305
- expect(a).toBe(255);
306
- });
307
-
308
- await it('should fill a rectangle path', async () => {
309
- const { ctx } = createCanvas(20, 20);
310
- ctx.fillStyle = '#0000ff';
311
- ctx.beginPath();
312
- ctx.rect(2, 2, 16, 16);
313
- ctx.fill();
314
- const [_r, _g, b, a] = getPixel(ctx, 10, 10);
315
- expect(b).toBe(255);
316
- expect(a).toBe(255);
317
- });
318
- });
319
-
320
- // ---- Transforms ----
321
-
322
- await describe('Transforms', async () => {
323
- await it('should translate', async () => {
324
- const { ctx } = createCanvas(20, 20);
325
- ctx.fillStyle = '#ff0000';
326
- ctx.translate(5, 5);
327
- ctx.fillRect(0, 0, 5, 5);
328
- // The rect is drawn at (5,5) in device space
329
- const [r, _g, _b, a] = getPixel(ctx, 7, 7);
330
- expect(r).toBe(255);
331
- expect(a).toBe(255);
332
- // Origin should be empty
333
- const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
334
- expect(a2).toBe(0);
335
- });
336
-
337
- await it('should scale', async () => {
338
- const { ctx } = createCanvas(20, 20);
339
- ctx.fillStyle = '#00ff00';
340
- ctx.scale(2, 2);
341
- ctx.fillRect(0, 0, 5, 5);
342
- // The rect is 10x10 in device space
343
- const [_r, g, _b, a] = getPixel(ctx, 9, 9);
344
- expect(g).toBe(255);
345
- expect(a).toBe(255);
346
- });
347
-
348
- await it('should resetTransform', async () => {
349
- const { ctx } = createCanvas(20, 20);
350
- ctx.translate(100, 100);
351
- ctx.resetTransform();
352
- ctx.fillStyle = '#ff0000';
353
- ctx.fillRect(0, 0, 5, 5);
354
- const [r, _g, _b, a] = getPixel(ctx, 2, 2);
355
- expect(r).toBe(255);
356
- expect(a).toBe(255);
357
- });
358
-
359
- await it('should getTransform return identity by default', async () => {
360
- const { ctx } = createCanvas();
361
- const m = ctx.getTransform();
362
- expect(m.a).toBe(1);
363
- expect(m.b).toBe(0);
364
- expect(m.c).toBe(0);
365
- expect(m.d).toBe(1);
366
- expect(m.e).toBe(0);
367
- expect(m.f).toBe(0);
368
- });
369
- });
370
-
371
- // ---- save/restore ----
372
-
373
- await describe('save/restore', async () => {
374
- await it('should save and restore fillStyle', async () => {
375
- const { ctx } = createCanvas();
376
- ctx.fillStyle = '#ff0000';
377
- ctx.save();
378
- ctx.fillStyle = '#00ff00';
379
- expect(ctx.fillStyle).toBe('#00ff00');
380
- ctx.restore();
381
- expect(ctx.fillStyle).toBe('#ff0000');
382
- });
383
-
384
- await it('should save and restore lineWidth', async () => {
385
- const { ctx } = createCanvas();
386
- ctx.lineWidth = 5;
387
- ctx.save();
388
- ctx.lineWidth = 10;
389
- ctx.restore();
390
- expect(ctx.lineWidth).toBe(5);
391
- });
392
-
393
- await it('should save and restore globalAlpha', async () => {
394
- const { ctx } = createCanvas();
395
- ctx.globalAlpha = 0.5;
396
- ctx.save();
397
- ctx.globalAlpha = 1.0;
398
- ctx.restore();
399
- expect(ctx.globalAlpha).toBe(0.5);
400
- });
401
-
402
- await it('should save and restore transforms', async () => {
403
- const { ctx } = createCanvas(20, 20);
404
- ctx.translate(5, 5);
405
- ctx.save();
406
- ctx.translate(100, 100);
407
- ctx.restore();
408
- // After restore, translate(5,5) should still be in effect
409
- ctx.fillStyle = '#ff0000';
410
- ctx.fillRect(0, 0, 5, 5);
411
- const [r, _g, _b, a] = getPixel(ctx, 7, 7);
412
- expect(r).toBe(255);
413
- expect(a).toBe(255);
414
- });
415
- });
416
-
417
- // ---- Line properties ----
418
-
419
- await describe('Line properties', async () => {
420
- await it('should set and get lineWidth', async () => {
421
- const { ctx } = createCanvas();
422
- ctx.lineWidth = 5;
423
- expect(ctx.lineWidth).toBe(5);
424
- });
425
-
426
- await it('should ignore invalid lineWidth', async () => {
427
- const { ctx } = createCanvas();
428
- ctx.lineWidth = 5;
429
- ctx.lineWidth = -1;
430
- expect(ctx.lineWidth).toBe(5);
431
- ctx.lineWidth = 0;
432
- expect(ctx.lineWidth).toBe(5);
433
- ctx.lineWidth = Infinity;
434
- expect(ctx.lineWidth).toBe(5);
435
- });
436
-
437
- await it('should set and get lineCap', async () => {
438
- const { ctx } = createCanvas();
439
- ctx.lineCap = 'round';
440
- expect(ctx.lineCap).toBe('round');
441
- ctx.lineCap = 'square';
442
- expect(ctx.lineCap).toBe('square');
443
- ctx.lineCap = 'butt';
444
- expect(ctx.lineCap).toBe('butt');
445
- });
446
-
447
- await it('should set and get lineJoin', async () => {
448
- const { ctx } = createCanvas();
449
- ctx.lineJoin = 'round';
450
- expect(ctx.lineJoin).toBe('round');
451
- ctx.lineJoin = 'bevel';
452
- expect(ctx.lineJoin).toBe('bevel');
453
- });
454
-
455
- await it('should set and get lineDash', async () => {
456
- const { ctx } = createCanvas();
457
- ctx.setLineDash([5, 10]);
458
- expect(ctx.getLineDash().length).toBe(2);
459
- expect(ctx.getLineDash()[0]).toBe(5);
460
- expect(ctx.getLineDash()[1]).toBe(10);
461
- });
462
-
463
- await it('should ignore negative lineDash values', async () => {
464
- const { ctx } = createCanvas();
465
- ctx.setLineDash([5, 10]);
466
- ctx.setLineDash([-1, 5]);
467
- // Should not have changed
468
- expect(ctx.getLineDash()[0]).toBe(5);
469
- });
470
- });
471
-
472
- // ---- globalAlpha ----
473
-
474
- await describe('globalAlpha', async () => {
475
- await it('should affect fill opacity', async () => {
476
- const { ctx } = createCanvas(10, 10);
477
- ctx.globalAlpha = 0.5;
478
- ctx.fillStyle = '#ff0000';
479
- ctx.fillRect(0, 0, 10, 10);
480
- const [, , , a] = getPixel(ctx, 5, 5);
481
- // Alpha should be approximately 128
482
- expect(a).toBeGreaterThan(120);
483
- expect(a).toBeLessThan(140);
484
- });
485
-
486
- await it('should reject invalid values', async () => {
487
- const { ctx } = createCanvas();
488
- ctx.globalAlpha = 0.5;
489
- ctx.globalAlpha = -0.1;
490
- expect(ctx.globalAlpha).toBe(0.5);
491
- ctx.globalAlpha = 1.1;
492
- expect(ctx.globalAlpha).toBe(0.5);
493
- });
494
- });
495
-
496
- // ---- globalCompositeOperation ----
497
-
498
- await describe('globalCompositeOperation', async () => {
499
- await it('should default to source-over', async () => {
500
- const { ctx } = createCanvas();
501
- expect(ctx.globalCompositeOperation).toBe('source-over');
502
- });
503
-
504
- await it('should accept valid operations', async () => {
505
- const { ctx } = createCanvas();
506
- const ops = ['source-over', 'source-in', 'source-out', 'source-atop',
507
- 'destination-over', 'destination-in', 'destination-out', 'destination-atop',
508
- 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay',
509
- 'darken', 'lighten', 'color-dodge', 'color-burn',
510
- 'hard-light', 'soft-light', 'difference', 'exclusion',
511
- 'hue', 'saturation', 'color', 'luminosity'];
512
- for (const op of ops) {
513
- ctx.globalCompositeOperation = op as GlobalCompositeOperation;
514
- expect(ctx.globalCompositeOperation).toBe(op);
515
- }
516
- });
517
-
518
- await it('copy should replace destination', async () => {
519
- const { ctx } = createCanvas(10, 10);
520
- ctx.fillStyle = '#ff0000';
521
- ctx.fillRect(0, 0, 10, 10);
522
- ctx.globalCompositeOperation = 'copy';
523
- ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
524
- ctx.fillRect(0, 0, 10, 10);
525
- const [r, g, _b, a] = getPixel(ctx, 5, 5);
526
- // Red should be gone (copy replaces)
527
- expect(r).toBe(0);
528
- expect(g).toBeGreaterThan(0);
529
- expect(a).toBeGreaterThan(120);
530
- expect(a).toBeLessThan(140);
531
- });
532
- });
533
-
534
- // ---- Gradients ----
535
-
536
- await describe('Gradients', async () => {
537
- await it('should create a linear gradient', async () => {
538
- const { ctx } = createCanvas(20, 10);
539
- const grad = ctx.createLinearGradient(0, 0, 20, 0);
540
- grad.addColorStop(0, '#ff0000');
541
- grad.addColorStop(1, '#0000ff');
542
- ctx.fillStyle = grad;
543
- ctx.fillRect(0, 0, 20, 10);
544
- // Left should be red-ish
545
- const [r1, _g1, b1] = getPixel(ctx, 1, 5);
546
- expect(r1).toBeGreaterThan(200);
547
- expect(b1).toBeLessThan(50);
548
- // Right should be blue-ish
549
- const [r2, _g2, b2] = getPixel(ctx, 18, 5);
550
- expect(r2).toBeLessThan(50);
551
- expect(b2).toBeGreaterThan(200);
552
- });
553
-
554
- await it('should create a radial gradient', async () => {
555
- const { ctx } = createCanvas(20, 20);
556
- const grad = ctx.createRadialGradient(10, 10, 0, 10, 10, 10);
557
- grad.addColorStop(0, '#ffffff');
558
- grad.addColorStop(1, '#000000');
559
- ctx.fillStyle = grad;
560
- ctx.fillRect(0, 0, 20, 20);
561
- // Center should be bright
562
- const [r1] = getPixel(ctx, 10, 10);
563
- expect(r1).toBeGreaterThan(200);
564
- // Edge should be dark
565
- const [r2] = getPixel(ctx, 19, 10);
566
- expect(r2).toBeLessThan(50);
567
- });
568
- });
569
-
570
- // ---- getImageData / putImageData round-trip ----
571
-
572
- await describe('getImageData / putImageData', async () => {
573
- await it('should round-trip pixel data', async () => {
574
- const { ctx } = createCanvas(10, 10);
575
- ctx.fillStyle = '#ff8000';
576
- ctx.fillRect(0, 0, 10, 10);
577
- const imageData = ctx.getImageData(0, 0, 10, 10);
578
- expect(imageData.width).toBe(10);
579
- expect(imageData.height).toBe(10);
580
- // Check orange pixel
581
- expect(imageData.data[0]).toBe(255); // R
582
- expect(imageData.data[1]).toBeGreaterThan(120); // G (~128)
583
- expect(imageData.data[2]).toBe(0); // B
584
- expect(imageData.data[3]).toBe(255); // A
585
-
586
- // Clear and put it back
587
- ctx.clearRect(0, 0, 10, 10);
588
- ctx.putImageData(imageData, 0, 0);
589
- const [r, , , a] = getPixel(ctx, 5, 5);
590
- expect(r).toBe(255);
591
- expect(a).toBe(255);
592
- });
593
-
594
- await it('should createImageData with correct dimensions', async () => {
595
- const { ctx } = createCanvas();
596
- const img = ctx.createImageData(5, 3);
597
- expect(img.width).toBe(5);
598
- expect(img.height).toBe(3);
599
- expect(img.data.length).toBe(5 * 3 * 4);
600
- // Should be all zeros (transparent black)
601
- expect(img.data[0]).toBe(0);
602
- });
603
- });
604
-
605
- // ---- Path2D ----
606
-
607
- await describe('Path2D', async () => {
608
- await it('should create and fill a Path2D rectangle', async () => {
609
- const { ctx } = createCanvas(20, 20);
610
- const path = new Path2D();
611
- path.rect(2, 2, 16, 16);
612
- ctx.fillStyle = '#ff0000';
613
- ctx.fill(path);
614
- const [r, _g, _b, a] = getPixel(ctx, 10, 10);
615
- expect(r).toBe(255);
616
- expect(a).toBe(255);
617
- });
618
-
619
- await it('should stroke a Path2D', async () => {
620
- const { ctx } = createCanvas(20, 20);
621
- const path = new Path2D();
622
- path.moveTo(0, 10);
623
- path.lineTo(20, 10);
624
- ctx.strokeStyle = '#00ff00';
625
- ctx.lineWidth = 2;
626
- ctx.stroke(path);
627
- const [_r, g, _b, a] = getPixel(ctx, 10, 10);
628
- expect(g).toBe(255);
629
- expect(a).toBe(255);
630
- });
631
-
632
- await it('should copy a Path2D', async () => {
633
- const path1 = new Path2D();
634
- path1.rect(0, 0, 10, 10);
635
- const path2 = new Path2D(path1);
636
- // path2 should have the same ops
637
- expect(path2._ops.length).toBe(path1._ops.length);
638
- });
639
-
640
- await it('should addPath', async () => {
641
- const path1 = new Path2D();
642
- path1.rect(0, 0, 5, 5);
643
- const path2 = new Path2D();
644
- path2.rect(10, 10, 5, 5);
645
- path1.addPath(path2);
646
- expect(path1._ops.length).toBe(2);
647
- });
648
- });
649
-
650
- // ---- Clipping ----
651
-
652
- await describe('Clipping', async () => {
653
- await it('should clip to a rectangular region', async () => {
654
- const { ctx } = createCanvas(20, 20);
655
- ctx.beginPath();
656
- ctx.rect(5, 5, 10, 10);
657
- ctx.clip();
658
- ctx.fillStyle = '#ff0000';
659
- ctx.fillRect(0, 0, 20, 20);
660
- // Inside clip → filled
661
- const [r, _g, _b, a] = getPixel(ctx, 10, 10);
662
- expect(r).toBe(255);
663
- expect(a).toBe(255);
664
- // Outside clip → transparent
665
- const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
666
- expect(a2).toBe(0);
667
- });
668
- });
669
-
670
- // ---- Text ----
671
-
672
- await describe('Text', async () => {
673
- await it('should measureText return non-zero width', async () => {
674
- const { ctx } = createCanvas();
675
- ctx.font = '16px sans-serif';
676
- const m = ctx.measureText('Hello World');
677
- expect(m.width).toBeGreaterThan(0);
678
- });
679
-
680
- await it('should measureText empty string return 0 width', async () => {
681
- const { ctx } = createCanvas();
682
- const m = ctx.measureText('');
683
- expect(m.width).toBe(0);
684
- });
685
-
686
- await it('should fillText produce visible pixels', async () => {
687
- const { ctx } = createCanvas(100, 30);
688
- ctx.fillStyle = '#000000';
689
- ctx.font = '20px sans-serif';
690
- ctx.fillText('X', 10, 20);
691
- // Check around the area where the text should be
692
- const imageData = ctx.getImageData(0, 0, 100, 30);
693
- let nonZeroPixels = 0;
694
- for (let i = 3; i < imageData.data.length; i += 4) {
695
- if (imageData.data[i] > 0) nonZeroPixels++;
696
- }
697
- expect(nonZeroPixels).toBeGreaterThan(0);
698
- });
699
-
700
- await it('should set and get font', async () => {
701
- const { ctx } = createCanvas();
702
- ctx.font = 'bold 20px Arial';
703
- expect(ctx.font).toBe('bold 20px Arial');
704
- });
705
-
706
- await it('should set and get textAlign', async () => {
707
- const { ctx } = createCanvas();
708
- ctx.textAlign = 'center';
709
- expect(ctx.textAlign).toBe('center');
710
- ctx.textAlign = 'right';
711
- expect(ctx.textAlign).toBe('right');
712
- });
713
-
714
- await it('should set and get textBaseline', async () => {
715
- const { ctx } = createCanvas();
716
- ctx.textBaseline = 'top';
717
- expect(ctx.textBaseline).toBe('top');
718
- ctx.textBaseline = 'middle';
719
- expect(ctx.textBaseline).toBe('middle');
720
- });
721
- });
722
-
723
- // ---- Hit testing ----
724
-
725
- await describe('Hit testing', async () => {
726
- await it('should detect point in rectangular path', async () => {
727
- const { ctx } = createCanvas(20, 20);
728
- ctx.beginPath();
729
- ctx.rect(5, 5, 10, 10);
730
- expect(ctx.isPointInPath(10, 10)).toBe(true);
731
- expect(ctx.isPointInPath(0, 0)).toBe(false);
732
- });
733
-
734
- await it('should detect point in stroke', async () => {
735
- const { ctx } = createCanvas(20, 20);
736
- ctx.lineWidth = 4;
737
- ctx.beginPath();
738
- ctx.moveTo(0, 10);
739
- ctx.lineTo(20, 10);
740
- expect(ctx.isPointInStroke(10, 10)).toBe(true);
741
- expect(ctx.isPointInStroke(10, 0)).toBe(false);
742
- });
743
-
744
- await it('should detect point in Path2D', async () => {
745
- const { ctx } = createCanvas(20, 20);
746
- const path = new Path2D();
747
- path.rect(5, 5, 10, 10);
748
- expect(ctx.isPointInPath(path, 10, 10)).toBe(true);
749
- expect(ctx.isPointInPath(path, 0, 0)).toBe(false);
750
- });
751
- });
752
-
753
- // ---- Shadow properties ----
754
-
755
- await describe('Shadow properties', async () => {
756
- await it('should set and get shadow properties', async () => {
757
- const { ctx } = createCanvas();
758
- ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
759
- expect(ctx.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
760
- ctx.shadowBlur = 10;
761
- expect(ctx.shadowBlur).toBe(10);
762
- ctx.shadowOffsetX = 5;
763
- expect(ctx.shadowOffsetX).toBe(5);
764
- ctx.shadowOffsetY = 3;
765
- expect(ctx.shadowOffsetY).toBe(3);
766
- });
767
-
768
- await it('should reject negative shadowBlur', async () => {
769
- const { ctx } = createCanvas();
770
- ctx.shadowBlur = 5;
771
- ctx.shadowBlur = -1;
772
- expect(ctx.shadowBlur).toBe(5);
773
- });
774
- });
775
-
776
- // ---- Ellipse and roundRect ----
777
-
778
- await describe('Ellipse and roundRect', async () => {
779
- await it('should fill an ellipse', async () => {
780
- const { ctx } = createCanvas(30, 20);
781
- ctx.fillStyle = '#ff0000';
782
- ctx.beginPath();
783
- ctx.ellipse(15, 10, 12, 8, 0, 0, Math.PI * 2);
784
- ctx.fill();
785
- const [r, _g, _b, a] = getPixel(ctx, 15, 10);
786
- expect(r).toBe(255);
787
- expect(a).toBe(255);
788
- });
789
-
790
- await it('should throw on negative ellipse radii', async () => {
791
- const { ctx } = createCanvas();
792
- let threw = false;
793
- try {
794
- ctx.ellipse(10, 10, -5, 5, 0, 0, Math.PI * 2);
795
- } catch {
796
- threw = true;
797
- }
798
- expect(threw).toBe(true);
799
- });
800
-
801
- await it('should fill a roundRect', async () => {
802
- const { ctx } = createCanvas(30, 30);
803
- ctx.fillStyle = '#0000ff';
804
- ctx.beginPath();
805
- ctx.roundRect(2, 2, 26, 26, 5);
806
- ctx.fill();
807
- const [_r, _g, b, a] = getPixel(ctx, 15, 15);
808
- expect(b).toBe(255);
809
- expect(a).toBe(255);
810
- });
811
- });
812
-
813
- // ---- imageSmoothingEnabled ----
814
-
815
- await describe('imageSmoothingEnabled', async () => {
816
- await it('should default to true', async () => {
817
- const { ctx } = createCanvas();
818
- expect(ctx.imageSmoothingEnabled).toBe(true);
819
- });
820
-
821
- await it('should be settable', async () => {
822
- const { ctx } = createCanvas();
823
- ctx.imageSmoothingEnabled = false;
824
- expect(ctx.imageSmoothingEnabled).toBe(false);
825
- });
826
- });
827
-
828
- // ---- fillText / font rendering ----
829
- // Regression tests for the Excalibur Jelly Jumper coin-counter pipeline:
830
- // Excalibur renders text to an offscreen canvas via fillText, then uploads
831
- // it to WebGL as a texture. These tests verify the Cairo text pipeline.
832
-
833
- await describe('fillText', async () => {
834
- await it('renders non-transparent pixels for numeric text', async () => {
835
- const { canvas, ctx } = createCanvas(200, 50);
836
- ctx.fillStyle = 'white';
837
- ctx.fillRect(0, 0, canvas.width, canvas.height);
838
- ctx.fillStyle = 'black';
839
- ctx.font = '20px sans-serif';
840
- ctx.fillText('42', 5, 30);
841
- const data = ctx.getImageData(5, 10, 30, 25).data;
842
- const hasNonWhite = Array.from({ length: data.length / 4 }, (_, i) =>
843
- data[i * 4] < 200 || data[i * 4 + 1] < 200 || data[i * 4 + 2] < 200
844
- ).some(Boolean);
845
- expect(hasNonWhite).toBe(true);
846
- });
847
-
848
- await it('different font sizes produce different text widths', async () => {
849
- const { ctx } = createCanvas(200, 50);
850
- ctx.font = '12px sans-serif';
851
- const small = ctx.measureText('Hello').width;
852
- ctx.font = '24px sans-serif';
853
- const large = ctx.measureText('Hello').width;
854
- expect(large).toBeGreaterThan(small);
855
- });
856
-
857
- await it('rgba fill color is respected (red channel > blue for red text)', async () => {
858
- const { ctx } = createCanvas(100, 100);
859
- ctx.fillStyle = 'white';
860
- ctx.fillRect(0, 0, 100, 100);
861
- ctx.fillStyle = 'rgba(255, 0, 0, 1.0)';
862
- ctx.font = '30px sans-serif';
863
- ctx.fillText('X', 10, 60);
864
- const data = ctx.getImageData(10, 30, 50, 40).data;
865
- const hasReddish = Array.from({ length: data.length / 4 }, (_, i) =>
866
- data[i * 4] > 200 && data[i * 4 + 2] < 100
867
- ).some(Boolean);
868
- expect(hasReddish).toBe(true);
869
- });
870
-
871
- await it('unknown font name falls back to system font and still renders pixels', async () => {
872
- // Regression: Excalibur uses 'Round9x13' custom font. PangoCairo must fall back
873
- // to a system font and still render visible glyphs — NOT produce an empty bitmap.
874
- // If this fails, the coin counter in Jelly Jumper would be blank.
875
- const { canvas, ctx } = createCanvas(200, 50);
876
- ctx.fillStyle = 'white';
877
- ctx.fillRect(0, 0, canvas.width, canvas.height);
878
- ctx.fillStyle = 'black';
879
- ctx.font = '20px Round9x13';
880
- ctx.fillText('42', 5, 35);
881
- const data = ctx.getImageData(5, 10, 50, 30).data;
882
- const hasNonWhite = Array.from({ length: data.length / 4 }, (_, i) =>
883
- data[i * 4] < 200 || data[i * 4 + 1] < 200 || data[i * 4 + 2] < 200
884
- ).some(Boolean);
885
- expect(hasNonWhite).toBe(true);
886
- });
887
- });
888
- };