@gjsify/canvas2d-core 0.4.0 → 0.4.3
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/package.json +50 -46
- package/src/cairo-types.ts +0 -44
- package/src/cairo-utils.ts +0 -254
- package/src/canvas-clearing.spec.ts +0 -126
- package/src/canvas-color.spec.ts +0 -113
- package/src/canvas-composite.spec.ts +0 -114
- package/src/canvas-drawimage.spec.ts +0 -334
- package/src/canvas-gradient.ts +0 -36
- package/src/canvas-imagedata.spec.ts +0 -150
- package/src/canvas-path.ts +0 -131
- package/src/canvas-pattern.ts +0 -84
- package/src/canvas-rendering-context-2d.ts +0 -1208
- package/src/canvas-state.spec.ts +0 -245
- package/src/canvas-state.ts +0 -77
- package/src/canvas-text.spec.ts +0 -241
- package/src/canvas-transform.spec.ts +0 -211
- package/src/color.ts +0 -177
- package/src/dom-types.ts +0 -96
- package/src/image-data.ts +0 -34
- package/src/index.ts +0 -14
- package/src/test.browser.mts +0 -614
- package/src/test.mts +0 -22
- package/tmp/.tsbuildinfo +0 -1
- package/tsconfig.json +0 -47
package/src/canvas-state.spec.ts
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
// Canvas 2D state stack tests — save/restore must round-trip every
|
|
2
|
-
// state field (fillStyle, strokeStyle, globalAlpha, lineWidth, transform,
|
|
3
|
-
// text properties, clip region, imageSmoothingEnabled).
|
|
4
|
-
//
|
|
5
|
-
// Ported from refs/wpt/html/canvas/element/the-canvas-state/
|
|
6
|
-
// 2d.state.saverestore.{fillStyle,strokeStyle,globalAlpha,
|
|
7
|
-
// globalCompositeOperation,lineWidth,lineCap,lineJoin,miterLimit,
|
|
8
|
-
// transformation,path,underflow,clip,stackdepth}.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 = 50, height = 50): CanvasRenderingContext2D {
|
|
16
|
-
const canvas = { width, height };
|
|
17
|
-
return new CanvasRenderingContext2D(canvas);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function nearlyEqual(a: number, b: number, eps = 1e-9): boolean {
|
|
21
|
-
return Math.abs(a - b) < eps;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export default async () => {
|
|
25
|
-
await describe('CanvasRenderingContext2D — state save/restore', async () => {
|
|
26
|
-
|
|
27
|
-
await describe('style properties', async () => {
|
|
28
|
-
await it('save/restore round-trips fillStyle string colors', async () => {
|
|
29
|
-
const ctx = makeCtx();
|
|
30
|
-
ctx.fillStyle = '#ff0000';
|
|
31
|
-
ctx.save();
|
|
32
|
-
ctx.fillStyle = '#00ff00';
|
|
33
|
-
expect(ctx.fillStyle).toBe('#00ff00');
|
|
34
|
-
ctx.restore();
|
|
35
|
-
expect(ctx.fillStyle).toBe('#ff0000');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
await it('save/restore round-trips strokeStyle', async () => {
|
|
39
|
-
const ctx = makeCtx();
|
|
40
|
-
ctx.strokeStyle = '#112233';
|
|
41
|
-
ctx.save();
|
|
42
|
-
ctx.strokeStyle = '#445566';
|
|
43
|
-
ctx.restore();
|
|
44
|
-
expect(ctx.strokeStyle).toBe('#112233');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
await it('save/restore round-trips globalAlpha', async () => {
|
|
48
|
-
const ctx = makeCtx();
|
|
49
|
-
ctx.globalAlpha = 0.3;
|
|
50
|
-
ctx.save();
|
|
51
|
-
ctx.globalAlpha = 0.9;
|
|
52
|
-
ctx.restore();
|
|
53
|
-
expect(nearlyEqual(ctx.globalAlpha, 0.3)).toBe(true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
await it('save/restore round-trips globalCompositeOperation', async () => {
|
|
57
|
-
const ctx = makeCtx();
|
|
58
|
-
ctx.globalCompositeOperation = 'source-over';
|
|
59
|
-
ctx.save();
|
|
60
|
-
ctx.globalCompositeOperation = 'destination-over';
|
|
61
|
-
ctx.restore();
|
|
62
|
-
expect(ctx.globalCompositeOperation).toBe('source-over');
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
await describe('line properties', async () => {
|
|
67
|
-
await it('save/restore round-trips lineWidth', async () => {
|
|
68
|
-
const ctx = makeCtx();
|
|
69
|
-
ctx.lineWidth = 3;
|
|
70
|
-
ctx.save();
|
|
71
|
-
ctx.lineWidth = 10;
|
|
72
|
-
ctx.restore();
|
|
73
|
-
expect(ctx.lineWidth).toBe(3);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
await it('save/restore round-trips lineCap and lineJoin', async () => {
|
|
77
|
-
const ctx = makeCtx();
|
|
78
|
-
ctx.lineCap = 'round';
|
|
79
|
-
ctx.lineJoin = 'bevel';
|
|
80
|
-
ctx.save();
|
|
81
|
-
ctx.lineCap = 'square';
|
|
82
|
-
ctx.lineJoin = 'miter';
|
|
83
|
-
ctx.restore();
|
|
84
|
-
expect(ctx.lineCap).toBe('round');
|
|
85
|
-
expect(ctx.lineJoin).toBe('bevel');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
await it('save/restore round-trips miterLimit', async () => {
|
|
89
|
-
const ctx = makeCtx();
|
|
90
|
-
ctx.miterLimit = 5;
|
|
91
|
-
ctx.save();
|
|
92
|
-
ctx.miterLimit = 20;
|
|
93
|
-
ctx.restore();
|
|
94
|
-
expect(ctx.miterLimit).toBe(5);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
await it('save/restore round-trips lineDash and lineDashOffset', async () => {
|
|
98
|
-
const ctx = makeCtx();
|
|
99
|
-
ctx.setLineDash([5, 2]);
|
|
100
|
-
ctx.lineDashOffset = 3;
|
|
101
|
-
ctx.save();
|
|
102
|
-
ctx.setLineDash([10, 10]);
|
|
103
|
-
ctx.lineDashOffset = 7;
|
|
104
|
-
ctx.restore();
|
|
105
|
-
const restored = ctx.getLineDash();
|
|
106
|
-
expect(restored.length).toBe(2);
|
|
107
|
-
expect(restored[0]).toBe(5);
|
|
108
|
-
expect(restored[1]).toBe(2);
|
|
109
|
-
expect(ctx.lineDashOffset).toBe(3);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
await describe('text properties', async () => {
|
|
114
|
-
await it('save/restore round-trips font, textAlign, textBaseline', async () => {
|
|
115
|
-
const ctx = makeCtx();
|
|
116
|
-
ctx.font = 'bold 20px sans-serif';
|
|
117
|
-
ctx.textAlign = 'center';
|
|
118
|
-
ctx.textBaseline = 'middle';
|
|
119
|
-
ctx.save();
|
|
120
|
-
ctx.font = '10px serif';
|
|
121
|
-
ctx.textAlign = 'left';
|
|
122
|
-
ctx.textBaseline = 'top';
|
|
123
|
-
ctx.restore();
|
|
124
|
-
expect(ctx.font).toBe('bold 20px sans-serif');
|
|
125
|
-
expect(ctx.textAlign).toBe('center');
|
|
126
|
-
expect(ctx.textBaseline).toBe('middle');
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await describe('shadow properties', async () => {
|
|
131
|
-
await it('save/restore round-trips shadow state', async () => {
|
|
132
|
-
const ctx = makeCtx();
|
|
133
|
-
ctx.shadowColor = '#abcdef';
|
|
134
|
-
ctx.shadowBlur = 5;
|
|
135
|
-
ctx.shadowOffsetX = 3;
|
|
136
|
-
ctx.shadowOffsetY = 7;
|
|
137
|
-
ctx.save();
|
|
138
|
-
ctx.shadowColor = '#000000';
|
|
139
|
-
ctx.shadowBlur = 0;
|
|
140
|
-
ctx.shadowOffsetX = 0;
|
|
141
|
-
ctx.shadowOffsetY = 0;
|
|
142
|
-
ctx.restore();
|
|
143
|
-
expect(ctx.shadowColor).toBe('#abcdef');
|
|
144
|
-
expect(ctx.shadowBlur).toBe(5);
|
|
145
|
-
expect(ctx.shadowOffsetX).toBe(3);
|
|
146
|
-
expect(ctx.shadowOffsetY).toBe(7);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
await describe('imageSmoothing properties — Excalibur pixel-art regression', async () => {
|
|
151
|
-
// Locks the Phase D imageSmoothingEnabled=false fix. If any
|
|
152
|
-
// future refactor drops imageSmoothingEnabled from cloneState,
|
|
153
|
-
// save/restore would silently reset it to the default (true)
|
|
154
|
-
// and pixel-art sprites would render blurry again.
|
|
155
|
-
await it('save/restore round-trips imageSmoothingEnabled', async () => {
|
|
156
|
-
const ctx = makeCtx();
|
|
157
|
-
ctx.imageSmoothingEnabled = false;
|
|
158
|
-
ctx.save();
|
|
159
|
-
ctx.imageSmoothingEnabled = true;
|
|
160
|
-
ctx.restore();
|
|
161
|
-
expect(ctx.imageSmoothingEnabled).toBe(false);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
await it('save/restore round-trips imageSmoothingQuality', async () => {
|
|
165
|
-
const ctx = makeCtx();
|
|
166
|
-
ctx.imageSmoothingQuality = 'high';
|
|
167
|
-
ctx.save();
|
|
168
|
-
ctx.imageSmoothingQuality = 'low';
|
|
169
|
-
ctx.restore();
|
|
170
|
-
expect(ctx.imageSmoothingQuality).toBe('high');
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
await describe('transform matrix', async () => {
|
|
175
|
-
await it('save/restore round-trips translation', async () => {
|
|
176
|
-
const ctx = makeCtx();
|
|
177
|
-
ctx.translate(10, 20);
|
|
178
|
-
ctx.save();
|
|
179
|
-
ctx.translate(50, 50);
|
|
180
|
-
const inner = ctx.getTransform();
|
|
181
|
-
expect(nearlyEqual(inner.e, 60)).toBe(true);
|
|
182
|
-
expect(nearlyEqual(inner.f, 70)).toBe(true);
|
|
183
|
-
ctx.restore();
|
|
184
|
-
const outer = ctx.getTransform();
|
|
185
|
-
expect(nearlyEqual(outer.e, 10)).toBe(true);
|
|
186
|
-
expect(nearlyEqual(outer.f, 20)).toBe(true);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
await it('save/restore round-trips scale', async () => {
|
|
190
|
-
const ctx = makeCtx();
|
|
191
|
-
ctx.scale(2, 3);
|
|
192
|
-
ctx.save();
|
|
193
|
-
ctx.scale(5, 5);
|
|
194
|
-
ctx.restore();
|
|
195
|
-
const m = ctx.getTransform();
|
|
196
|
-
expect(nearlyEqual(m.a, 2)).toBe(true);
|
|
197
|
-
expect(nearlyEqual(m.d, 3)).toBe(true);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
await describe('nested save/restore stacks', async () => {
|
|
202
|
-
await it('triple save + triple restore returns to default', async () => {
|
|
203
|
-
const ctx = makeCtx();
|
|
204
|
-
ctx.save();
|
|
205
|
-
ctx.fillStyle = '#111111';
|
|
206
|
-
ctx.save();
|
|
207
|
-
ctx.fillStyle = '#222222';
|
|
208
|
-
ctx.save();
|
|
209
|
-
ctx.fillStyle = '#333333';
|
|
210
|
-
ctx.restore();
|
|
211
|
-
expect(ctx.fillStyle).toBe('#222222');
|
|
212
|
-
ctx.restore();
|
|
213
|
-
expect(ctx.fillStyle).toBe('#111111');
|
|
214
|
-
ctx.restore();
|
|
215
|
-
// Default fillStyle per spec is #000000
|
|
216
|
-
expect(ctx.fillStyle).toBe('#000000');
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
await it('restore() on empty stack is a no-op', async () => {
|
|
220
|
-
const ctx = makeCtx();
|
|
221
|
-
// Should not throw — Canvas 2D spec explicitly allows
|
|
222
|
-
// unbalanced restores.
|
|
223
|
-
expect(() => {
|
|
224
|
-
ctx.restore();
|
|
225
|
-
ctx.restore();
|
|
226
|
-
ctx.restore();
|
|
227
|
-
}).not.toThrow();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
await it('deep stack (50 saves) restores correctly', async () => {
|
|
231
|
-
const ctx = makeCtx();
|
|
232
|
-
const fills: string[] = [];
|
|
233
|
-
for (let i = 0; i < 50; i++) {
|
|
234
|
-
ctx.save();
|
|
235
|
-
ctx.fillStyle = '#' + (i * 7).toString(16).padStart(6, '0');
|
|
236
|
-
fills.push(ctx.fillStyle as string);
|
|
237
|
-
}
|
|
238
|
-
for (let i = 49; i >= 0; i--) {
|
|
239
|
-
expect(ctx.fillStyle).toBe(fills[i]);
|
|
240
|
-
ctx.restore();
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
};
|
package/src/canvas-state.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// Drawing state for Canvas 2D context save()/restore() stack.
|
|
2
|
-
// Each save() pushes a clone of the current state; restore() pops it.
|
|
3
|
-
|
|
4
|
-
import type { RGBA } from './color.js';
|
|
5
|
-
import { BLACK } from './color.js';
|
|
6
|
-
|
|
7
|
-
export interface CanvasState {
|
|
8
|
-
// Fill & stroke
|
|
9
|
-
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
10
|
-
fillColor: RGBA;
|
|
11
|
-
strokeStyle: string | CanvasGradient | CanvasPattern;
|
|
12
|
-
strokeColor: RGBA;
|
|
13
|
-
|
|
14
|
-
// Line properties
|
|
15
|
-
lineWidth: number;
|
|
16
|
-
lineCap: CanvasLineCap;
|
|
17
|
-
lineJoin: CanvasLineJoin;
|
|
18
|
-
miterLimit: number;
|
|
19
|
-
lineDash: number[];
|
|
20
|
-
lineDashOffset: number;
|
|
21
|
-
|
|
22
|
-
// Compositing
|
|
23
|
-
globalAlpha: number;
|
|
24
|
-
globalCompositeOperation: GlobalCompositeOperation;
|
|
25
|
-
|
|
26
|
-
// Shadows (Phase 5 — tracked in state for save/restore correctness)
|
|
27
|
-
shadowColor: string;
|
|
28
|
-
shadowBlur: number;
|
|
29
|
-
shadowOffsetX: number;
|
|
30
|
-
shadowOffsetY: number;
|
|
31
|
-
|
|
32
|
-
// Text (Phase 4)
|
|
33
|
-
font: string;
|
|
34
|
-
textAlign: CanvasTextAlign;
|
|
35
|
-
textBaseline: CanvasTextBaseline;
|
|
36
|
-
direction: CanvasDirection;
|
|
37
|
-
|
|
38
|
-
// Image smoothing
|
|
39
|
-
imageSmoothingEnabled: boolean;
|
|
40
|
-
imageSmoothingQuality: ImageSmoothingQuality;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function createDefaultState(): CanvasState {
|
|
44
|
-
return {
|
|
45
|
-
fillStyle: '#000000',
|
|
46
|
-
fillColor: { ...BLACK },
|
|
47
|
-
strokeStyle: '#000000',
|
|
48
|
-
strokeColor: { ...BLACK },
|
|
49
|
-
lineWidth: 1,
|
|
50
|
-
lineCap: 'butt',
|
|
51
|
-
lineJoin: 'miter',
|
|
52
|
-
miterLimit: 10,
|
|
53
|
-
lineDash: [],
|
|
54
|
-
lineDashOffset: 0,
|
|
55
|
-
globalAlpha: 1,
|
|
56
|
-
globalCompositeOperation: 'source-over',
|
|
57
|
-
shadowColor: 'rgba(0, 0, 0, 0)',
|
|
58
|
-
shadowBlur: 0,
|
|
59
|
-
shadowOffsetX: 0,
|
|
60
|
-
shadowOffsetY: 0,
|
|
61
|
-
font: '10px sans-serif',
|
|
62
|
-
textAlign: 'start',
|
|
63
|
-
textBaseline: 'alphabetic',
|
|
64
|
-
direction: 'ltr',
|
|
65
|
-
imageSmoothingEnabled: true,
|
|
66
|
-
imageSmoothingQuality: 'low',
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function cloneState(state: CanvasState): CanvasState {
|
|
71
|
-
return {
|
|
72
|
-
...state,
|
|
73
|
-
fillColor: { ...state.fillColor },
|
|
74
|
-
strokeColor: { ...state.strokeColor },
|
|
75
|
-
lineDash: [...state.lineDash],
|
|
76
|
-
};
|
|
77
|
-
}
|
package/src/canvas-text.spec.ts
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
// Canvas 2D text rendering tests — baseline positioning and alignment
|
|
2
|
-
// Reference: refs/wpt/html/canvas/element/text/2d.text.draw.baseline.*.html
|
|
3
|
-
// Ported from W3C WPT canvas text tests (3-Clause BSD, web-platform-tests contributors)
|
|
4
|
-
// Reimplemented for GJS using PangoCairo (no DOM, no font face loading)
|
|
5
|
-
|
|
6
|
-
import { describe, it, expect } from '@gjsify/unit';
|
|
7
|
-
import { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
|
|
8
|
-
|
|
9
|
-
// --- Helpers ---
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Create a minimal canvas mock + context for testing.
|
|
13
|
-
* CanvasRenderingContext2D only needs canvas.width / canvas.height.
|
|
14
|
-
*/
|
|
15
|
-
function makeCtx(width: number, height: number): CanvasRenderingContext2D {
|
|
16
|
-
const canvas = { width, height };
|
|
17
|
-
return new CanvasRenderingContext2D(canvas);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Find the first and last row (y-coordinate) that contains at least one
|
|
22
|
-
* "green" pixel (G > 128, R < 64, B < 64) in the given ImageData.
|
|
23
|
-
* Returns { first: -1, last: -1 } when no green pixel is found.
|
|
24
|
-
*/
|
|
25
|
-
function findGreenRowBounds(data: Uint8ClampedArray, width: number, height: number) {
|
|
26
|
-
let first = -1;
|
|
27
|
-
let last = -1;
|
|
28
|
-
for (let y = 0; y < height; y++) {
|
|
29
|
-
for (let x = 0; x < width; x++) {
|
|
30
|
-
const i = (y * width + x) * 4;
|
|
31
|
-
if (data[i + 1] > 128 && data[i] < 64 && data[i + 2] < 64) {
|
|
32
|
-
if (first === -1) first = y;
|
|
33
|
-
last = y;
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return { first, last };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Draw text on a background-filled canvas and return the bounding rows of the
|
|
43
|
-
* rendered text pixels.
|
|
44
|
-
*
|
|
45
|
-
* @param baseline CSS textBaseline value
|
|
46
|
-
* @param yCoord The y argument passed to fillText
|
|
47
|
-
* @param width Canvas width
|
|
48
|
-
* @param height Canvas height
|
|
49
|
-
*/
|
|
50
|
-
function drawAndMeasure(
|
|
51
|
-
baseline: CanvasTextBaseline,
|
|
52
|
-
yCoord: number,
|
|
53
|
-
width = 300,
|
|
54
|
-
height = 100,
|
|
55
|
-
) {
|
|
56
|
-
const ctx = makeCtx(width, height);
|
|
57
|
-
|
|
58
|
-
// Red background
|
|
59
|
-
ctx.fillStyle = '#f00';
|
|
60
|
-
ctx.fillRect(0, 0, width, height);
|
|
61
|
-
|
|
62
|
-
// Green text — use a wide string to guarantee pixels across multiple columns
|
|
63
|
-
ctx.fillStyle = '#0f0';
|
|
64
|
-
ctx.font = '24px sans-serif';
|
|
65
|
-
ctx.textBaseline = baseline;
|
|
66
|
-
ctx.textAlign = 'left';
|
|
67
|
-
ctx.fillText('XXXXXXX', 0, yCoord);
|
|
68
|
-
|
|
69
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
70
|
-
return findGreenRowBounds(imageData.data, width, height);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// --- Tests ---
|
|
74
|
-
|
|
75
|
-
export default async () => {
|
|
76
|
-
|
|
77
|
-
await describe('CanvasRenderingContext2D.fillText — textBaseline positioning', async () => {
|
|
78
|
-
|
|
79
|
-
// textBaseline='top': text top edge is at the provided y coordinate.
|
|
80
|
-
// With y=5, the first green row must appear near y=5 (within ±8px).
|
|
81
|
-
await it("textBaseline='top': text starts near y", async () => {
|
|
82
|
-
const { first, last } = drawAndMeasure('top', 5);
|
|
83
|
-
expect(first).toBeGreaterThan(-1); // text was rendered
|
|
84
|
-
expect(first).toBeLessThan(15); // top edge close to y=5
|
|
85
|
-
expect(last).toBeGreaterThan(first); // text has height
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// textBaseline='bottom': text bottom edge is at y. With y=95 on a 100px canvas,
|
|
89
|
-
// the last green row must appear near y=95 (within ±8px).
|
|
90
|
-
await it("textBaseline='bottom': text ends near y", async () => {
|
|
91
|
-
const { first, last } = drawAndMeasure('bottom', 95, 300, 100);
|
|
92
|
-
expect(last).toBeGreaterThan(-1); // text was rendered
|
|
93
|
-
expect(last).toBeGreaterThan(85); // bottom edge close to y=95
|
|
94
|
-
expect(first).toBeLessThan(last); // text has height
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// textBaseline='middle': the vertical midpoint of the text is at y.
|
|
98
|
-
// With y=50 on a 100px canvas, text must span across the center:
|
|
99
|
-
// first green row < 50 AND last green row > 50.
|
|
100
|
-
await it("textBaseline='middle': text is centered on y", async () => {
|
|
101
|
-
const { first, last } = drawAndMeasure('middle', 50, 300, 100);
|
|
102
|
-
expect(first).toBeGreaterThan(-1);
|
|
103
|
-
expect(first).toBeLessThan(50); // text extends above center
|
|
104
|
-
expect(last).toBeGreaterThan(50); // text extends below center
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// textBaseline='alphabetic': the baseline (a reference Latin line) is at y.
|
|
108
|
-
// Ascent extends above y, descent extends below.
|
|
109
|
-
// With y=50 on a 100px canvas:
|
|
110
|
-
// - first green row < 50 (ascent above baseline)
|
|
111
|
-
// - last green row > 50 (descent below baseline — use 'g' which has a descender)
|
|
112
|
-
await it("textBaseline='alphabetic': baseline is at y, ascent above, descent below", async () => {
|
|
113
|
-
// Use text with descenders (g, p, q, y) so pixels extend below the baseline.
|
|
114
|
-
const canvas = { width: 300, height: 100 };
|
|
115
|
-
const ctx = new CanvasRenderingContext2D(canvas);
|
|
116
|
-
ctx.fillStyle = '#f00';
|
|
117
|
-
ctx.fillRect(0, 0, 300, 100);
|
|
118
|
-
ctx.fillStyle = '#0f0';
|
|
119
|
-
ctx.font = '24px sans-serif';
|
|
120
|
-
ctx.textBaseline = 'alphabetic';
|
|
121
|
-
ctx.textAlign = 'left';
|
|
122
|
-
ctx.fillText('Xgpqy', 0, 50); // descenders ensure pixels below baseline
|
|
123
|
-
const imageData = ctx.getImageData(0, 0, 300, 100);
|
|
124
|
-
const { first, last } = findGreenRowBounds(imageData.data, 300, 100);
|
|
125
|
-
expect(first).toBeGreaterThan(-1);
|
|
126
|
-
expect(first).toBeLessThan(50); // text extends above y
|
|
127
|
-
expect(last).toBeGreaterThan(50); // descent below y
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// textBaseline='hanging': the hanging baseline is at y (used for Devanagari etc.).
|
|
131
|
-
// Approximates the top of the cap height (~0.8 * ascent below the em-square top).
|
|
132
|
-
// Most of the text should extend BELOW y, with very little above.
|
|
133
|
-
await it("textBaseline='hanging': text mostly extends below y", async () => {
|
|
134
|
-
const { first, last } = drawAndMeasure('hanging', 5, 300, 100);
|
|
135
|
-
expect(first).toBeGreaterThan(-1);
|
|
136
|
-
// Text should start near y=5; because hanging ≈ 0.2*ascent from top,
|
|
137
|
-
// first green row should be within 10px of y=5.
|
|
138
|
-
expect(first).toBeLessThan(20);
|
|
139
|
-
expect(last).toBeGreaterThan(first);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// textBaseline='ideographic': ideographic baseline is at y.
|
|
143
|
-
// Below the alphabetic baseline; the bulk of text is ABOVE y.
|
|
144
|
-
await it("textBaseline='ideographic': most text is above y", async () => {
|
|
145
|
-
const { first, last } = drawAndMeasure('ideographic', 70, 300, 100);
|
|
146
|
-
expect(first).toBeGreaterThan(-1);
|
|
147
|
-
expect(first).toBeLessThan(70); // text extends above y
|
|
148
|
-
// last may be ≥ 70 (descent below ideographic line) or ≤ 70
|
|
149
|
-
expect(last).toBeGreaterThan(first);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// Regression: 'top' at y=0 should NOT be far below y=0.
|
|
153
|
-
// Before fix, yOff = +ascent pushed text ~20px too low (wrong).
|
|
154
|
-
await it("textBaseline='top' regression: first pixel is near y=0, not shifted down by ascent", async () => {
|
|
155
|
-
const { first } = drawAndMeasure('top', 0, 300, 80);
|
|
156
|
-
// Before fix: first ≈ 18+ (shifted by ascent). After fix: first ≈ 0-9
|
|
157
|
-
// (exact value varies with PangoCairo version and font hinting).
|
|
158
|
-
expect(first).toBeLessThan(12);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// Regression: 'alphabetic' at y=40 should put text above AND below y=40.
|
|
162
|
-
// Before fix, yOff=0 put the layout TOP at y=40, so all text was below y=40.
|
|
163
|
-
await it("textBaseline='alphabetic' regression: text extends above y, not only below", async () => {
|
|
164
|
-
const { first } = drawAndMeasure('alphabetic', 40, 300, 80);
|
|
165
|
-
expect(first).toBeLessThan(40); // ascent extends above y=40
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// Regression: 'middle' at y=40 with 24px font → text spans ~28..52
|
|
169
|
-
// Before fix: yOff = ascent - height/2 ≈ +10, so layout top at ~50, text at 50..74
|
|
170
|
-
// → first ≈ 50, which is NOT less than 40.
|
|
171
|
-
await it("textBaseline='middle' regression: text straddles y, not shifted below y", async () => {
|
|
172
|
-
const { first } = drawAndMeasure('middle', 40, 300, 80);
|
|
173
|
-
expect(first).toBeLessThan(40);
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
await describe('CanvasRenderingContext2D.fillText — textAlign positioning', async () => {
|
|
178
|
-
|
|
179
|
-
await it("textAlign='center': text is centered on x", async () => {
|
|
180
|
-
const width = 200;
|
|
181
|
-
const height = 60;
|
|
182
|
-
const ctx = makeCtx(width, height);
|
|
183
|
-
ctx.fillStyle = '#f00';
|
|
184
|
-
ctx.fillRect(0, 0, width, height);
|
|
185
|
-
ctx.fillStyle = '#0f0';
|
|
186
|
-
ctx.font = '20px sans-serif';
|
|
187
|
-
ctx.textBaseline = 'top';
|
|
188
|
-
ctx.textAlign = 'center';
|
|
189
|
-
ctx.fillText('XX', width / 2, 0);
|
|
190
|
-
|
|
191
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
192
|
-
// Find leftmost and rightmost green column
|
|
193
|
-
let leftCol = -1, rightCol = -1;
|
|
194
|
-
for (let x = 0; x < width; x++) {
|
|
195
|
-
for (let y = 0; y < height; y++) {
|
|
196
|
-
const i = (y * width + x) * 4;
|
|
197
|
-
if (imageData.data[i + 1] > 128 && imageData.data[i] < 64 && imageData.data[i + 2] < 64) {
|
|
198
|
-
if (leftCol === -1) leftCol = x;
|
|
199
|
-
rightCol = x;
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
expect(leftCol).toBeGreaterThan(-1);
|
|
205
|
-
// Text should be roughly centered: left and right distances from center should be similar
|
|
206
|
-
const distLeft = width / 2 - leftCol;
|
|
207
|
-
const distRight = rightCol - width / 2;
|
|
208
|
-
// Both sides should have roughly equal extent (within 20px)
|
|
209
|
-
expect(Math.abs(distLeft - distRight)).toBeLessThan(20);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
await it("textAlign='left': text starts at x", async () => {
|
|
213
|
-
const width = 200;
|
|
214
|
-
const height = 60;
|
|
215
|
-
const ctx = makeCtx(width, height);
|
|
216
|
-
ctx.fillStyle = '#f00';
|
|
217
|
-
ctx.fillRect(0, 0, width, height);
|
|
218
|
-
ctx.fillStyle = '#0f0';
|
|
219
|
-
ctx.font = '20px sans-serif';
|
|
220
|
-
ctx.textBaseline = 'top';
|
|
221
|
-
ctx.textAlign = 'left';
|
|
222
|
-
ctx.fillText('XX', 20, 0);
|
|
223
|
-
|
|
224
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
225
|
-
let leftCol = -1;
|
|
226
|
-
outer: for (let x = 0; x < width; x++) {
|
|
227
|
-
for (let y = 0; y < height; y++) {
|
|
228
|
-
const i = (y * width + x) * 4;
|
|
229
|
-
if (imageData.data[i + 1] > 128 && imageData.data[i] < 64 && imageData.data[i + 2] < 64) {
|
|
230
|
-
leftCol = x;
|
|
231
|
-
break outer;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
// leftmost green pixel should be near x=20
|
|
236
|
-
expect(leftCol).toBeGreaterThan(-1);
|
|
237
|
-
expect(leftCol).toBeGreaterThan(14);
|
|
238
|
-
expect(leftCol).toBeLessThan(30);
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
};
|