@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.
- package/lib/esm/cairo-utils.js +8 -8
- package/lib/esm/canvas-rendering-context-2d.js +190 -80
- package/lib/esm/color.js +38 -0
- package/lib/types/cairo-utils.d.ts +12 -1
- package/lib/types/canvas-clearing.spec.d.ts +2 -0
- package/lib/types/canvas-color.spec.d.ts +2 -0
- package/lib/types/canvas-composite.spec.d.ts +2 -0
- package/lib/types/canvas-drawimage.spec.d.ts +2 -0
- package/lib/types/canvas-imagedata.spec.d.ts +2 -0
- package/lib/types/canvas-rendering-context-2d.d.ts +30 -3
- package/lib/types/canvas-state.spec.d.ts +2 -0
- package/lib/types/canvas-transform.spec.d.ts +2 -0
- package/lib/types/color.d.ts +5 -1
- package/package.json +11 -11
- package/src/cairo-utils.ts +20 -9
- package/src/canvas-clearing.spec.ts +126 -0
- package/src/canvas-color.spec.ts +113 -0
- package/src/canvas-composite.spec.ts +114 -0
- package/src/canvas-drawimage.spec.ts +287 -0
- package/src/canvas-imagedata.spec.ts +150 -0
- package/src/canvas-rendering-context-2d.ts +253 -94
- package/src/canvas-state.spec.ts +245 -0
- package/src/canvas-transform.spec.ts +211 -0
- package/src/color.ts +53 -1
- package/src/test.mts +17 -1
- package/tmp/.tsbuildinfo +1 -1
|
@@ -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
|
+
};
|