@gjsify/canvas2d 0.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.
- package/README.md +33 -0
- package/lib/esm/cairo-utils.js +172 -0
- package/lib/esm/canvas-drawing-area.js +112 -0
- package/lib/esm/canvas-gradient.js +23 -0
- package/lib/esm/canvas-path.js +104 -0
- package/lib/esm/canvas-pattern.js +58 -0
- package/lib/esm/canvas-rendering-context-2d.js +865 -0
- package/lib/esm/canvas-state.js +39 -0
- package/lib/esm/color.js +209 -0
- package/lib/esm/image-data.js +22 -0
- package/lib/esm/index.js +43 -0
- package/lib/types/cairo-utils.d.ts +54 -0
- package/lib/types/canvas-drawing-area.d.ts +482 -0
- package/lib/types/canvas-gradient.d.ts +11 -0
- package/lib/types/canvas-path.d.ts +80 -0
- package/lib/types/canvas-pattern.d.ts +12 -0
- package/lib/types/canvas-rendering-context-2d.d.ts +155 -0
- package/lib/types/canvas-state.d.ts +27 -0
- package/lib/types/color.d.ts +15 -0
- package/lib/types/image-data.d.ts +12 -0
- package/lib/types/index.d.ts +7 -0
- package/package.json +50 -0
- package/src/cairo-utils.ts +243 -0
- package/src/canvas-drawing-area.ts +164 -0
- package/src/canvas-gradient.ts +36 -0
- package/src/canvas-path.ts +131 -0
- package/src/canvas-pattern.ts +75 -0
- package/src/canvas-rendering-context-2d.ts +981 -0
- package/src/canvas-state.ts +77 -0
- package/src/color.ts +125 -0
- package/src/image-data.ts +34 -0
- package/src/index.spec.ts +828 -0
- package/src/index.ts +49 -0
- package/src/test.mts +6 -0
- package/tmp/.tsbuildinfo +1 -0
- package/tsconfig.json +48 -0
|
@@ -0,0 +1,828 @@
|
|
|
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, CanvasGradient, 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 [r1, g1, b1, a1] = getPixel(ctx, 10, 10);
|
|
162
|
+
expect(g1).toBe(255);
|
|
163
|
+
expect(a1).toBe(255);
|
|
164
|
+
// Outside the rect (should be transparent)
|
|
165
|
+
const [_r2, _g2, _b2, 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 [r, g, b, a] = getPixel(ctx, 5, 5);
|
|
174
|
+
expect(r).toBe(0);
|
|
175
|
+
expect(g).toBe(0);
|
|
176
|
+
// Alpha should be approximately 128 (0.5 * 255)
|
|
177
|
+
expect(a).toBeGreaterThan(120);
|
|
178
|
+
expect(a).toBeLessThan(140);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await it('should not affect the current path', async () => {
|
|
182
|
+
// Regression: fillRect added a rectangle to the existing path.
|
|
183
|
+
// Since fill() uses fillPreserve(), the preserved path was
|
|
184
|
+
// repainted with fillRect's color.
|
|
185
|
+
const { ctx } = createCanvas(40, 40);
|
|
186
|
+
// Draw a red circle
|
|
187
|
+
ctx.beginPath();
|
|
188
|
+
ctx.arc(10, 10, 8, 0, Math.PI * 2);
|
|
189
|
+
ctx.fillStyle = '#ff0000';
|
|
190
|
+
ctx.fill();
|
|
191
|
+
// fillRect with green in a different area — must not repaint the circle
|
|
192
|
+
ctx.fillStyle = '#00ff00';
|
|
193
|
+
ctx.fillRect(25, 25, 10, 10);
|
|
194
|
+
// Circle should still be red, not green
|
|
195
|
+
const [r, g, _b, a] = getPixel(ctx, 10, 10);
|
|
196
|
+
expect(r).toBe(255);
|
|
197
|
+
expect(g).toBe(0);
|
|
198
|
+
expect(a).toBe(255);
|
|
199
|
+
// Rectangle should be green
|
|
200
|
+
const [r2, g2, _b2, a2] = getPixel(ctx, 30, 30);
|
|
201
|
+
expect(r2).toBe(0);
|
|
202
|
+
expect(g2).toBe(255);
|
|
203
|
+
expect(a2).toBe(255);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---- clearRect ----
|
|
208
|
+
|
|
209
|
+
await describe('clearRect', async () => {
|
|
210
|
+
await it('should clear a region to transparent', async () => {
|
|
211
|
+
const { ctx } = createCanvas(20, 20);
|
|
212
|
+
ctx.fillStyle = '#ff0000';
|
|
213
|
+
ctx.fillRect(0, 0, 20, 20);
|
|
214
|
+
ctx.clearRect(5, 5, 10, 10);
|
|
215
|
+
// Inside cleared region
|
|
216
|
+
const [_r, _g, _b, a] = getPixel(ctx, 10, 10);
|
|
217
|
+
expect(a).toBe(0);
|
|
218
|
+
// Outside cleared region (still red)
|
|
219
|
+
const [r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
|
|
220
|
+
expect(r2).toBe(255);
|
|
221
|
+
expect(a2).toBe(255);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await it('should not affect the current path', async () => {
|
|
225
|
+
// Regression: clearRect added a rectangle to the current path.
|
|
226
|
+
// Per spec, clearRect must not affect the current path.
|
|
227
|
+
const { ctx } = createCanvas(40, 40);
|
|
228
|
+
ctx.fillStyle = '#ff0000';
|
|
229
|
+
ctx.beginPath();
|
|
230
|
+
ctx.arc(20, 20, 10, 0, Math.PI * 2);
|
|
231
|
+
ctx.fill();
|
|
232
|
+
// clearRect a different region — path should survive
|
|
233
|
+
ctx.clearRect(0, 0, 5, 5);
|
|
234
|
+
// Fill again with the preserved path — should still be a circle
|
|
235
|
+
ctx.fillStyle = '#0000ff';
|
|
236
|
+
ctx.fill();
|
|
237
|
+
// Center of circle should now be blue
|
|
238
|
+
const [r, _g, b, a] = getPixel(ctx, 20, 20);
|
|
239
|
+
expect(r).toBe(0);
|
|
240
|
+
expect(b).toBe(255);
|
|
241
|
+
expect(a).toBe(255);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---- strokeRect ----
|
|
246
|
+
|
|
247
|
+
await describe('strokeRect', async () => {
|
|
248
|
+
await it('should stroke a rectangle outline', async () => {
|
|
249
|
+
const { ctx } = createCanvas(20, 20);
|
|
250
|
+
ctx.strokeStyle = '#0000ff';
|
|
251
|
+
ctx.lineWidth = 2;
|
|
252
|
+
ctx.strokeRect(2, 2, 16, 16);
|
|
253
|
+
// On the stroke border (top edge around y=2)
|
|
254
|
+
const [_r, _g, b, a] = getPixel(ctx, 10, 2);
|
|
255
|
+
expect(b).toBe(255);
|
|
256
|
+
expect(a).toBe(255);
|
|
257
|
+
// Center should be empty
|
|
258
|
+
const [_r2, _g2, _b2, a2] = getPixel(ctx, 10, 10);
|
|
259
|
+
expect(a2).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ---- Path operations ----
|
|
264
|
+
|
|
265
|
+
await describe('Path operations', async () => {
|
|
266
|
+
await it('should fill a triangle', async () => {
|
|
267
|
+
const { ctx } = createCanvas(20, 20);
|
|
268
|
+
ctx.fillStyle = '#00ff00';
|
|
269
|
+
ctx.beginPath();
|
|
270
|
+
ctx.moveTo(10, 0);
|
|
271
|
+
ctx.lineTo(20, 20);
|
|
272
|
+
ctx.lineTo(0, 20);
|
|
273
|
+
ctx.closePath();
|
|
274
|
+
ctx.fill();
|
|
275
|
+
// Bottom center should be filled
|
|
276
|
+
const [_r, g, _b, a] = getPixel(ctx, 10, 15);
|
|
277
|
+
expect(g).toBe(255);
|
|
278
|
+
expect(a).toBe(255);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await it('should fill a circle (arc)', async () => {
|
|
282
|
+
const { ctx } = createCanvas(20, 20);
|
|
283
|
+
ctx.fillStyle = '#ff0000';
|
|
284
|
+
ctx.beginPath();
|
|
285
|
+
ctx.arc(10, 10, 8, 0, Math.PI * 2);
|
|
286
|
+
ctx.fill();
|
|
287
|
+
// Center should be filled
|
|
288
|
+
const [r, _g, _b, a] = getPixel(ctx, 10, 10);
|
|
289
|
+
expect(r).toBe(255);
|
|
290
|
+
expect(a).toBe(255);
|
|
291
|
+
// Corner should be empty
|
|
292
|
+
const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
|
|
293
|
+
expect(a2).toBe(0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await it('should fill a full circle with counterclockwise=true', async () => {
|
|
297
|
+
// Regression: Cairo arcNegative(x,y,r,0,2π) normalizes endAngle to
|
|
298
|
+
// startAngle, producing a zero-length arc. Browsers draw a full circle.
|
|
299
|
+
const { ctx } = createCanvas(20, 20);
|
|
300
|
+
ctx.fillStyle = '#ff0000';
|
|
301
|
+
ctx.beginPath();
|
|
302
|
+
ctx.arc(10, 10, 8, 0, Math.PI * 2, true);
|
|
303
|
+
ctx.fill();
|
|
304
|
+
const [r, _g, _b, a] = getPixel(ctx, 10, 10);
|
|
305
|
+
expect(r).toBe(255);
|
|
306
|
+
expect(a).toBe(255);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await it('should fill a rectangle path', async () => {
|
|
310
|
+
const { ctx } = createCanvas(20, 20);
|
|
311
|
+
ctx.fillStyle = '#0000ff';
|
|
312
|
+
ctx.beginPath();
|
|
313
|
+
ctx.rect(2, 2, 16, 16);
|
|
314
|
+
ctx.fill();
|
|
315
|
+
const [_r, _g, b, a] = getPixel(ctx, 10, 10);
|
|
316
|
+
expect(b).toBe(255);
|
|
317
|
+
expect(a).toBe(255);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ---- Transforms ----
|
|
322
|
+
|
|
323
|
+
await describe('Transforms', async () => {
|
|
324
|
+
await it('should translate', async () => {
|
|
325
|
+
const { ctx } = createCanvas(20, 20);
|
|
326
|
+
ctx.fillStyle = '#ff0000';
|
|
327
|
+
ctx.translate(5, 5);
|
|
328
|
+
ctx.fillRect(0, 0, 5, 5);
|
|
329
|
+
// The rect is drawn at (5,5) in device space
|
|
330
|
+
const [r, _g, _b, a] = getPixel(ctx, 7, 7);
|
|
331
|
+
expect(r).toBe(255);
|
|
332
|
+
expect(a).toBe(255);
|
|
333
|
+
// Origin should be empty
|
|
334
|
+
const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
|
|
335
|
+
expect(a2).toBe(0);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await it('should scale', async () => {
|
|
339
|
+
const { ctx } = createCanvas(20, 20);
|
|
340
|
+
ctx.fillStyle = '#00ff00';
|
|
341
|
+
ctx.scale(2, 2);
|
|
342
|
+
ctx.fillRect(0, 0, 5, 5);
|
|
343
|
+
// The rect is 10x10 in device space
|
|
344
|
+
const [_r, g, _b, a] = getPixel(ctx, 9, 9);
|
|
345
|
+
expect(g).toBe(255);
|
|
346
|
+
expect(a).toBe(255);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await it('should resetTransform', async () => {
|
|
350
|
+
const { ctx } = createCanvas(20, 20);
|
|
351
|
+
ctx.translate(100, 100);
|
|
352
|
+
ctx.resetTransform();
|
|
353
|
+
ctx.fillStyle = '#ff0000';
|
|
354
|
+
ctx.fillRect(0, 0, 5, 5);
|
|
355
|
+
const [r, _g, _b, a] = getPixel(ctx, 2, 2);
|
|
356
|
+
expect(r).toBe(255);
|
|
357
|
+
expect(a).toBe(255);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await it('should getTransform return identity by default', async () => {
|
|
361
|
+
const { ctx } = createCanvas();
|
|
362
|
+
const m = ctx.getTransform();
|
|
363
|
+
expect(m.a).toBe(1);
|
|
364
|
+
expect(m.b).toBe(0);
|
|
365
|
+
expect(m.c).toBe(0);
|
|
366
|
+
expect(m.d).toBe(1);
|
|
367
|
+
expect(m.e).toBe(0);
|
|
368
|
+
expect(m.f).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ---- save/restore ----
|
|
373
|
+
|
|
374
|
+
await describe('save/restore', async () => {
|
|
375
|
+
await it('should save and restore fillStyle', async () => {
|
|
376
|
+
const { ctx } = createCanvas();
|
|
377
|
+
ctx.fillStyle = '#ff0000';
|
|
378
|
+
ctx.save();
|
|
379
|
+
ctx.fillStyle = '#00ff00';
|
|
380
|
+
expect(ctx.fillStyle).toBe('#00ff00');
|
|
381
|
+
ctx.restore();
|
|
382
|
+
expect(ctx.fillStyle).toBe('#ff0000');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await it('should save and restore lineWidth', async () => {
|
|
386
|
+
const { ctx } = createCanvas();
|
|
387
|
+
ctx.lineWidth = 5;
|
|
388
|
+
ctx.save();
|
|
389
|
+
ctx.lineWidth = 10;
|
|
390
|
+
ctx.restore();
|
|
391
|
+
expect(ctx.lineWidth).toBe(5);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await it('should save and restore globalAlpha', async () => {
|
|
395
|
+
const { ctx } = createCanvas();
|
|
396
|
+
ctx.globalAlpha = 0.5;
|
|
397
|
+
ctx.save();
|
|
398
|
+
ctx.globalAlpha = 1.0;
|
|
399
|
+
ctx.restore();
|
|
400
|
+
expect(ctx.globalAlpha).toBe(0.5);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await it('should save and restore transforms', async () => {
|
|
404
|
+
const { ctx } = createCanvas(20, 20);
|
|
405
|
+
ctx.translate(5, 5);
|
|
406
|
+
ctx.save();
|
|
407
|
+
ctx.translate(100, 100);
|
|
408
|
+
ctx.restore();
|
|
409
|
+
// After restore, translate(5,5) should still be in effect
|
|
410
|
+
ctx.fillStyle = '#ff0000';
|
|
411
|
+
ctx.fillRect(0, 0, 5, 5);
|
|
412
|
+
const [r, _g, _b, a] = getPixel(ctx, 7, 7);
|
|
413
|
+
expect(r).toBe(255);
|
|
414
|
+
expect(a).toBe(255);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ---- Line properties ----
|
|
419
|
+
|
|
420
|
+
await describe('Line properties', async () => {
|
|
421
|
+
await it('should set and get lineWidth', async () => {
|
|
422
|
+
const { ctx } = createCanvas();
|
|
423
|
+
ctx.lineWidth = 5;
|
|
424
|
+
expect(ctx.lineWidth).toBe(5);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
await it('should ignore invalid lineWidth', async () => {
|
|
428
|
+
const { ctx } = createCanvas();
|
|
429
|
+
ctx.lineWidth = 5;
|
|
430
|
+
ctx.lineWidth = -1;
|
|
431
|
+
expect(ctx.lineWidth).toBe(5);
|
|
432
|
+
ctx.lineWidth = 0;
|
|
433
|
+
expect(ctx.lineWidth).toBe(5);
|
|
434
|
+
ctx.lineWidth = Infinity;
|
|
435
|
+
expect(ctx.lineWidth).toBe(5);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await it('should set and get lineCap', async () => {
|
|
439
|
+
const { ctx } = createCanvas();
|
|
440
|
+
ctx.lineCap = 'round';
|
|
441
|
+
expect(ctx.lineCap).toBe('round');
|
|
442
|
+
ctx.lineCap = 'square';
|
|
443
|
+
expect(ctx.lineCap).toBe('square');
|
|
444
|
+
ctx.lineCap = 'butt';
|
|
445
|
+
expect(ctx.lineCap).toBe('butt');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await it('should set and get lineJoin', async () => {
|
|
449
|
+
const { ctx } = createCanvas();
|
|
450
|
+
ctx.lineJoin = 'round';
|
|
451
|
+
expect(ctx.lineJoin).toBe('round');
|
|
452
|
+
ctx.lineJoin = 'bevel';
|
|
453
|
+
expect(ctx.lineJoin).toBe('bevel');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
await it('should set and get lineDash', async () => {
|
|
457
|
+
const { ctx } = createCanvas();
|
|
458
|
+
ctx.setLineDash([5, 10]);
|
|
459
|
+
expect(ctx.getLineDash().length).toBe(2);
|
|
460
|
+
expect(ctx.getLineDash()[0]).toBe(5);
|
|
461
|
+
expect(ctx.getLineDash()[1]).toBe(10);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await it('should ignore negative lineDash values', async () => {
|
|
465
|
+
const { ctx } = createCanvas();
|
|
466
|
+
ctx.setLineDash([5, 10]);
|
|
467
|
+
ctx.setLineDash([-1, 5]);
|
|
468
|
+
// Should not have changed
|
|
469
|
+
expect(ctx.getLineDash()[0]).toBe(5);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ---- globalAlpha ----
|
|
474
|
+
|
|
475
|
+
await describe('globalAlpha', async () => {
|
|
476
|
+
await it('should affect fill opacity', async () => {
|
|
477
|
+
const { ctx } = createCanvas(10, 10);
|
|
478
|
+
ctx.globalAlpha = 0.5;
|
|
479
|
+
ctx.fillStyle = '#ff0000';
|
|
480
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
481
|
+
const [r, _g, _b, a] = getPixel(ctx, 5, 5);
|
|
482
|
+
// Alpha should be approximately 128
|
|
483
|
+
expect(a).toBeGreaterThan(120);
|
|
484
|
+
expect(a).toBeLessThan(140);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
await it('should reject invalid values', async () => {
|
|
488
|
+
const { ctx } = createCanvas();
|
|
489
|
+
ctx.globalAlpha = 0.5;
|
|
490
|
+
ctx.globalAlpha = -0.1;
|
|
491
|
+
expect(ctx.globalAlpha).toBe(0.5);
|
|
492
|
+
ctx.globalAlpha = 1.1;
|
|
493
|
+
expect(ctx.globalAlpha).toBe(0.5);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ---- globalCompositeOperation ----
|
|
498
|
+
|
|
499
|
+
await describe('globalCompositeOperation', async () => {
|
|
500
|
+
await it('should default to source-over', async () => {
|
|
501
|
+
const { ctx } = createCanvas();
|
|
502
|
+
expect(ctx.globalCompositeOperation).toBe('source-over');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await it('should accept valid operations', async () => {
|
|
506
|
+
const { ctx } = createCanvas();
|
|
507
|
+
const ops = ['source-over', 'source-in', 'source-out', 'source-atop',
|
|
508
|
+
'destination-over', 'destination-in', 'destination-out', 'destination-atop',
|
|
509
|
+
'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay',
|
|
510
|
+
'darken', 'lighten', 'color-dodge', 'color-burn',
|
|
511
|
+
'hard-light', 'soft-light', 'difference', 'exclusion',
|
|
512
|
+
'hue', 'saturation', 'color', 'luminosity'];
|
|
513
|
+
for (const op of ops) {
|
|
514
|
+
ctx.globalCompositeOperation = op as GlobalCompositeOperation;
|
|
515
|
+
expect(ctx.globalCompositeOperation).toBe(op);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await it('copy should replace destination', async () => {
|
|
520
|
+
const { ctx } = createCanvas(10, 10);
|
|
521
|
+
ctx.fillStyle = '#ff0000';
|
|
522
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
523
|
+
ctx.globalCompositeOperation = 'copy';
|
|
524
|
+
ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
|
|
525
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
526
|
+
const [r, g, _b, a] = getPixel(ctx, 5, 5);
|
|
527
|
+
// Red should be gone (copy replaces)
|
|
528
|
+
expect(r).toBe(0);
|
|
529
|
+
expect(g).toBeGreaterThan(0);
|
|
530
|
+
expect(a).toBeGreaterThan(120);
|
|
531
|
+
expect(a).toBeLessThan(140);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ---- Gradients ----
|
|
536
|
+
|
|
537
|
+
await describe('Gradients', async () => {
|
|
538
|
+
await it('should create a linear gradient', async () => {
|
|
539
|
+
const { ctx } = createCanvas(20, 10);
|
|
540
|
+
const grad = ctx.createLinearGradient(0, 0, 20, 0);
|
|
541
|
+
grad.addColorStop(0, '#ff0000');
|
|
542
|
+
grad.addColorStop(1, '#0000ff');
|
|
543
|
+
ctx.fillStyle = grad;
|
|
544
|
+
ctx.fillRect(0, 0, 20, 10);
|
|
545
|
+
// Left should be red-ish
|
|
546
|
+
const [r1, _g1, b1] = getPixel(ctx, 1, 5);
|
|
547
|
+
expect(r1).toBeGreaterThan(200);
|
|
548
|
+
expect(b1).toBeLessThan(50);
|
|
549
|
+
// Right should be blue-ish
|
|
550
|
+
const [r2, _g2, b2] = getPixel(ctx, 18, 5);
|
|
551
|
+
expect(r2).toBeLessThan(50);
|
|
552
|
+
expect(b2).toBeGreaterThan(200);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await it('should create a radial gradient', async () => {
|
|
556
|
+
const { ctx } = createCanvas(20, 20);
|
|
557
|
+
const grad = ctx.createRadialGradient(10, 10, 0, 10, 10, 10);
|
|
558
|
+
grad.addColorStop(0, '#ffffff');
|
|
559
|
+
grad.addColorStop(1, '#000000');
|
|
560
|
+
ctx.fillStyle = grad;
|
|
561
|
+
ctx.fillRect(0, 0, 20, 20);
|
|
562
|
+
// Center should be bright
|
|
563
|
+
const [r1] = getPixel(ctx, 10, 10);
|
|
564
|
+
expect(r1).toBeGreaterThan(200);
|
|
565
|
+
// Edge should be dark
|
|
566
|
+
const [r2] = getPixel(ctx, 19, 10);
|
|
567
|
+
expect(r2).toBeLessThan(50);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ---- getImageData / putImageData round-trip ----
|
|
572
|
+
|
|
573
|
+
await describe('getImageData / putImageData', async () => {
|
|
574
|
+
await it('should round-trip pixel data', async () => {
|
|
575
|
+
const { ctx } = createCanvas(10, 10);
|
|
576
|
+
ctx.fillStyle = '#ff8000';
|
|
577
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
578
|
+
const imageData = ctx.getImageData(0, 0, 10, 10);
|
|
579
|
+
expect(imageData.width).toBe(10);
|
|
580
|
+
expect(imageData.height).toBe(10);
|
|
581
|
+
// Check orange pixel
|
|
582
|
+
expect(imageData.data[0]).toBe(255); // R
|
|
583
|
+
expect(imageData.data[1]).toBeGreaterThan(120); // G (~128)
|
|
584
|
+
expect(imageData.data[2]).toBe(0); // B
|
|
585
|
+
expect(imageData.data[3]).toBe(255); // A
|
|
586
|
+
|
|
587
|
+
// Clear and put it back
|
|
588
|
+
ctx.clearRect(0, 0, 10, 10);
|
|
589
|
+
ctx.putImageData(imageData, 0, 0);
|
|
590
|
+
const [r, _g, _b, a] = getPixel(ctx, 5, 5);
|
|
591
|
+
expect(r).toBe(255);
|
|
592
|
+
expect(a).toBe(255);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await it('should createImageData with correct dimensions', async () => {
|
|
596
|
+
const { ctx } = createCanvas();
|
|
597
|
+
const img = ctx.createImageData(5, 3);
|
|
598
|
+
expect(img.width).toBe(5);
|
|
599
|
+
expect(img.height).toBe(3);
|
|
600
|
+
expect(img.data.length).toBe(5 * 3 * 4);
|
|
601
|
+
// Should be all zeros (transparent black)
|
|
602
|
+
expect(img.data[0]).toBe(0);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ---- Path2D ----
|
|
607
|
+
|
|
608
|
+
await describe('Path2D', async () => {
|
|
609
|
+
await it('should create and fill a Path2D rectangle', async () => {
|
|
610
|
+
const { ctx } = createCanvas(20, 20);
|
|
611
|
+
const path = new Path2D();
|
|
612
|
+
path.rect(2, 2, 16, 16);
|
|
613
|
+
ctx.fillStyle = '#ff0000';
|
|
614
|
+
ctx.fill(path);
|
|
615
|
+
const [r, _g, _b, a] = getPixel(ctx, 10, 10);
|
|
616
|
+
expect(r).toBe(255);
|
|
617
|
+
expect(a).toBe(255);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
await it('should stroke a Path2D', async () => {
|
|
621
|
+
const { ctx } = createCanvas(20, 20);
|
|
622
|
+
const path = new Path2D();
|
|
623
|
+
path.moveTo(0, 10);
|
|
624
|
+
path.lineTo(20, 10);
|
|
625
|
+
ctx.strokeStyle = '#00ff00';
|
|
626
|
+
ctx.lineWidth = 2;
|
|
627
|
+
ctx.stroke(path);
|
|
628
|
+
const [_r, g, _b, a] = getPixel(ctx, 10, 10);
|
|
629
|
+
expect(g).toBe(255);
|
|
630
|
+
expect(a).toBe(255);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await it('should copy a Path2D', async () => {
|
|
634
|
+
const path1 = new Path2D();
|
|
635
|
+
path1.rect(0, 0, 10, 10);
|
|
636
|
+
const path2 = new Path2D(path1);
|
|
637
|
+
// path2 should have the same ops
|
|
638
|
+
expect(path2._ops.length).toBe(path1._ops.length);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
await it('should addPath', async () => {
|
|
642
|
+
const path1 = new Path2D();
|
|
643
|
+
path1.rect(0, 0, 5, 5);
|
|
644
|
+
const path2 = new Path2D();
|
|
645
|
+
path2.rect(10, 10, 5, 5);
|
|
646
|
+
path1.addPath(path2);
|
|
647
|
+
expect(path1._ops.length).toBe(2);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// ---- Clipping ----
|
|
652
|
+
|
|
653
|
+
await describe('Clipping', async () => {
|
|
654
|
+
await it('should clip to a rectangular region', async () => {
|
|
655
|
+
const { ctx } = createCanvas(20, 20);
|
|
656
|
+
ctx.beginPath();
|
|
657
|
+
ctx.rect(5, 5, 10, 10);
|
|
658
|
+
ctx.clip();
|
|
659
|
+
ctx.fillStyle = '#ff0000';
|
|
660
|
+
ctx.fillRect(0, 0, 20, 20);
|
|
661
|
+
// Inside clip → filled
|
|
662
|
+
const [r, _g, _b, a] = getPixel(ctx, 10, 10);
|
|
663
|
+
expect(r).toBe(255);
|
|
664
|
+
expect(a).toBe(255);
|
|
665
|
+
// Outside clip → transparent
|
|
666
|
+
const [_r2, _g2, _b2, a2] = getPixel(ctx, 0, 0);
|
|
667
|
+
expect(a2).toBe(0);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// ---- Text ----
|
|
672
|
+
|
|
673
|
+
await describe('Text', async () => {
|
|
674
|
+
await it('should measureText return non-zero width', async () => {
|
|
675
|
+
const { ctx } = createCanvas();
|
|
676
|
+
ctx.font = '16px sans-serif';
|
|
677
|
+
const m = ctx.measureText('Hello World');
|
|
678
|
+
expect(m.width).toBeGreaterThan(0);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await it('should measureText empty string return 0 width', async () => {
|
|
682
|
+
const { ctx } = createCanvas();
|
|
683
|
+
const m = ctx.measureText('');
|
|
684
|
+
expect(m.width).toBe(0);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
await it('should fillText produce visible pixels', async () => {
|
|
688
|
+
const { ctx } = createCanvas(100, 30);
|
|
689
|
+
ctx.fillStyle = '#000000';
|
|
690
|
+
ctx.font = '20px sans-serif';
|
|
691
|
+
ctx.fillText('X', 10, 20);
|
|
692
|
+
// Check around the area where the text should be
|
|
693
|
+
const imageData = ctx.getImageData(0, 0, 100, 30);
|
|
694
|
+
let nonZeroPixels = 0;
|
|
695
|
+
for (let i = 3; i < imageData.data.length; i += 4) {
|
|
696
|
+
if (imageData.data[i] > 0) nonZeroPixels++;
|
|
697
|
+
}
|
|
698
|
+
expect(nonZeroPixels).toBeGreaterThan(0);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await it('should set and get font', async () => {
|
|
702
|
+
const { ctx } = createCanvas();
|
|
703
|
+
ctx.font = 'bold 20px Arial';
|
|
704
|
+
expect(ctx.font).toBe('bold 20px Arial');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await it('should set and get textAlign', async () => {
|
|
708
|
+
const { ctx } = createCanvas();
|
|
709
|
+
ctx.textAlign = 'center';
|
|
710
|
+
expect(ctx.textAlign).toBe('center');
|
|
711
|
+
ctx.textAlign = 'right';
|
|
712
|
+
expect(ctx.textAlign).toBe('right');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
await it('should set and get textBaseline', async () => {
|
|
716
|
+
const { ctx } = createCanvas();
|
|
717
|
+
ctx.textBaseline = 'top';
|
|
718
|
+
expect(ctx.textBaseline).toBe('top');
|
|
719
|
+
ctx.textBaseline = 'middle';
|
|
720
|
+
expect(ctx.textBaseline).toBe('middle');
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ---- Hit testing ----
|
|
725
|
+
|
|
726
|
+
await describe('Hit testing', async () => {
|
|
727
|
+
await it('should detect point in rectangular path', async () => {
|
|
728
|
+
const { ctx } = createCanvas(20, 20);
|
|
729
|
+
ctx.beginPath();
|
|
730
|
+
ctx.rect(5, 5, 10, 10);
|
|
731
|
+
expect(ctx.isPointInPath(10, 10)).toBe(true);
|
|
732
|
+
expect(ctx.isPointInPath(0, 0)).toBe(false);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await it('should detect point in stroke', async () => {
|
|
736
|
+
const { ctx } = createCanvas(20, 20);
|
|
737
|
+
ctx.lineWidth = 4;
|
|
738
|
+
ctx.beginPath();
|
|
739
|
+
ctx.moveTo(0, 10);
|
|
740
|
+
ctx.lineTo(20, 10);
|
|
741
|
+
expect(ctx.isPointInStroke(10, 10)).toBe(true);
|
|
742
|
+
expect(ctx.isPointInStroke(10, 0)).toBe(false);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
await it('should detect point in Path2D', async () => {
|
|
746
|
+
const { ctx } = createCanvas(20, 20);
|
|
747
|
+
const path = new Path2D();
|
|
748
|
+
path.rect(5, 5, 10, 10);
|
|
749
|
+
expect(ctx.isPointInPath(path, 10, 10)).toBe(true);
|
|
750
|
+
expect(ctx.isPointInPath(path, 0, 0)).toBe(false);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// ---- Shadow properties ----
|
|
755
|
+
|
|
756
|
+
await describe('Shadow properties', async () => {
|
|
757
|
+
await it('should set and get shadow properties', async () => {
|
|
758
|
+
const { ctx } = createCanvas();
|
|
759
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
|
760
|
+
expect(ctx.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
|
|
761
|
+
ctx.shadowBlur = 10;
|
|
762
|
+
expect(ctx.shadowBlur).toBe(10);
|
|
763
|
+
ctx.shadowOffsetX = 5;
|
|
764
|
+
expect(ctx.shadowOffsetX).toBe(5);
|
|
765
|
+
ctx.shadowOffsetY = 3;
|
|
766
|
+
expect(ctx.shadowOffsetY).toBe(3);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
await it('should reject negative shadowBlur', async () => {
|
|
770
|
+
const { ctx } = createCanvas();
|
|
771
|
+
ctx.shadowBlur = 5;
|
|
772
|
+
ctx.shadowBlur = -1;
|
|
773
|
+
expect(ctx.shadowBlur).toBe(5);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// ---- Ellipse and roundRect ----
|
|
778
|
+
|
|
779
|
+
await describe('Ellipse and roundRect', async () => {
|
|
780
|
+
await it('should fill an ellipse', async () => {
|
|
781
|
+
const { ctx } = createCanvas(30, 20);
|
|
782
|
+
ctx.fillStyle = '#ff0000';
|
|
783
|
+
ctx.beginPath();
|
|
784
|
+
ctx.ellipse(15, 10, 12, 8, 0, 0, Math.PI * 2);
|
|
785
|
+
ctx.fill();
|
|
786
|
+
const [r, _g, _b, a] = getPixel(ctx, 15, 10);
|
|
787
|
+
expect(r).toBe(255);
|
|
788
|
+
expect(a).toBe(255);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
await it('should throw on negative ellipse radii', async () => {
|
|
792
|
+
const { ctx } = createCanvas();
|
|
793
|
+
let threw = false;
|
|
794
|
+
try {
|
|
795
|
+
ctx.ellipse(10, 10, -5, 5, 0, 0, Math.PI * 2);
|
|
796
|
+
} catch {
|
|
797
|
+
threw = true;
|
|
798
|
+
}
|
|
799
|
+
expect(threw).toBe(true);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
await it('should fill a roundRect', async () => {
|
|
803
|
+
const { ctx } = createCanvas(30, 30);
|
|
804
|
+
ctx.fillStyle = '#0000ff';
|
|
805
|
+
ctx.beginPath();
|
|
806
|
+
ctx.roundRect(2, 2, 26, 26, 5);
|
|
807
|
+
ctx.fill();
|
|
808
|
+
const [_r, _g, b, a] = getPixel(ctx, 15, 15);
|
|
809
|
+
expect(b).toBe(255);
|
|
810
|
+
expect(a).toBe(255);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ---- imageSmoothingEnabled ----
|
|
815
|
+
|
|
816
|
+
await describe('imageSmoothingEnabled', async () => {
|
|
817
|
+
await it('should default to true', async () => {
|
|
818
|
+
const { ctx } = createCanvas();
|
|
819
|
+
expect(ctx.imageSmoothingEnabled).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
await it('should be settable', async () => {
|
|
823
|
+
const { ctx } = createCanvas();
|
|
824
|
+
ctx.imageSmoothingEnabled = false;
|
|
825
|
+
expect(ctx.imageSmoothingEnabled).toBe(false);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
};
|