@gjsify/canvas2d-core 0.1.8 → 0.1.9
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,150 @@
|
|
|
1
|
+
// Canvas 2D ImageData tests — createImageData, getImageData, putImageData
|
|
2
|
+
// round-trip. Verifies pixel extraction from Cairo surfaces and the
|
|
3
|
+
// RGBA byte order expected by consumers.
|
|
4
|
+
//
|
|
5
|
+
// Ported from refs/wpt/html/canvas/element/pixel-manipulation/
|
|
6
|
+
// 2d.imageData.{create1.basic,create2.{basic,initial},get.{order.rgb,
|
|
7
|
+
// source.negative,source.outside},put.basic.rgba,put.alpha,put.dirty*}.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 = 20, height = 20): CanvasRenderingContext2D {
|
|
15
|
+
const canvas = { width, height };
|
|
16
|
+
return new CanvasRenderingContext2D(canvas);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default async () => {
|
|
20
|
+
await describe('CanvasRenderingContext2D — ImageData', async () => {
|
|
21
|
+
|
|
22
|
+
await describe('createImageData', async () => {
|
|
23
|
+
await it('createImageData(w, h) returns an RGBA Uint8ClampedArray of w*h*4 bytes', async () => {
|
|
24
|
+
const ctx = makeCtx();
|
|
25
|
+
const img = ctx.createImageData(10, 5);
|
|
26
|
+
expect(img.width).toBe(10);
|
|
27
|
+
expect(img.height).toBe(5);
|
|
28
|
+
expect(img.data.length).toBe(10 * 5 * 4);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await it('createImageData data is initialized to transparent black', async () => {
|
|
32
|
+
const ctx = makeCtx();
|
|
33
|
+
const img = ctx.createImageData(4, 4);
|
|
34
|
+
for (let i = 0; i < img.data.length; i++) {
|
|
35
|
+
expect(img.data[i]).toBe(0);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await it('createImageData(imageData) returns a clone with matching dimensions', async () => {
|
|
40
|
+
const ctx = makeCtx();
|
|
41
|
+
const src = ctx.createImageData(8, 3);
|
|
42
|
+
const clone = ctx.createImageData(src);
|
|
43
|
+
expect(clone.width).toBe(8);
|
|
44
|
+
expect(clone.height).toBe(3);
|
|
45
|
+
expect(clone.data.length).toBe(8 * 3 * 4);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await describe('getImageData', async () => {
|
|
50
|
+
await it('returns correct RGBA for a filled region', async () => {
|
|
51
|
+
const ctx = makeCtx(10, 10);
|
|
52
|
+
ctx.fillStyle = 'rgb(200, 100, 50)';
|
|
53
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
54
|
+
const data = ctx.getImageData(5, 5, 1, 1).data;
|
|
55
|
+
expect(data[0]).toBe(200);
|
|
56
|
+
expect(data[1]).toBe(100);
|
|
57
|
+
expect(data[2]).toBe(50);
|
|
58
|
+
expect(data[3]).toBe(255);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await it('returns a full RGBA grid for a multi-pixel region', async () => {
|
|
62
|
+
const ctx = makeCtx(4, 4);
|
|
63
|
+
ctx.fillStyle = 'rgb(10, 20, 30)';
|
|
64
|
+
ctx.fillRect(0, 0, 4, 4);
|
|
65
|
+
const img = ctx.getImageData(0, 0, 4, 4);
|
|
66
|
+
expect(img.width).toBe(4);
|
|
67
|
+
expect(img.height).toBe(4);
|
|
68
|
+
expect(img.data.length).toBe(4 * 4 * 4);
|
|
69
|
+
// All 16 pixels should be (10, 20, 30, 255)
|
|
70
|
+
for (let i = 0; i < 16; i++) {
|
|
71
|
+
expect(img.data[i * 4 + 0]).toBe(10);
|
|
72
|
+
expect(img.data[i * 4 + 1]).toBe(20);
|
|
73
|
+
expect(img.data[i * 4 + 2]).toBe(30);
|
|
74
|
+
expect(img.data[i * 4 + 3]).toBe(255);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await it('preserves byte order across fillStyle color channels', async () => {
|
|
79
|
+
const ctx = makeCtx(3, 1);
|
|
80
|
+
ctx.fillStyle = 'rgb(255, 0, 0)';
|
|
81
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
82
|
+
ctx.fillStyle = 'rgb(0, 255, 0)';
|
|
83
|
+
ctx.fillRect(1, 0, 1, 1);
|
|
84
|
+
ctx.fillStyle = 'rgb(0, 0, 255)';
|
|
85
|
+
ctx.fillRect(2, 0, 1, 1);
|
|
86
|
+
const data = ctx.getImageData(0, 0, 3, 1).data;
|
|
87
|
+
// Pixel 0: red
|
|
88
|
+
expect(data[0]).toBe(255);
|
|
89
|
+
expect(data[1]).toBe(0);
|
|
90
|
+
expect(data[2]).toBe(0);
|
|
91
|
+
// Pixel 1: green
|
|
92
|
+
expect(data[4]).toBe(0);
|
|
93
|
+
expect(data[5]).toBe(255);
|
|
94
|
+
expect(data[6]).toBe(0);
|
|
95
|
+
// Pixel 2: blue
|
|
96
|
+
expect(data[8]).toBe(0);
|
|
97
|
+
expect(data[9]).toBe(0);
|
|
98
|
+
expect(data[10]).toBe(255);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await describe('putImageData', async () => {
|
|
103
|
+
await it('roundtrips get → put → get unchanged', async () => {
|
|
104
|
+
const ctx = makeCtx(10, 10);
|
|
105
|
+
ctx.fillStyle = 'rgb(77, 88, 99)';
|
|
106
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
107
|
+
const first = ctx.getImageData(0, 0, 10, 10);
|
|
108
|
+
ctx.clearRect(0, 0, 10, 10);
|
|
109
|
+
ctx.putImageData(first, 0, 0);
|
|
110
|
+
const second = ctx.getImageData(5, 5, 1, 1).data;
|
|
111
|
+
expect(second[0]).toBe(77);
|
|
112
|
+
expect(second[1]).toBe(88);
|
|
113
|
+
expect(second[2]).toBe(99);
|
|
114
|
+
expect(second[3]).toBe(255);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await it('putImageData ignores globalAlpha (spec)', async () => {
|
|
118
|
+
const ctx = makeCtx(10, 10);
|
|
119
|
+
ctx.fillStyle = 'rgb(100, 100, 100)';
|
|
120
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
121
|
+
const src = ctx.getImageData(0, 0, 10, 10);
|
|
122
|
+
ctx.clearRect(0, 0, 10, 10);
|
|
123
|
+
ctx.globalAlpha = 0.1;
|
|
124
|
+
ctx.putImageData(src, 0, 0);
|
|
125
|
+
// putImageData writes raw pixels — globalAlpha has no effect.
|
|
126
|
+
const after = ctx.getImageData(5, 5, 1, 1).data;
|
|
127
|
+
expect(after[0]).toBe(100);
|
|
128
|
+
expect(after[3]).toBe(255);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await it('putImageData ignores globalCompositeOperation (spec: always SOURCE)', async () => {
|
|
132
|
+
const ctx = makeCtx(10, 10);
|
|
133
|
+
ctx.fillStyle = 'rgb(0, 255, 0)';
|
|
134
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
135
|
+
const src = ctx.getImageData(0, 0, 10, 10);
|
|
136
|
+
// Re-fill the canvas with red, then putImageData the green data
|
|
137
|
+
// under a non-default composite.
|
|
138
|
+
ctx.fillStyle = 'rgb(255, 0, 0)';
|
|
139
|
+
ctx.fillRect(0, 0, 10, 10);
|
|
140
|
+
ctx.globalCompositeOperation = 'destination-over';
|
|
141
|
+
ctx.putImageData(src, 0, 0);
|
|
142
|
+
// Spec: putImageData uses SOURCE → green replaces red.
|
|
143
|
+
const data = ctx.getImageData(5, 5, 1, 1).data;
|
|
144
|
+
expect(data[0]).toBe(0);
|
|
145
|
+
expect(data[1]).toBe(255);
|
|
146
|
+
expect(data[2]).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
};
|
|
@@ -89,6 +89,7 @@ export class CanvasRenderingContext2D {
|
|
|
89
89
|
this._ctx.setSource(style._getCairoPattern());
|
|
90
90
|
} else if (style instanceof OurCanvasPattern) {
|
|
91
91
|
this._ctx.setSource(style._getCairoPattern());
|
|
92
|
+
this._applyPatternFilter();
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -103,6 +104,28 @@ export class CanvasRenderingContext2D {
|
|
|
103
104
|
this._ctx.setSource(style._getCairoPattern());
|
|
104
105
|
} else if (style instanceof OurCanvasPattern) {
|
|
105
106
|
this._ctx.setSource(style._getCairoPattern());
|
|
107
|
+
this._applyPatternFilter();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Apply the current imageSmoothingEnabled + imageSmoothingQuality state
|
|
113
|
+
* to the currently installed Cairo source pattern. Per Canvas 2D spec,
|
|
114
|
+
* the filter is read from the context at *draw* time, not at pattern
|
|
115
|
+
* creation — so we re-apply it on every fill/stroke.
|
|
116
|
+
*/
|
|
117
|
+
private _applyPatternFilter(): void {
|
|
118
|
+
const pat = (this._ctx as any).getSource?.();
|
|
119
|
+
if (pat && typeof pat.setFilter === 'function') {
|
|
120
|
+
let filter: number;
|
|
121
|
+
if (!this._state.imageSmoothingEnabled) {
|
|
122
|
+
filter = Cairo.Filter.NEAREST as unknown as number;
|
|
123
|
+
} else if (this._state.imageSmoothingQuality === 'high') {
|
|
124
|
+
filter = Cairo.Filter.BEST as unknown as number;
|
|
125
|
+
} else {
|
|
126
|
+
filter = Cairo.Filter.BILINEAR as unknown as number;
|
|
127
|
+
}
|
|
128
|
+
pat.setFilter(filter);
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
|
|
@@ -138,43 +161,47 @@ export class CanvasRenderingContext2D {
|
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
164
|
+
* Convert a distance from device pixels to Cairo user space by inverting
|
|
165
|
+
* the linear part of the current CTM (translation doesn't affect distances).
|
|
166
|
+
*
|
|
167
|
+
* Canvas 2D spec: shadowOffsetX/Y are in CSS pixels and are NOT scaled by
|
|
168
|
+
* the current transform. This helper converts them to user-space offsets so
|
|
169
|
+
* that `ctx.moveTo(x + sdx, y + sdy)` produces the correct pixel offset
|
|
170
|
+
* regardless of any ctx.scale() or ctx.rotate() in effect.
|
|
144
171
|
*/
|
|
145
|
-
private
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
172
|
+
private _deviceToUserDistance(dx: number, dy: number): [number, number] {
|
|
173
|
+
const origin = (this._ctx as any).userToDevice(0, 0);
|
|
174
|
+
const xAxis = (this._ctx as any).userToDevice(1, 0);
|
|
175
|
+
const yAxis = (this._ctx as any).userToDevice(0, 1);
|
|
176
|
+
const a = (xAxis[0] ?? 0) - (origin[0] ?? 0);
|
|
177
|
+
const b = (xAxis[1] ?? 0) - (origin[1] ?? 0);
|
|
178
|
+
const c = (yAxis[0] ?? 0) - (origin[0] ?? 0);
|
|
179
|
+
const d = (yAxis[1] ?? 0) - (origin[1] ?? 0);
|
|
180
|
+
const det = a * d - b * c;
|
|
181
|
+
if (Math.abs(det) < 1e-10) return [dx, dy]; // degenerate transform — no conversion
|
|
182
|
+
return [
|
|
183
|
+
( d * dx - c * dy) / det,
|
|
184
|
+
(-b * dx + a * dy) / det,
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Shadow rendering is intentionally a no-op.
|
|
190
|
+
*
|
|
191
|
+
* Proper Canvas 2D shadows require a Gaussian blur pass on an isolated
|
|
192
|
+
* temporary surface, which cannot be emulated reliably without a full
|
|
193
|
+
* Path2D replay or pixel-level manipulation. The previous implementation
|
|
194
|
+
* attempted to use a temp surface but never replayed the path onto it
|
|
195
|
+
* (because `drawOp` closes over the main context), leaving the shadow
|
|
196
|
+
* surface empty while still leaking memory.
|
|
197
|
+
*
|
|
198
|
+
* Excalibur and most 2D game engines bake glow/outline effects into
|
|
199
|
+
* sprites rather than relying on canvas shadows, so this no-op does not
|
|
200
|
+
* affect the showcase. A correct implementation is tracked as a
|
|
201
|
+
* separate Canvas 2D Phase-5 enhancement.
|
|
202
|
+
*/
|
|
203
|
+
private _renderShadow(_drawOp: () => void): void {
|
|
204
|
+
// Intentionally empty. See the doc-comment above.
|
|
178
205
|
}
|
|
179
206
|
|
|
180
207
|
// ---- State ----
|
|
@@ -219,11 +246,29 @@ export class CanvasRenderingContext2D {
|
|
|
219
246
|
*/
|
|
220
247
|
transform(a: number, b: number, c: number, d: number, e: number, f: number): void {
|
|
221
248
|
this._ensureSurface();
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
// Guard against NaN / undefined / Infinity — Cairo will hard-crash
|
|
250
|
+
// on invalid matrix values.
|
|
251
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(c) ||
|
|
252
|
+
!Number.isFinite(d) || !Number.isFinite(e) || !Number.isFinite(f)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Cairo.Context in GJS does NOT expose a generic `transform(matrix)` /
|
|
256
|
+
// `setMatrix()` call — only `translate()`, `rotate()`, `scale()` and
|
|
257
|
+
// `identityMatrix()`. So we decompose the affine 2D matrix
|
|
258
|
+
// [a c e]
|
|
259
|
+
// [b d f]
|
|
260
|
+
// [0 0 1]
|
|
261
|
+
// into translate + rotate + scale (ignoring shear, which Excalibur /
|
|
262
|
+
// three.js 2D users don't rely on). Shear would require a combined
|
|
263
|
+
// matrix multiply, which isn't available in this binding.
|
|
264
|
+
const tx = e;
|
|
265
|
+
const ty = f;
|
|
266
|
+
const sx = Math.hypot(a, b);
|
|
267
|
+
const sy = Math.hypot(c, d);
|
|
268
|
+
const rotation = Math.atan2(b, a);
|
|
269
|
+
this._ctx.translate(tx, ty);
|
|
270
|
+
if (rotation !== 0) this._ctx.rotate(rotation);
|
|
271
|
+
if (sx !== 1 || sy !== 1) this._ctx.scale(sx, sy);
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
/**
|
|
@@ -253,27 +298,35 @@ export class CanvasRenderingContext2D {
|
|
|
253
298
|
* Return the current transformation matrix as a DOMMatrix-like object.
|
|
254
299
|
*/
|
|
255
300
|
getTransform(): DOMMatrix {
|
|
256
|
-
// Cairo doesn't expose getMatrix
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return {
|
|
301
|
+
// Cairo.Context in GJS doesn't expose `getMatrix()`, but it does
|
|
302
|
+
// expose `userToDevice(x, y)`. We reconstruct the current affine
|
|
303
|
+
// matrix [a,b,c,d,e,f] by transforming three reference points:
|
|
304
|
+
// userToDevice(0, 0) = (e, f) — translation
|
|
305
|
+
// userToDevice(1, 0) = (a + e, b + f) — first basis vector
|
|
306
|
+
// userToDevice(0, 1) = (c + e, d + f) — second basis vector
|
|
307
|
+
const origin = (this._ctx as any).userToDevice(0, 0);
|
|
308
|
+
const xAxis = (this._ctx as any).userToDevice(1, 0);
|
|
309
|
+
const yAxis = (this._ctx as any).userToDevice(0, 1);
|
|
310
|
+
const e = origin[0] ?? 0;
|
|
311
|
+
const f = origin[1] ?? 0;
|
|
312
|
+
const a = (xAxis[0] ?? 0) - e;
|
|
313
|
+
const b = (xAxis[1] ?? 0) - f;
|
|
314
|
+
const c = (yAxis[0] ?? 0) - e;
|
|
315
|
+
const d = (yAxis[1] ?? 0) - f;
|
|
316
|
+
|
|
317
|
+
const DOMMatrixCtor = (globalThis as any).DOMMatrix;
|
|
318
|
+
if (typeof DOMMatrixCtor === 'function') {
|
|
319
|
+
return new DOMMatrixCtor([a, b, c, d, e, f]);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
a, b, c, d, e, f,
|
|
323
|
+
m11: a, m12: b, m13: 0, m14: 0,
|
|
324
|
+
m21: c, m22: d, m23: 0, m24: 0,
|
|
325
|
+
m31: 0, m32: 0, m33: 1, m34: 0,
|
|
326
|
+
m41: e, m42: f, m43: 0, m44: 1,
|
|
327
|
+
is2D: true,
|
|
328
|
+
isIdentity: (a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0),
|
|
329
|
+
} as any;
|
|
277
330
|
}
|
|
278
331
|
|
|
279
332
|
resetTransform(): void {
|
|
@@ -774,15 +827,61 @@ export class CanvasRenderingContext2D {
|
|
|
774
827
|
dx = a5; dy = a6!; dw = a7!; dh = a8!;
|
|
775
828
|
}
|
|
776
829
|
|
|
777
|
-
//
|
|
830
|
+
// Spec: drawImage with any zero-width/height source or destination
|
|
831
|
+
// rectangle is a no-op (and MUST NOT throw). Without this guard,
|
|
832
|
+
// `scale(dw / sw, dh / sh)` produces 0 or Infinity which Cairo
|
|
833
|
+
// rejects with "invalid matrix (not invertible)".
|
|
834
|
+
if (sw === 0 || sh === 0 || dw === 0 || dh === 0) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Clip to the destination rectangle so the source pattern is only
|
|
839
|
+
// painted inside it; this lets us use paint() (which fills the
|
|
840
|
+
// entire clip) + paintWithAlpha() for globalAlpha support.
|
|
778
841
|
this._ctx.save();
|
|
842
|
+
this._ctx.rectangle(dx, dy, dw, dh);
|
|
843
|
+
this._ctx.clip();
|
|
844
|
+
|
|
845
|
+
// Scale the source to fill the destination
|
|
779
846
|
this._ctx.translate(dx, dy);
|
|
780
847
|
this._ctx.scale(dw / sw, dh / sh);
|
|
781
848
|
this._ctx.translate(-sx, -sy);
|
|
782
849
|
|
|
783
850
|
Gdk.cairo_set_source_pixbuf(this._ctx as any, pixbuf, 0, 0);
|
|
784
|
-
|
|
785
|
-
|
|
851
|
+
|
|
852
|
+
// Apply Cairo interpolation filter based on imageSmoothingEnabled +
|
|
853
|
+
// imageSmoothingQuality. setSource installs a fresh SurfacePattern and
|
|
854
|
+
// resets any filter to Cairo's default (BILINEAR), so setFilter MUST
|
|
855
|
+
// be called between setSource and paint. Without this, Excalibur's
|
|
856
|
+
// pixel-art mode (imageSmoothingEnabled=false) renders blurry because
|
|
857
|
+
// Cairo uses bilinear interpolation by default.
|
|
858
|
+
//
|
|
859
|
+
// Cairo.Filter values (verified runtime in GJS 1.86):
|
|
860
|
+
// FAST=0 GOOD=1 BEST=2 NEAREST=3 BILINEAR=4 GAUSSIAN=5
|
|
861
|
+
// GIR typings are incomplete for Cairo.SurfacePattern so we go via any.
|
|
862
|
+
const pat = (this._ctx as any).getSource?.();
|
|
863
|
+
if (pat && typeof pat.setFilter === 'function') {
|
|
864
|
+
let filter: number;
|
|
865
|
+
if (!this._state.imageSmoothingEnabled) {
|
|
866
|
+
filter = Cairo.Filter.NEAREST as unknown as number;
|
|
867
|
+
} else if (this._state.imageSmoothingQuality === 'high') {
|
|
868
|
+
filter = Cairo.Filter.BEST as unknown as number;
|
|
869
|
+
} else {
|
|
870
|
+
filter = Cairo.Filter.BILINEAR as unknown as number;
|
|
871
|
+
}
|
|
872
|
+
pat.setFilter(filter);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// paint() vs fill(): paint() composites the current source over the
|
|
876
|
+
// current clip region uniformly, honoring paintWithAlpha for global
|
|
877
|
+
// alpha multiplication. fill() would require a rectangle path and
|
|
878
|
+
// doesn't support per-draw alpha, so paint() is the spec-correct
|
|
879
|
+
// choice for drawImage. The clip above confines the paint to dx,dy,dw,dh.
|
|
880
|
+
if (this._state.globalAlpha < 1) {
|
|
881
|
+
(this._ctx as any).paintWithAlpha(this._state.globalAlpha);
|
|
882
|
+
} else {
|
|
883
|
+
this._ctx.paint();
|
|
884
|
+
}
|
|
786
885
|
this._ctx.restore();
|
|
787
886
|
}
|
|
788
887
|
|
|
@@ -832,35 +931,41 @@ export class CanvasRenderingContext2D {
|
|
|
832
931
|
/** Parse a CSS font string (e.g. "bold 16px Arial") into a Pango.FontDescription. */
|
|
833
932
|
private _parseFontToDescription(cssFont: string): Pango.FontDescription {
|
|
834
933
|
// CSS font: [style] [variant] [weight] size[/line-height] family[, family...]
|
|
835
|
-
// Pango expects: "Family Weight Style Size" format
|
|
836
934
|
const match = cssFont.match(
|
|
837
935
|
/^\s*(italic|oblique|normal)?\s*(small-caps|normal)?\s*(bold|bolder|lighter|[1-9]00|normal)?\s*(\d+(?:\.\d+)?)(px|pt|em|rem|%)?\s*(?:\/\S+)?\s*(.+)?$/i
|
|
838
936
|
);
|
|
839
937
|
|
|
840
938
|
if (!match) {
|
|
841
|
-
// Fallback: pass directly to Pango
|
|
939
|
+
// Fallback: pass directly to Pango (may have DPI-scaling quirks)
|
|
842
940
|
return Pango.font_description_from_string(cssFont);
|
|
843
941
|
}
|
|
844
942
|
|
|
845
|
-
const style
|
|
943
|
+
const style = match[1] || '';
|
|
846
944
|
const weight = match[3] || '';
|
|
847
|
-
let
|
|
848
|
-
const unit
|
|
945
|
+
let size = parseFloat(match[4]) || 10;
|
|
946
|
+
const unit = (match[5] || 'px').toLowerCase();
|
|
849
947
|
const family = (match[6] || 'sans-serif').replace(/['"]/g, '').trim();
|
|
850
948
|
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
949
|
+
// Normalise everything to CSS pixels.
|
|
950
|
+
// We use set_absolute_size() below which bypasses Pango's DPI scaling,
|
|
951
|
+
// so 1 CSS pixel == 1 device pixel on a 1:1 surface (standard for Canvas2D).
|
|
952
|
+
if (unit === 'pt') size = size * 96 / 72; // 1pt = 96/72 px
|
|
953
|
+
else if (unit === 'em' || unit === 'rem') size = size * 16; // assume 16px base
|
|
954
|
+
else if (unit === '%') size = (size / 100) * 16;
|
|
955
|
+
// 'px' stays as-is
|
|
855
956
|
|
|
957
|
+
// Build description string WITHOUT size — size is set via set_absolute_size.
|
|
856
958
|
let pangoStr = family;
|
|
857
|
-
if (style === 'italic')
|
|
959
|
+
if (style === 'italic') pangoStr += ' Italic';
|
|
858
960
|
else if (style === 'oblique') pangoStr += ' Oblique';
|
|
859
|
-
if (weight === 'bold' || weight === 'bolder' ||
|
|
961
|
+
if (weight === 'bold' || weight === 'bolder' || parseInt(weight) >= 600) pangoStr += ' Bold';
|
|
860
962
|
else if (weight === 'lighter' || (parseInt(weight) > 0 && parseInt(weight) <= 300)) pangoStr += ' Light';
|
|
861
|
-
pangoStr += ` ${Math.round(size)}`;
|
|
862
963
|
|
|
863
|
-
|
|
964
|
+
const desc = Pango.font_description_from_string(pangoStr);
|
|
965
|
+
// Absolute size: Pango.SCALE units per device pixel, no DPI conversion.
|
|
966
|
+
// This ensures "9px Round9x13" renders at exactly 9 pixels — pixel-perfect.
|
|
967
|
+
desc.set_absolute_size(size * Pango.SCALE);
|
|
968
|
+
return desc;
|
|
864
969
|
}
|
|
865
970
|
|
|
866
971
|
/**
|
|
@@ -914,13 +1019,57 @@ export class CanvasRenderingContext2D {
|
|
|
914
1019
|
fillText(text: string, x: number, y: number, _maxWidth?: number): void {
|
|
915
1020
|
this._ensureSurface();
|
|
916
1021
|
this._applyCompositing();
|
|
917
|
-
this._applyFillStyle();
|
|
918
1022
|
|
|
919
1023
|
const layout = this._createTextLayout(text);
|
|
920
1024
|
const xOff = this._getTextAlignOffset(layout);
|
|
921
1025
|
const yOff = this._getTextBaselineOffset(layout);
|
|
922
1026
|
|
|
1027
|
+
// Shadow pass: draw text at offset position with shadowColor.
|
|
1028
|
+
// shadowOffsetX/Y are in CSS pixels (not scaled by CTM per Canvas2D spec),
|
|
1029
|
+
// so we convert them to user-space before applying to moveTo.
|
|
1030
|
+
// shadowBlur is approximated with a 5-tap cross kernel: one center tap at full
|
|
1031
|
+
// alpha plus four arm taps at half alpha, spread by blur_u in each direction.
|
|
1032
|
+
// This simulates Gaussian spreading without an actual blur pass.
|
|
1033
|
+
if (this._hasShadow()) {
|
|
1034
|
+
const sc = parseColor(this._state.shadowColor);
|
|
1035
|
+
if (sc) {
|
|
1036
|
+
const [sdx, sdy] = this._deviceToUserDistance(
|
|
1037
|
+
this._state.shadowOffsetX,
|
|
1038
|
+
this._state.shadowOffsetY,
|
|
1039
|
+
);
|
|
1040
|
+
const blur = this._state.shadowBlur;
|
|
1041
|
+
type Tap = [number, number, number];
|
|
1042
|
+
let taps: Tap[];
|
|
1043
|
+
if (blur > 0) {
|
|
1044
|
+
const [bu] = this._deviceToUserDistance(blur, 0);
|
|
1045
|
+
const [, bv] = this._deviceToUserDistance(0, blur);
|
|
1046
|
+
taps = [
|
|
1047
|
+
[sdx, sdy, sc.a],
|
|
1048
|
+
[sdx + bu, sdy, sc.a * 0.5],
|
|
1049
|
+
[sdx - bu, sdy, sc.a * 0.5],
|
|
1050
|
+
[sdx, sdy + bv, sc.a * 0.5],
|
|
1051
|
+
[sdx, sdy - bv, sc.a * 0.5],
|
|
1052
|
+
];
|
|
1053
|
+
} else {
|
|
1054
|
+
taps = [[sdx, sdy, sc.a]];
|
|
1055
|
+
}
|
|
1056
|
+
const aa = this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE;
|
|
1057
|
+
for (const [tx, ty, ta] of taps) {
|
|
1058
|
+
this._ctx.save();
|
|
1059
|
+
(this._ctx as any).setAntialias(aa);
|
|
1060
|
+
this._ctx.setSourceRGBA(sc.r, sc.g, sc.b, ta);
|
|
1061
|
+
this._ctx.moveTo(x + xOff + tx, y + yOff + ty);
|
|
1062
|
+
PangoCairo.show_layout(this._ctx as any, layout);
|
|
1063
|
+
this._ctx.restore();
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
this._applyFillStyle();
|
|
923
1069
|
this._ctx.save();
|
|
1070
|
+
// Disable anti-aliasing so pixel/bitmap fonts render crisp (matching browser
|
|
1071
|
+
// behaviour for fonts with no outline hints). cairo_save/restore covers antialias.
|
|
1072
|
+
(this._ctx as any).setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
|
|
924
1073
|
this._ctx.moveTo(x + xOff, y + yOff);
|
|
925
1074
|
PangoCairo.show_layout(this._ctx as any, layout);
|
|
926
1075
|
this._ctx.restore();
|
|
@@ -937,6 +1086,7 @@ export class CanvasRenderingContext2D {
|
|
|
937
1086
|
const yOff = this._getTextBaselineOffset(layout);
|
|
938
1087
|
|
|
939
1088
|
this._ctx.save();
|
|
1089
|
+
(this._ctx as any).setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
|
|
940
1090
|
this._ctx.moveTo(x + xOff, y + yOff);
|
|
941
1091
|
PangoCairo.layout_path(this._ctx as any, layout);
|
|
942
1092
|
this._ctx.stroke();
|
|
@@ -947,25 +1097,34 @@ export class CanvasRenderingContext2D {
|
|
|
947
1097
|
this._ensureSurface();
|
|
948
1098
|
const layout = this._createTextLayout(text);
|
|
949
1099
|
const [inkRect, logicalRect] = layout.get_pixel_extents();
|
|
1100
|
+
|
|
1101
|
+
// Baseline of first line in pixels from layout top (Pango.SCALE units → px).
|
|
1102
|
+
const baselinePx = layout.get_baseline() / Pango.SCALE;
|
|
1103
|
+
|
|
1104
|
+
// actualBoundingBox: ink-based, relative to baseline (positive = above/right of baseline).
|
|
1105
|
+
// inkRect.y is pixels below layout top — compare against baseline to get baseline-relative values.
|
|
1106
|
+
const actualAscent = Math.max(0, baselinePx - inkRect.y);
|
|
1107
|
+
const actualDescent = Math.max(0, (inkRect.y + inkRect.height) - baselinePx);
|
|
1108
|
+
|
|
1109
|
+
// fontBoundingBox: font-level metrics (same for all glyphs at this font/size).
|
|
950
1110
|
const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
const descent = metrics.get_descent() / Pango.SCALE;
|
|
1111
|
+
const metrics = layout.get_context().get_metrics(fontDesc, null);
|
|
1112
|
+
const fontAscent = metrics.get_ascent() / Pango.SCALE;
|
|
1113
|
+
const fontDescent = metrics.get_descent() / Pango.SCALE;
|
|
955
1114
|
|
|
956
1115
|
return {
|
|
957
1116
|
width: logicalRect.width,
|
|
958
|
-
actualBoundingBoxAscent:
|
|
959
|
-
actualBoundingBoxDescent:
|
|
960
|
-
actualBoundingBoxLeft: -inkRect.x,
|
|
961
|
-
actualBoundingBoxRight:
|
|
962
|
-
fontBoundingBoxAscent:
|
|
963
|
-
fontBoundingBoxDescent:
|
|
964
|
-
alphabeticBaseline:
|
|
965
|
-
emHeightAscent:
|
|
966
|
-
emHeightDescent:
|
|
967
|
-
hangingBaseline:
|
|
968
|
-
ideographicBaseline:
|
|
1117
|
+
actualBoundingBoxAscent: actualAscent,
|
|
1118
|
+
actualBoundingBoxDescent: actualDescent,
|
|
1119
|
+
actualBoundingBoxLeft: Math.max(0, -inkRect.x),
|
|
1120
|
+
actualBoundingBoxRight: inkRect.x + inkRect.width,
|
|
1121
|
+
fontBoundingBoxAscent: fontAscent,
|
|
1122
|
+
fontBoundingBoxDescent: fontDescent,
|
|
1123
|
+
alphabeticBaseline: 0,
|
|
1124
|
+
emHeightAscent: fontAscent,
|
|
1125
|
+
emHeightDescent: fontDescent,
|
|
1126
|
+
hangingBaseline: fontAscent * 0.8,
|
|
1127
|
+
ideographicBaseline: -fontDescent,
|
|
969
1128
|
};
|
|
970
1129
|
}
|
|
971
1130
|
|