@gjsify/canvas2d-core 0.1.8 → 0.1.10

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.
@@ -0,0 +1,126 @@
1
+ // Canvas 2D clearRect tests — verifies the Cairo.Operator.CLEAR path
2
+ // correctly clears the specified rectangle regardless of transform,
3
+ // clip, globalAlpha, and globalCompositeOperation.
4
+ //
5
+ // Ported from refs/wpt/html/canvas/element/drawing-rectangles-to-the-canvas/
6
+ // 2d.clearRect.{basic,transform,clip,globalalpha,globalcomposite,
7
+ // negative,path,nonfinite}.html
8
+ // Original: Copyright (c) Web Platform Tests contributors. 3-Clause BSD.
9
+ // Reimplemented for GJS using @gjsify/canvas2d-core + @gjsify/unit.
10
+
11
+ import { describe, it, expect } from '@gjsify/unit';
12
+ import { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
13
+
14
+ function makeCtx(width = 30, height = 30): CanvasRenderingContext2D {
15
+ const canvas = { width, height };
16
+ return new CanvasRenderingContext2D(canvas);
17
+ }
18
+
19
+ function assertPixel(
20
+ ctx: CanvasRenderingContext2D,
21
+ x: number,
22
+ y: number,
23
+ r: number, g: number, b: number, a: number,
24
+ ): void {
25
+ const data = ctx.getImageData(x, y, 1, 1).data;
26
+ expect(data[0]).toBe(r);
27
+ expect(data[1]).toBe(g);
28
+ expect(data[2]).toBe(b);
29
+ expect(data[3]).toBe(a);
30
+ }
31
+
32
+ export default async () => {
33
+ await describe('CanvasRenderingContext2D — clearRect', async () => {
34
+
35
+ await it('clears a red-filled canvas to transparent black', async () => {
36
+ const ctx = makeCtx(20, 20);
37
+ ctx.fillStyle = 'rgb(255, 0, 0)';
38
+ ctx.fillRect(0, 0, 20, 20);
39
+ assertPixel(ctx, 10, 10, 255, 0, 0, 255);
40
+ ctx.clearRect(0, 0, 20, 20);
41
+ assertPixel(ctx, 10, 10, 0, 0, 0, 0);
42
+ });
43
+
44
+ await it('clears only the specified sub-rectangle', async () => {
45
+ const ctx = makeCtx(20, 20);
46
+ ctx.fillStyle = 'rgb(0, 0, 255)';
47
+ ctx.fillRect(0, 0, 20, 20);
48
+ ctx.clearRect(5, 5, 10, 10);
49
+ // Inside cleared region
50
+ assertPixel(ctx, 10, 10, 0, 0, 0, 0);
51
+ // Outside cleared region — still blue
52
+ assertPixel(ctx, 2, 2, 0, 0, 255, 255);
53
+ assertPixel(ctx, 17, 17, 0, 0, 255, 255);
54
+ });
55
+
56
+ await it('clearRect is transformed by the current matrix', async () => {
57
+ const ctx = makeCtx(30, 30);
58
+ ctx.fillStyle = 'rgb(0, 255, 0)';
59
+ ctx.fillRect(0, 0, 30, 30);
60
+ ctx.translate(10, 10);
61
+ ctx.clearRect(0, 0, 5, 5);
62
+ // The cleared region is at (10,10)-(15,15) in canvas coords
63
+ assertPixel(ctx, 12, 12, 0, 0, 0, 0);
64
+ // Pixel just outside the cleared region — still green
65
+ assertPixel(ctx, 16, 16, 0, 255, 0, 255);
66
+ });
67
+
68
+ await it('clearRect ignores globalAlpha (spec: always 0)', async () => {
69
+ const ctx = makeCtx(20, 20);
70
+ ctx.fillStyle = 'red';
71
+ ctx.fillRect(0, 0, 20, 20);
72
+ ctx.globalAlpha = 0.5;
73
+ ctx.clearRect(0, 0, 20, 20);
74
+ // Spec: clearRect completely clears, globalAlpha is ignored
75
+ assertPixel(ctx, 10, 10, 0, 0, 0, 0);
76
+ });
77
+
78
+ await it('clearRect ignores globalCompositeOperation (spec: always CLEAR)', async () => {
79
+ const ctx = makeCtx(20, 20);
80
+ ctx.fillStyle = 'red';
81
+ ctx.fillRect(0, 0, 20, 20);
82
+ ctx.globalCompositeOperation = 'xor';
83
+ ctx.clearRect(0, 0, 20, 20);
84
+ // Spec: clearRect uses CLEAR operator regardless of
85
+ // globalCompositeOperation
86
+ assertPixel(ctx, 10, 10, 0, 0, 0, 0);
87
+ });
88
+
89
+ await it('clearRect with negative width clears the normalized region', async () => {
90
+ const ctx = makeCtx(20, 20);
91
+ ctx.fillStyle = 'rgb(255, 255, 0)';
92
+ ctx.fillRect(0, 0, 20, 20);
93
+ // clearRect(15, 5, -10, 10) ≡ clearRect(5, 5, 10, 10)
94
+ ctx.clearRect(15, 5, -10, 10);
95
+ assertPixel(ctx, 10, 10, 0, 0, 0, 0);
96
+ assertPixel(ctx, 2, 2, 255, 255, 0, 255);
97
+ });
98
+
99
+ await it('clearRect restricted by a clip region', async () => {
100
+ const ctx = makeCtx(30, 30);
101
+ ctx.fillStyle = 'rgb(255, 0, 255)';
102
+ ctx.fillRect(0, 0, 30, 30);
103
+ // Clip to a 10×10 box starting at (10,10)
104
+ ctx.beginPath();
105
+ ctx.rect(10, 10, 10, 10);
106
+ ctx.clip();
107
+ // Try to clear the full canvas — only the clipped region should
108
+ // actually be cleared.
109
+ ctx.clearRect(0, 0, 30, 30);
110
+ // Inside clip → cleared
111
+ assertPixel(ctx, 15, 15, 0, 0, 0, 0);
112
+ // Outside clip → still filled
113
+ assertPixel(ctx, 5, 5, 255, 0, 255, 255);
114
+ assertPixel(ctx, 25, 25, 255, 0, 255, 255);
115
+ });
116
+
117
+ await it('clearRect with zero width is a no-op', async () => {
118
+ const ctx = makeCtx(20, 20);
119
+ ctx.fillStyle = 'rgb(128, 128, 128)';
120
+ ctx.fillRect(0, 0, 20, 20);
121
+ ctx.clearRect(5, 5, 0, 10);
122
+ // Nothing cleared — pixel still grey
123
+ assertPixel(ctx, 10, 10, 128, 128, 128, 255);
124
+ });
125
+ });
126
+ };
@@ -0,0 +1,113 @@
1
+ // parseColor tests — RGB, hex, named, and HSL formats
2
+ // Covers Excalibur's non-standard HSL output (0-1 normalized values without %)
3
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl
4
+
5
+ import { describe, it, expect } from '@gjsify/unit';
6
+ import { parseColor } from './color.js';
7
+
8
+ export default async () => {
9
+
10
+ await describe('parseColor — hex and named', async () => {
11
+ await it('parses #ffffff as white', async () => {
12
+ const c = parseColor('#ffffff')!;
13
+ expect(c.r).toBe(1); expect(c.g).toBe(1); expect(c.b).toBe(1); expect(c.a).toBe(1);
14
+ });
15
+
16
+ await it('parses named "white" as white', async () => {
17
+ const c = parseColor('white')!;
18
+ expect(c.r).toBe(1); expect(c.g).toBe(1); expect(c.b).toBe(1);
19
+ });
20
+
21
+ await it('parses "black" as black', async () => {
22
+ const c = parseColor('black')!;
23
+ expect(c.r).toBe(0); expect(c.g).toBe(0); expect(c.b).toBe(0);
24
+ });
25
+ });
26
+
27
+ await describe('parseColor — rgb()/rgba()', async () => {
28
+ await it('parses rgb(255,0,0) as red', async () => {
29
+ const c = parseColor('rgb(255, 0, 0)')!;
30
+ expect(c.r).toBe(1); expect(c.g).toBe(0); expect(c.b).toBe(0); expect(c.a).toBe(1);
31
+ });
32
+
33
+ await it('parses rgba(0,0,255,0.5) as semi-transparent blue', async () => {
34
+ const c = parseColor('rgba(0, 0, 255, 0.5)')!;
35
+ expect(c.r).toBe(0); expect(c.b).toBe(1); expect(c.a).toBe(0.5);
36
+ });
37
+ });
38
+
39
+ await describe('parseColor — standard CSS hsl()/hsla()', async () => {
40
+ await it('hsl(0, 0%, 100%) → white', async () => {
41
+ const c = parseColor('hsl(0, 0%, 100%)')!;
42
+ expect(c).toBeDefined();
43
+ expect(c.r).toBeGreaterThan(0.99);
44
+ expect(c.g).toBeGreaterThan(0.99);
45
+ expect(c.b).toBeGreaterThan(0.99);
46
+ });
47
+
48
+ await it('hsl(0, 0%, 0%) → black', async () => {
49
+ const c = parseColor('hsl(0, 0%, 0%)')!;
50
+ expect(c).toBeDefined();
51
+ expect(c.r).toBe(0); expect(c.g).toBe(0); expect(c.b).toBe(0);
52
+ });
53
+
54
+ await it('hsl(120, 100%, 50%) → green', async () => {
55
+ const c = parseColor('hsl(120, 100%, 50%)')!;
56
+ expect(c).toBeDefined();
57
+ expect(c.g).toBeGreaterThan(0.99);
58
+ expect(c.r).toBeLessThan(0.01);
59
+ expect(c.b).toBeLessThan(0.01);
60
+ });
61
+
62
+ await it('hsla(0, 100%, 50%, 0.5) → semi-transparent red', async () => {
63
+ const c = parseColor('hsla(0, 100%, 50%, 0.5)')!;
64
+ expect(c).toBeDefined();
65
+ expect(c.r).toBeGreaterThan(0.99);
66
+ expect(c.a).toBe(0.5);
67
+ });
68
+ });
69
+
70
+ await describe('parseColor — Excalibur non-standard HSL (0-1 normalized)', async () => {
71
+ // Excalibur's Color.toString() → HSLColor.fromRGBA().toString()
72
+ // uses toFixed(0) on h/s/l values stored in 0-1 range → "hsla(h, s, l, a)"
73
+ // without % signs and with values that are 0 or 1 after rounding.
74
+
75
+ await it('hsla(0, 0, 1, 1) → white (Color.White)', async () => {
76
+ // Color.White = fromHex("#FFFFFF") → r=255,g=255,b=255,a=1
77
+ // → HSLColor(h=0, s=0, l=1, a=1) → "hsla(0, 0, 1, 1)"
78
+ const c = parseColor('hsla(0, 0, 1, 1)')!;
79
+ expect(c).toBeDefined();
80
+ expect(c.r).toBeGreaterThan(0.99);
81
+ expect(c.g).toBeGreaterThan(0.99);
82
+ expect(c.b).toBeGreaterThan(0.99);
83
+ expect(c.a).toBe(1);
84
+ });
85
+
86
+ await it('hsla(0, 0, 0, 1) → black (Color.Black)', async () => {
87
+ const c = parseColor('hsla(0, 0, 0, 1)')!;
88
+ expect(c).toBeDefined();
89
+ expect(c.r).toBe(0); expect(c.g).toBe(0); expect(c.b).toBe(0);
90
+ expect(c.a).toBe(1);
91
+ });
92
+
93
+ await it('hsla(0, 0, 0, 0) → transparent black', async () => {
94
+ const c = parseColor('hsla(0, 0, 0, 0)')!;
95
+ expect(c).toBeDefined();
96
+ expect(c.a).toBe(0);
97
+ });
98
+ });
99
+
100
+ await describe('parseColor — fillStyle round-trip via Excalibur HSL', async () => {
101
+ await it('returns non-null for Excalibur white string', async () => {
102
+ expect(parseColor('hsla(0, 0, 1, 1)')).toBeDefined();
103
+ });
104
+
105
+ await it('returns non-null for Excalibur black string', async () => {
106
+ expect(parseColor('hsla(0, 0, 0, 1)')).toBeDefined();
107
+ });
108
+
109
+ await it('returns null for completely invalid color string', async () => {
110
+ expect(parseColor('not-a-color')).toBeNull();
111
+ });
112
+ });
113
+ };
@@ -0,0 +1,114 @@
1
+ // Canvas 2D composite operation tests — verifies the Cairo operator
2
+ // mapping for the globalCompositeOperation modes that 2D games commonly
3
+ // use (source-over, destination-over, source-in, destination-in,
4
+ // lighter, xor, copy).
5
+ //
6
+ // Ported from refs/wpt/html/canvas/element/compositing/
7
+ // 2d.composite.canvas.{source-over,destination-over,source-in,
8
+ // destination-in,lighter,xor}.html
9
+ // Original: Copyright (c) Web Platform Tests contributors. 3-Clause BSD.
10
+ // Reimplemented for GJS using @gjsify/canvas2d-core + @gjsify/unit.
11
+
12
+ import { describe, it, expect } from '@gjsify/unit';
13
+ import { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
14
+
15
+ function makeCtx(width = 30, height = 30): CanvasRenderingContext2D {
16
+ const canvas = { width, height };
17
+ return new CanvasRenderingContext2D(canvas);
18
+ }
19
+
20
+ function assertPixel(
21
+ ctx: CanvasRenderingContext2D,
22
+ x: number, y: number,
23
+ r: number, g: number, b: number, a: number,
24
+ tol = 2,
25
+ ): void {
26
+ const data = ctx.getImageData(x, y, 1, 1).data;
27
+ expect(Math.abs(data[0] - r) <= tol).toBe(true);
28
+ expect(Math.abs(data[1] - g) <= tol).toBe(true);
29
+ expect(Math.abs(data[2] - b) <= tol).toBe(true);
30
+ expect(Math.abs(data[3] - a) <= tol).toBe(true);
31
+ }
32
+
33
+ /**
34
+ * Common setup: draws a red rect on the left half, then configures the
35
+ * composite operator, then draws a green rect that overlaps on the right half.
36
+ * The test asserts the result in four zones: pure red, overlap, pure green,
37
+ * empty.
38
+ */
39
+ function setupAndDraw(ctx: CanvasRenderingContext2D, mode: GlobalCompositeOperation): void {
40
+ ctx.fillStyle = 'rgb(255, 0, 0)';
41
+ ctx.fillRect(5, 5, 15, 15);
42
+ ctx.globalCompositeOperation = mode;
43
+ ctx.fillStyle = 'rgb(0, 255, 0)';
44
+ ctx.fillRect(10, 10, 15, 15);
45
+ }
46
+
47
+ export default async () => {
48
+ await describe('CanvasRenderingContext2D — globalCompositeOperation', async () => {
49
+
50
+ await describe('source-over (default)', async () => {
51
+ await it('green overlay replaces red in the overlap region', async () => {
52
+ const ctx = makeCtx();
53
+ setupAndDraw(ctx, 'source-over');
54
+ // Red-only zone (7,7)
55
+ assertPixel(ctx, 7, 7, 255, 0, 0, 255);
56
+ // Overlap zone (15,15) — green on top
57
+ assertPixel(ctx, 15, 15, 0, 255, 0, 255);
58
+ // Green-only zone (22,22)
59
+ assertPixel(ctx, 22, 22, 0, 255, 0, 255);
60
+ // Empty zone (2,2)
61
+ assertPixel(ctx, 2, 2, 0, 0, 0, 0);
62
+ });
63
+ });
64
+
65
+ await describe('destination-over', async () => {
66
+ await it('green is drawn BEHIND the existing red', async () => {
67
+ const ctx = makeCtx();
68
+ setupAndDraw(ctx, 'destination-over');
69
+ // Overlap region — red in front
70
+ assertPixel(ctx, 15, 15, 255, 0, 0, 255);
71
+ // Red-only zone unchanged
72
+ assertPixel(ctx, 7, 7, 255, 0, 0, 255);
73
+ // Green-only zone
74
+ assertPixel(ctx, 22, 22, 0, 255, 0, 255);
75
+ });
76
+ });
77
+
78
+ await describe('destination-in', async () => {
79
+ await it('keeps only red pixels where green overlaps', async () => {
80
+ const ctx = makeCtx();
81
+ setupAndDraw(ctx, 'destination-in');
82
+ // Overlap region — red stays (destination-in keeps destination
83
+ // where source exists)
84
+ assertPixel(ctx, 15, 15, 255, 0, 0, 255);
85
+ // Red-only zone — cleared (no source → destination discarded)
86
+ assertPixel(ctx, 7, 7, 0, 0, 0, 0);
87
+ // Green-only zone — transparent (no destination)
88
+ assertPixel(ctx, 22, 22, 0, 0, 0, 0);
89
+ });
90
+ });
91
+
92
+ await describe('xor', async () => {
93
+ await it('overlap region becomes transparent', async () => {
94
+ const ctx = makeCtx();
95
+ setupAndDraw(ctx, 'xor');
96
+ // Overlap → transparent
97
+ assertPixel(ctx, 15, 15, 0, 0, 0, 0);
98
+ // Red-only zone
99
+ assertPixel(ctx, 7, 7, 255, 0, 0, 255);
100
+ // Green-only zone
101
+ assertPixel(ctx, 22, 22, 0, 255, 0, 255);
102
+ });
103
+ });
104
+
105
+ await describe('lighter (additive)', async () => {
106
+ await it('overlap region sums red + green → yellow', async () => {
107
+ const ctx = makeCtx();
108
+ setupAndDraw(ctx, 'lighter');
109
+ // Overlap: red 255 + green 255 = (255, 255, 0, 255) yellow
110
+ assertPixel(ctx, 15, 15, 255, 255, 0, 255);
111
+ });
112
+ });
113
+ });
114
+ };
@@ -0,0 +1,287 @@
1
+ // Canvas 2D drawImage tests — verifies the full 3/5/9-argument forms,
2
+ // transform composition, cross-canvas sourcing, and the critical
3
+ // imageSmoothingEnabled pixel-art path (Cairo NEAREST filter lock).
4
+ //
5
+ // Ported from refs/wpt/html/canvas/element/drawing-images-to-the-canvas/
6
+ // 2d.drawImage.canvas.html, .5arg.html, .9arg.basic.html,
7
+ // .negativedest.html, .negativedir.html, .transform.html,
8
+ // .alpha.html, .self.1.html, .zerosource.html
9
+ // Plus refs/wpt/html/canvas/element/manual/image-smoothing/imagesmoothing.html
10
+ // Original: Copyright (c) Web Platform Tests contributors. 3-Clause BSD.
11
+ // Reimplemented for GJS using @gjsify/canvas2d-core + @gjsify/unit.
12
+
13
+ import { describe, it, expect } from '@gjsify/unit';
14
+ import { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
15
+
16
+ function makeCtx(width = 50, height = 50): CanvasRenderingContext2D {
17
+ const canvas = { width, height };
18
+ return new CanvasRenderingContext2D(canvas);
19
+ }
20
+
21
+ /**
22
+ * Create a test image as an HTMLCanvasElement-like object with known pixel
23
+ * content. Works through the getContext('2d') branch of _getDrawImageSource
24
+ * (canvas-rendering-context-2d.ts:822-834), avoiding the need for PNG
25
+ * fixtures on disk.
26
+ */
27
+ function createTestImage(
28
+ width: number,
29
+ height: number,
30
+ draw: (ctx: CanvasRenderingContext2D) => void,
31
+ ): any {
32
+ const ctx = makeCtx(width, height);
33
+ draw(ctx);
34
+ return {
35
+ width,
36
+ height,
37
+ getContext: (id: string) => (id === '2d' ? ctx : null),
38
+ };
39
+ }
40
+
41
+ /** Assert pixel RGBA within tolerance (default ±2 per channel). */
42
+ function assertPixel(
43
+ ctx: CanvasRenderingContext2D,
44
+ x: number,
45
+ y: number,
46
+ r: number,
47
+ g: number,
48
+ b: number,
49
+ a: number,
50
+ tolerance = 2,
51
+ ): void {
52
+ const data = ctx.getImageData(x, y, 1, 1).data;
53
+ expect(Math.abs(data[0] - r) <= tolerance).toBe(true);
54
+ expect(Math.abs(data[1] - g) <= tolerance).toBe(true);
55
+ expect(Math.abs(data[2] - b) <= tolerance).toBe(true);
56
+ expect(Math.abs(data[3] - a) <= tolerance).toBe(true);
57
+ }
58
+
59
+ function assertPixelExact(
60
+ ctx: CanvasRenderingContext2D,
61
+ x: number,
62
+ y: number,
63
+ r: number,
64
+ g: number,
65
+ b: number,
66
+ a: number,
67
+ ): void {
68
+ const data = ctx.getImageData(x, y, 1, 1).data;
69
+ expect(data[0]).toBe(r);
70
+ expect(data[1]).toBe(g);
71
+ expect(data[2]).toBe(b);
72
+ expect(data[3]).toBe(a);
73
+ }
74
+
75
+ export default async () => {
76
+ await describe('CanvasRenderingContext2D — drawImage', async () => {
77
+
78
+ await describe('3-argument form: drawImage(image, dx, dy)', async () => {
79
+ await it('draws a full canvas source at the given destination', async () => {
80
+ const src = createTestImage(10, 10, (c) => {
81
+ c.fillStyle = 'rgb(255, 0, 0)';
82
+ c.fillRect(0, 0, 10, 10);
83
+ });
84
+ const dst = makeCtx(30, 30);
85
+ dst.drawImage(src, 5, 5);
86
+ // Outside the drawn region → transparent
87
+ assertPixel(dst, 2, 2, 0, 0, 0, 0);
88
+ // Inside the drawn region → red
89
+ assertPixel(dst, 8, 8, 255, 0, 0, 255);
90
+ assertPixel(dst, 14, 14, 255, 0, 0, 255);
91
+ // Right at the edge of the drawn region
92
+ assertPixel(dst, 20, 20, 0, 0, 0, 0);
93
+ });
94
+ });
95
+
96
+ await describe('5-argument form: drawImage(image, dx, dy, dw, dh)', async () => {
97
+ await it('scales the source to the destination rectangle', async () => {
98
+ const src = createTestImage(4, 4, (c) => {
99
+ c.fillStyle = 'rgb(0, 255, 0)';
100
+ c.fillRect(0, 0, 4, 4);
101
+ });
102
+ const dst = makeCtx(40, 40);
103
+ dst.drawImage(src, 10, 10, 20, 20);
104
+ // Pixel well inside the 20×20 destination region → green
105
+ assertPixel(dst, 20, 20, 0, 255, 0, 255);
106
+ // Pixel outside the destination region → transparent
107
+ assertPixel(dst, 5, 5, 0, 0, 0, 0);
108
+ });
109
+ });
110
+
111
+ await describe('9-argument form: drawImage(image, sx,sy,sw,sh, dx,dy,dw,dh)', async () => {
112
+ await it('crops the source region and scales to destination', async () => {
113
+ // Source: 20x20 with left half red, right half blue
114
+ const src = createTestImage(20, 20, (c) => {
115
+ c.fillStyle = 'rgb(255, 0, 0)';
116
+ c.fillRect(0, 0, 10, 20);
117
+ c.fillStyle = 'rgb(0, 0, 255)';
118
+ c.fillRect(10, 0, 10, 20);
119
+ });
120
+ const dst = makeCtx(40, 40);
121
+ // Take only the right (blue) half of the source and scale to 30x30
122
+ dst.drawImage(src, 10, 0, 10, 20, 5, 5, 30, 30);
123
+ assertPixel(dst, 15, 20, 0, 0, 255, 255);
124
+ assertPixel(dst, 30, 20, 0, 0, 255, 255);
125
+ // Outside destination → still transparent
126
+ assertPixel(dst, 2, 2, 0, 0, 0, 0);
127
+ });
128
+ });
129
+
130
+ await describe('drawImage respects current transform', async () => {
131
+ await it('translate shifts the destination', async () => {
132
+ const src = createTestImage(10, 10, (c) => {
133
+ c.fillStyle = 'rgb(255, 255, 0)';
134
+ c.fillRect(0, 0, 10, 10);
135
+ });
136
+ const dst = makeCtx(30, 30);
137
+ dst.translate(10, 10);
138
+ dst.drawImage(src, 0, 0);
139
+ // Yellow should now appear at the translated origin
140
+ assertPixel(dst, 15, 15, 255, 255, 0, 255);
141
+ assertPixel(dst, 5, 5, 0, 0, 0, 0);
142
+ });
143
+
144
+ await it('save/restore isolates drawImage transforms', async () => {
145
+ const src = createTestImage(5, 5, (c) => {
146
+ c.fillStyle = 'rgb(100, 100, 100)';
147
+ c.fillRect(0, 0, 5, 5);
148
+ });
149
+ const dst = makeCtx(30, 30);
150
+ dst.save();
151
+ dst.translate(20, 20);
152
+ dst.drawImage(src, 0, 0);
153
+ dst.restore();
154
+ // After restore, drawImage at 0,0 goes to the origin
155
+ dst.drawImage(src, 0, 0);
156
+ // Both regions should contain grey pixels
157
+ assertPixel(dst, 2, 2, 100, 100, 100, 255);
158
+ assertPixel(dst, 22, 22, 100, 100, 100, 255);
159
+ });
160
+ });
161
+
162
+ await describe('drawImage with globalAlpha', async () => {
163
+ await it('premultiplies globalAlpha into the source color', async () => {
164
+ const src = createTestImage(10, 10, (c) => {
165
+ c.fillStyle = 'rgb(255, 0, 0)';
166
+ c.fillRect(0, 0, 10, 10);
167
+ });
168
+ const dst = makeCtx(30, 30);
169
+ dst.globalAlpha = 0.5;
170
+ dst.drawImage(src, 10, 10);
171
+ // Sample well inside the destination rect to avoid bilinear
172
+ // edge bleeding. Canvas 2D spec: getImageData returns
173
+ // non-premultiplied RGBA, so red stays 255 while alpha
174
+ // becomes ~128. Tolerance ±5 for Cairo's rounding.
175
+ const data = dst.getImageData(15, 15, 1, 1).data;
176
+ expect(data[0]).toBe(255);
177
+ expect(data[1]).toBe(0);
178
+ expect(data[2]).toBe(0);
179
+ expect(data[3] > 120 && data[3] < 135).toBe(true);
180
+ });
181
+ });
182
+
183
+ await describe('drawImage — zero-size and edge cases', async () => {
184
+ await it('drawImage with 0-width source is a no-op', async () => {
185
+ const src = createTestImage(10, 10, (c) => {
186
+ c.fillStyle = 'red';
187
+ c.fillRect(0, 0, 10, 10);
188
+ });
189
+ const dst = makeCtx(20, 20);
190
+ // Pre-fill destination to check nothing was overwritten
191
+ dst.fillStyle = 'blue';
192
+ dst.fillRect(0, 0, 20, 20);
193
+ dst.drawImage(src, 0, 0, 0, 10, 5, 5, 10, 10);
194
+ // Pixel should still be the pre-fill blue
195
+ assertPixel(dst, 10, 10, 0, 0, 255, 255);
196
+ });
197
+
198
+ await it('drawImage with 0-height destination is a no-op', async () => {
199
+ const src = createTestImage(10, 10, (c) => {
200
+ c.fillStyle = 'red';
201
+ c.fillRect(0, 0, 10, 10);
202
+ });
203
+ const dst = makeCtx(20, 20);
204
+ dst.fillStyle = 'blue';
205
+ dst.fillRect(0, 0, 20, 20);
206
+ dst.drawImage(src, 5, 5, 10, 0);
207
+ assertPixel(dst, 10, 10, 0, 0, 255, 255);
208
+ });
209
+ });
210
+
211
+ await describe('drawImage — imageSmoothingEnabled pixel-art lock', async () => {
212
+ // Regression for the Cairo Filter.NEAREST fix in drawImage.
213
+ // When imageSmoothingEnabled=false, scaling a 2×2 image by 10 must
214
+ // produce a sharp checker pattern. Otherwise Cairo's default
215
+ // BILINEAR filter blends the pixels and pixel-art games render
216
+ // blurry.
217
+
218
+ await it('imageSmoothingEnabled=false: exact nearest-neighbor scale', async () => {
219
+ // 2×2 image: red (top-left), green (top-right),
220
+ // blue (bottom-left), white (bottom-right).
221
+ const src = createTestImage(2, 2, (c) => {
222
+ c.fillStyle = 'rgb(255, 0, 0)'; c.fillRect(0, 0, 1, 1);
223
+ c.fillStyle = 'rgb(0, 255, 0)'; c.fillRect(1, 0, 1, 1);
224
+ c.fillStyle = 'rgb(0, 0, 255)'; c.fillRect(0, 1, 1, 1);
225
+ c.fillStyle = 'rgb(255, 255, 255)'; c.fillRect(1, 1, 1, 1);
226
+ });
227
+ const dst = makeCtx(20, 20);
228
+ dst.imageSmoothingEnabled = false;
229
+ dst.drawImage(src, 0, 0, 20, 20);
230
+
231
+ // With NEAREST, each source pixel fills a 10×10 quadrant in
232
+ // the destination without color bleeding. Pixel (5,5) should
233
+ // be exactly red, (15,5) exactly green, etc.
234
+ assertPixelExact(dst, 5, 5, 255, 0, 0, 255);
235
+ assertPixelExact(dst, 15, 5, 0, 255, 0, 255);
236
+ assertPixelExact(dst, 5, 15, 0, 0, 255, 255);
237
+ assertPixelExact(dst, 15, 15, 255, 255, 255, 255);
238
+ });
239
+
240
+ await it('imageSmoothingEnabled=true: bilinear bleeding at boundaries', async () => {
241
+ const src = createTestImage(2, 2, (c) => {
242
+ c.fillStyle = 'rgb(255, 0, 0)'; c.fillRect(0, 0, 1, 1);
243
+ c.fillStyle = 'rgb(0, 255, 0)'; c.fillRect(1, 0, 1, 1);
244
+ c.fillStyle = 'rgb(0, 0, 255)'; c.fillRect(0, 1, 1, 1);
245
+ c.fillStyle = 'rgb(255, 255, 255)'; c.fillRect(1, 1, 1, 1);
246
+ });
247
+ const dst = makeCtx(20, 20);
248
+ dst.imageSmoothingEnabled = true;
249
+ dst.drawImage(src, 0, 0, 20, 20);
250
+
251
+ // With bilinear, pixel at the boundary between red and green
252
+ // (around x=10, y=5) is a mix — NOT exactly red and NOT
253
+ // exactly green. This test proves the filter is applied.
254
+ const boundary = dst.getImageData(9, 5, 1, 1).data;
255
+ const isExactRed = boundary[0] === 255 && boundary[1] === 0;
256
+ const isExactGreen = boundary[0] === 0 && boundary[1] === 255;
257
+ // Neither pure red nor pure green → bilinear blending is active
258
+ expect(isExactRed).toBe(false);
259
+ expect(isExactGreen).toBe(false);
260
+ });
261
+ });
262
+
263
+ await describe('drawImage with composite operation', async () => {
264
+ // Note: the 'copy' operator in Canvas spec wipes the ENTIRE
265
+ // destination, not just the drawImage rect. Our implementation
266
+ // clips to the destination rect for paint(), so the "wipe
267
+ // outside" semantic is NOT implemented here — that would require
268
+ // a separate clearRect path. We only verify that the inside
269
+ // pixel is correctly replaced.
270
+ await it('drawImage honors globalCompositeOperation=source-over', async () => {
271
+ const src = createTestImage(10, 10, (c) => {
272
+ c.fillStyle = 'rgb(0, 255, 0)';
273
+ c.fillRect(0, 0, 10, 10);
274
+ });
275
+ const dst = makeCtx(20, 20);
276
+ // Pre-fill with red
277
+ dst.fillStyle = 'rgb(255, 0, 0)';
278
+ dst.fillRect(0, 0, 20, 20);
279
+ // source-over: green opaque pixel replaces red in the drawn region
280
+ dst.drawImage(src, 5, 5);
281
+ assertPixel(dst, 10, 10, 0, 255, 0, 255);
282
+ // Outside drawn region → still red (unaffected)
283
+ assertPixel(dst, 1, 1, 255, 0, 0, 255);
284
+ });
285
+ });
286
+ });
287
+ };