@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
|
@@ -1,1208 +0,0 @@
|
|
|
1
|
-
// CanvasRenderingContext2D implementation backed by Cairo
|
|
2
|
-
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
|
|
3
|
-
// Reference: refs/node-canvas (Cairo-backed Canvas 2D for Node.js)
|
|
4
|
-
|
|
5
|
-
import Cairo from 'cairo';
|
|
6
|
-
import Gdk from 'gi://Gdk?version=4.0';
|
|
7
|
-
import GdkPixbuf from 'gi://GdkPixbuf';
|
|
8
|
-
import Pango from 'gi://Pango';
|
|
9
|
-
import PangoCairo from 'gi://PangoCairo';
|
|
10
|
-
// HTMLCanvasElement type is provided by the DOM lib.
|
|
11
|
-
// Our @gjsify/dom-elements HTMLCanvasElement satisfies this interface.
|
|
12
|
-
|
|
13
|
-
import { asCairoPattern } from './cairo-types.js';
|
|
14
|
-
import {
|
|
15
|
-
type CanvasLike,
|
|
16
|
-
type CanvasGlobalThis,
|
|
17
|
-
type DOMMatrix2DLike,
|
|
18
|
-
isPixbufImageSource,
|
|
19
|
-
isCanvasImageSource,
|
|
20
|
-
} from './dom-types.js';
|
|
21
|
-
import { parseColor } from './color.js';
|
|
22
|
-
import {
|
|
23
|
-
quadraticToCubic,
|
|
24
|
-
cairoArcTo,
|
|
25
|
-
cairoEllipse,
|
|
26
|
-
cairoRoundRect,
|
|
27
|
-
COMPOSITE_OP_MAP,
|
|
28
|
-
LINE_CAP_MAP,
|
|
29
|
-
LINE_JOIN_MAP,
|
|
30
|
-
} from './cairo-utils.js';
|
|
31
|
-
import { type CanvasState, createDefaultState, cloneState } from './canvas-state.js';
|
|
32
|
-
import { OurImageData } from './image-data.js';
|
|
33
|
-
import { CanvasGradient as OurCanvasGradient } from './canvas-gradient.js';
|
|
34
|
-
import { CanvasPattern as OurCanvasPattern } from './canvas-pattern.js';
|
|
35
|
-
import { Path2D } from './canvas-path.js';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Options bag passed through the `getContext('2d', options)` factory. Mirrors
|
|
39
|
-
* the WHATWG `CanvasRenderingContext2DSettings` dictionary; fields are
|
|
40
|
-
* accepted but not yet honored by this implementation.
|
|
41
|
-
*/
|
|
42
|
-
export interface CanvasRenderingContext2DInit {
|
|
43
|
-
alpha?: boolean;
|
|
44
|
-
desynchronized?: boolean;
|
|
45
|
-
colorSpace?: PredefinedColorSpace;
|
|
46
|
-
willReadFrequently?: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* CanvasRenderingContext2D backed by Cairo.ImageSurface.
|
|
51
|
-
* Implements the Canvas 2D API for GJS.
|
|
52
|
-
*/
|
|
53
|
-
export class CanvasRenderingContext2D {
|
|
54
|
-
readonly canvas: CanvasLike;
|
|
55
|
-
|
|
56
|
-
private _surface: Cairo.ImageSurface;
|
|
57
|
-
private _ctx: Cairo.Context;
|
|
58
|
-
private _state: CanvasState;
|
|
59
|
-
private _stateStack: CanvasState[] = [];
|
|
60
|
-
private _surfaceWidth: number;
|
|
61
|
-
private _surfaceHeight: number;
|
|
62
|
-
|
|
63
|
-
constructor(canvas: CanvasLike, _options?: CanvasRenderingContext2DInit) {
|
|
64
|
-
this.canvas = canvas;
|
|
65
|
-
this._surfaceWidth = canvas.width || 300;
|
|
66
|
-
this._surfaceHeight = canvas.height || 150;
|
|
67
|
-
this._surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, this._surfaceWidth, this._surfaceHeight);
|
|
68
|
-
this._ctx = new Cairo.Context(this._surface);
|
|
69
|
-
this._state = createDefaultState();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---- Internal helpers ----
|
|
73
|
-
|
|
74
|
-
/** Ensure the surface matches the current canvas dimensions. Recreate if resized. */
|
|
75
|
-
private _ensureSurface(): void {
|
|
76
|
-
const w = this.canvas.width || 300;
|
|
77
|
-
const h = this.canvas.height || 150;
|
|
78
|
-
if (w !== this._surfaceWidth || h !== this._surfaceHeight) {
|
|
79
|
-
this._ctx.$dispose();
|
|
80
|
-
this._surface.finish();
|
|
81
|
-
this._surfaceWidth = w;
|
|
82
|
-
this._surfaceHeight = h;
|
|
83
|
-
this._surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
|
|
84
|
-
this._ctx = new Cairo.Context(this._surface);
|
|
85
|
-
// Preserve the current drawing state (fillStyle, strokeStyle, font, etc.) across
|
|
86
|
-
// surface recreations triggered by widget resize. Only reset the save/restore stack
|
|
87
|
-
// because the old Cairo context is gone and saved state is invalid.
|
|
88
|
-
// NOTE: If app code wants a true canvas reset (spec: canvas.width = X resets context),
|
|
89
|
-
// it should call _resetState() explicitly. We do not reset here because _ensureSurface()
|
|
90
|
-
// is called internally from drawing operations, not from app-level canvas.width assignments.
|
|
91
|
-
this._stateStack = [];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Reset drawing state to defaults (called when canvas dimensions are explicitly reset). */
|
|
96
|
-
_resetState(): void {
|
|
97
|
-
this._state = createDefaultState();
|
|
98
|
-
this._stateStack = [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Apply the current fill style (color, gradient, or pattern) to the Cairo context. */
|
|
102
|
-
private _applyFillStyle(): void {
|
|
103
|
-
const style = this._state.fillStyle;
|
|
104
|
-
if (typeof style === 'string') {
|
|
105
|
-
const c = this._state.fillColor;
|
|
106
|
-
const a = c.a * this._state.globalAlpha;
|
|
107
|
-
this._ctx.setSourceRGBA(c.r, c.g, c.b, a);
|
|
108
|
-
} else if (style instanceof OurCanvasGradient) {
|
|
109
|
-
this._ctx.setSource(style._getCairoPattern());
|
|
110
|
-
} else if (style instanceof OurCanvasPattern) {
|
|
111
|
-
this._ctx.setSource(style._getCairoPattern());
|
|
112
|
-
this._applyPatternFilter();
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Apply the current stroke style to the Cairo context. */
|
|
117
|
-
private _applyStrokeStyle(): void {
|
|
118
|
-
const style = this._state.strokeStyle;
|
|
119
|
-
if (typeof style === 'string') {
|
|
120
|
-
const c = this._state.strokeColor;
|
|
121
|
-
const a = c.a * this._state.globalAlpha;
|
|
122
|
-
this._ctx.setSourceRGBA(c.r, c.g, c.b, a);
|
|
123
|
-
} else if (style instanceof OurCanvasGradient) {
|
|
124
|
-
this._ctx.setSource(style._getCairoPattern());
|
|
125
|
-
} else if (style instanceof OurCanvasPattern) {
|
|
126
|
-
this._ctx.setSource(style._getCairoPattern());
|
|
127
|
-
this._applyPatternFilter();
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Apply the current imageSmoothingEnabled + imageSmoothingQuality state
|
|
133
|
-
* to the currently installed Cairo source pattern. Per Canvas 2D spec,
|
|
134
|
-
* the filter is read from the context at *draw* time, not at pattern
|
|
135
|
-
* creation — so we re-apply it on every fill/stroke.
|
|
136
|
-
*/
|
|
137
|
-
private _applyPatternFilter(): void {
|
|
138
|
-
const pat = asCairoPattern(this._ctx.getSource?.());
|
|
139
|
-
if (!pat) return;
|
|
140
|
-
let filter: Cairo.Filter;
|
|
141
|
-
if (!this._state.imageSmoothingEnabled) {
|
|
142
|
-
filter = Cairo.Filter.NEAREST;
|
|
143
|
-
} else if (this._state.imageSmoothingQuality === 'high') {
|
|
144
|
-
filter = Cairo.Filter.BEST;
|
|
145
|
-
} else {
|
|
146
|
-
filter = Cairo.Filter.BILINEAR;
|
|
147
|
-
}
|
|
148
|
-
pat.setFilter(filter);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Apply line properties to the Cairo context. */
|
|
152
|
-
private _applyLineStyle(): void {
|
|
153
|
-
this._ctx.setLineWidth(this._state.lineWidth);
|
|
154
|
-
this._ctx.setLineCap(LINE_CAP_MAP[this._state.lineCap] as Cairo.LineCap);
|
|
155
|
-
this._ctx.setLineJoin(LINE_JOIN_MAP[this._state.lineJoin] as Cairo.LineJoin);
|
|
156
|
-
this._ctx.setMiterLimit(this._state.miterLimit);
|
|
157
|
-
this._ctx.setDash(this._state.lineDash, this._state.lineDashOffset);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Apply compositing operator. */
|
|
161
|
-
private _applyCompositing(): void {
|
|
162
|
-
const op = COMPOSITE_OP_MAP[this._state.globalCompositeOperation];
|
|
163
|
-
if (op !== undefined) {
|
|
164
|
-
this._ctx.setOperator(op as Cairo.Operator);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Get the Cairo ImageSurface (used by other contexts like drawImage). */
|
|
169
|
-
_getSurface(): Cairo.ImageSurface {
|
|
170
|
-
return this._surface;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Check if shadow rendering is needed. */
|
|
174
|
-
private _hasShadow(): boolean {
|
|
175
|
-
if (this._state.shadowBlur === 0 && this._state.shadowOffsetX === 0 && this._state.shadowOffsetY === 0) {
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
const c = parseColor(this._state.shadowColor);
|
|
179
|
-
return c !== null && c.a > 0;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Convert a distance from device pixels to Cairo user space by inverting
|
|
184
|
-
* the linear part of the current CTM (translation doesn't affect distances).
|
|
185
|
-
*
|
|
186
|
-
* Canvas 2D spec: shadowOffsetX/Y are in CSS pixels and are NOT scaled by
|
|
187
|
-
* the current transform. This helper converts them to user-space offsets so
|
|
188
|
-
* that `ctx.moveTo(x + sdx, y + sdy)` produces the correct pixel offset
|
|
189
|
-
* regardless of any ctx.scale() or ctx.rotate() in effect.
|
|
190
|
-
*/
|
|
191
|
-
private _deviceToUserDistance(dx: number, dy: number): [number, number] {
|
|
192
|
-
const origin = this._ctx.userToDevice(0, 0);
|
|
193
|
-
const xAxis = this._ctx.userToDevice(1, 0);
|
|
194
|
-
const yAxis = this._ctx.userToDevice(0, 1);
|
|
195
|
-
const a = (xAxis[0] ?? 0) - (origin[0] ?? 0);
|
|
196
|
-
const b = (xAxis[1] ?? 0) - (origin[1] ?? 0);
|
|
197
|
-
const c = (yAxis[0] ?? 0) - (origin[0] ?? 0);
|
|
198
|
-
const d = (yAxis[1] ?? 0) - (origin[1] ?? 0);
|
|
199
|
-
const det = a * d - b * c;
|
|
200
|
-
if (Math.abs(det) < 1e-10) return [dx, dy]; // degenerate transform — no conversion
|
|
201
|
-
return [
|
|
202
|
-
( d * dx - c * dy) / det,
|
|
203
|
-
(-b * dx + a * dy) / det,
|
|
204
|
-
];
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Shadow rendering is intentionally a no-op.
|
|
209
|
-
*
|
|
210
|
-
* Proper Canvas 2D shadows require a Gaussian blur pass on an isolated
|
|
211
|
-
* temporary surface, which cannot be emulated reliably without a full
|
|
212
|
-
* Path2D replay or pixel-level manipulation. The previous implementation
|
|
213
|
-
* attempted to use a temp surface but never replayed the path onto it
|
|
214
|
-
* (because `drawOp` closes over the main context), leaving the shadow
|
|
215
|
-
* surface empty while still leaking memory.
|
|
216
|
-
*
|
|
217
|
-
* Excalibur and most 2D game engines bake glow/outline effects into
|
|
218
|
-
* sprites rather than relying on canvas shadows, so this no-op does not
|
|
219
|
-
* affect the showcase. A correct implementation is tracked as a
|
|
220
|
-
* separate Canvas 2D Phase-5 enhancement.
|
|
221
|
-
*/
|
|
222
|
-
private _renderShadow(_drawOp: () => void): void {
|
|
223
|
-
// Intentionally empty. See the doc-comment above.
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ---- State ----
|
|
227
|
-
|
|
228
|
-
save(): void {
|
|
229
|
-
this._ensureSurface();
|
|
230
|
-
this._stateStack.push(cloneState(this._state));
|
|
231
|
-
this._ctx.save();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
restore(): void {
|
|
235
|
-
this._ensureSurface();
|
|
236
|
-
const prev = this._stateStack.pop();
|
|
237
|
-
if (prev) {
|
|
238
|
-
this._state = prev;
|
|
239
|
-
this._ctx.restore();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ---- Transforms ----
|
|
244
|
-
|
|
245
|
-
translate(x: number, y: number): void {
|
|
246
|
-
this._ensureSurface();
|
|
247
|
-
this._ctx.translate(x, y);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
rotate(angle: number): void {
|
|
251
|
-
this._ensureSurface();
|
|
252
|
-
this._ctx.rotate(angle);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
scale(x: number, y: number): void {
|
|
256
|
-
this._ensureSurface();
|
|
257
|
-
this._ctx.scale(x, y);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Multiply the current transformation matrix by the given values.
|
|
262
|
-
* Matrix: [a c e]
|
|
263
|
-
* [b d f]
|
|
264
|
-
* [0 0 1]
|
|
265
|
-
*/
|
|
266
|
-
transform(a: number, b: number, c: number, d: number, e: number, f: number): void {
|
|
267
|
-
this._ensureSurface();
|
|
268
|
-
// Guard against NaN / undefined / Infinity — Cairo will hard-crash
|
|
269
|
-
// on invalid matrix values.
|
|
270
|
-
if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(c) ||
|
|
271
|
-
!Number.isFinite(d) || !Number.isFinite(e) || !Number.isFinite(f)) {
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
// Cairo.Context in GJS does NOT expose a generic `transform(matrix)` /
|
|
275
|
-
// `setMatrix()` call — only `translate()`, `rotate()`, `scale()` and
|
|
276
|
-
// `identityMatrix()`. So we decompose the affine 2D matrix
|
|
277
|
-
// [a c e]
|
|
278
|
-
// [b d f]
|
|
279
|
-
// [0 0 1]
|
|
280
|
-
// into translate + rotate + scale (ignoring shear, which Excalibur /
|
|
281
|
-
// three.js 2D users don't rely on). Shear would require a combined
|
|
282
|
-
// matrix multiply, which isn't available in this binding.
|
|
283
|
-
const tx = e;
|
|
284
|
-
const ty = f;
|
|
285
|
-
const sx = Math.hypot(a, b);
|
|
286
|
-
const sy = Math.hypot(c, d);
|
|
287
|
-
const rotation = Math.atan2(b, a);
|
|
288
|
-
this._ctx.translate(tx, ty);
|
|
289
|
-
if (rotation !== 0) this._ctx.rotate(rotation);
|
|
290
|
-
if (sx !== 1 || sy !== 1) this._ctx.scale(sx, sy);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Reset the transform to identity, then apply the given matrix.
|
|
295
|
-
*/
|
|
296
|
-
setTransform(matrix?: DOMMatrix2DInit): void;
|
|
297
|
-
setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;
|
|
298
|
-
setTransform(a?: number | DOMMatrix2DInit, b?: number, c?: number, d?: number, e?: number, f?: number): void {
|
|
299
|
-
this._ensureSurface();
|
|
300
|
-
if (typeof a === 'object' && a !== null) {
|
|
301
|
-
const m = a;
|
|
302
|
-
this._ctx.identityMatrix();
|
|
303
|
-
this.transform(
|
|
304
|
-
m.a ?? m.m11 ?? 1, m.b ?? m.m12 ?? 0,
|
|
305
|
-
m.c ?? m.m21 ?? 0, m.d ?? m.m22 ?? 1,
|
|
306
|
-
m.e ?? m.m41 ?? 0, m.f ?? m.m42 ?? 0,
|
|
307
|
-
);
|
|
308
|
-
} else if (typeof a === 'number') {
|
|
309
|
-
this._ctx.identityMatrix();
|
|
310
|
-
this.transform(a, b!, c!, d!, e!, f!);
|
|
311
|
-
} else {
|
|
312
|
-
this._ctx.identityMatrix();
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Return the current transformation matrix as a DOMMatrix-like object.
|
|
318
|
-
*/
|
|
319
|
-
getTransform(): DOMMatrix {
|
|
320
|
-
// Cairo.Context in GJS doesn't expose `getMatrix()`, but it does
|
|
321
|
-
// expose `userToDevice(x, y)`. We reconstruct the current affine
|
|
322
|
-
// matrix [a,b,c,d,e,f] by transforming three reference points:
|
|
323
|
-
// userToDevice(0, 0) = (e, f) — translation
|
|
324
|
-
// userToDevice(1, 0) = (a + e, b + f) — first basis vector
|
|
325
|
-
// userToDevice(0, 1) = (c + e, d + f) — second basis vector
|
|
326
|
-
const origin = this._ctx.userToDevice(0, 0);
|
|
327
|
-
const xAxis = this._ctx.userToDevice(1, 0);
|
|
328
|
-
const yAxis = this._ctx.userToDevice(0, 1);
|
|
329
|
-
const e = origin[0] ?? 0;
|
|
330
|
-
const f = origin[1] ?? 0;
|
|
331
|
-
const a = (xAxis[0] ?? 0) - e;
|
|
332
|
-
const b = (xAxis[1] ?? 0) - f;
|
|
333
|
-
const c = (yAxis[0] ?? 0) - e;
|
|
334
|
-
const d = (yAxis[1] ?? 0) - f;
|
|
335
|
-
|
|
336
|
-
const DOMMatrixCtor = (globalThis as CanvasGlobalThis).DOMMatrix;
|
|
337
|
-
if (typeof DOMMatrixCtor === 'function') {
|
|
338
|
-
return new DOMMatrixCtor([a, b, c, d, e, f]);
|
|
339
|
-
}
|
|
340
|
-
const fallback: DOMMatrix2DLike = {
|
|
341
|
-
a, b, c, d, e, f,
|
|
342
|
-
m11: a, m12: b, m13: 0, m14: 0,
|
|
343
|
-
m21: c, m22: d, m23: 0, m24: 0,
|
|
344
|
-
m31: 0, m32: 0, m33: 1, m34: 0,
|
|
345
|
-
m41: e, m42: f, m43: 0, m44: 1,
|
|
346
|
-
is2D: true,
|
|
347
|
-
isIdentity: (a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0),
|
|
348
|
-
};
|
|
349
|
-
return fallback as unknown as DOMMatrix;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
resetTransform(): void {
|
|
353
|
-
this._ensureSurface();
|
|
354
|
-
this._ctx.identityMatrix();
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ---- Style properties ----
|
|
358
|
-
|
|
359
|
-
get fillStyle(): string | CanvasGradient | CanvasPattern {
|
|
360
|
-
return this._state.fillStyle;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
|
364
|
-
if (typeof value === 'string') {
|
|
365
|
-
const parsed = parseColor(value);
|
|
366
|
-
if (parsed) {
|
|
367
|
-
this._state.fillStyle = value;
|
|
368
|
-
this._state.fillColor = parsed;
|
|
369
|
-
}
|
|
370
|
-
} else {
|
|
371
|
-
this._state.fillStyle = value;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
get strokeStyle(): string | CanvasGradient | CanvasPattern {
|
|
376
|
-
return this._state.strokeStyle;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
set strokeStyle(value: string | CanvasGradient | CanvasPattern) {
|
|
380
|
-
if (typeof value === 'string') {
|
|
381
|
-
const parsed = parseColor(value);
|
|
382
|
-
if (parsed) {
|
|
383
|
-
this._state.strokeStyle = value;
|
|
384
|
-
this._state.strokeColor = parsed;
|
|
385
|
-
}
|
|
386
|
-
} else {
|
|
387
|
-
this._state.strokeStyle = value;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
get lineWidth(): number { return this._state.lineWidth; }
|
|
392
|
-
set lineWidth(value: number) {
|
|
393
|
-
if (value > 0 && isFinite(value)) this._state.lineWidth = value;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
get lineCap(): CanvasLineCap { return this._state.lineCap; }
|
|
397
|
-
set lineCap(value: CanvasLineCap) {
|
|
398
|
-
if (value === 'butt' || value === 'round' || value === 'square') {
|
|
399
|
-
this._state.lineCap = value;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
get lineJoin(): CanvasLineJoin { return this._state.lineJoin; }
|
|
404
|
-
set lineJoin(value: CanvasLineJoin) {
|
|
405
|
-
if (value === 'miter' || value === 'round' || value === 'bevel') {
|
|
406
|
-
this._state.lineJoin = value;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
get miterLimit(): number { return this._state.miterLimit; }
|
|
411
|
-
set miterLimit(value: number) {
|
|
412
|
-
if (value > 0 && isFinite(value)) this._state.miterLimit = value;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
get globalAlpha(): number { return this._state.globalAlpha; }
|
|
416
|
-
set globalAlpha(value: number) {
|
|
417
|
-
if (value >= 0 && value <= 1 && isFinite(value)) this._state.globalAlpha = value;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
get globalCompositeOperation(): GlobalCompositeOperation {
|
|
421
|
-
return this._state.globalCompositeOperation;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
set globalCompositeOperation(value: GlobalCompositeOperation) {
|
|
425
|
-
if (COMPOSITE_OP_MAP[value] !== undefined) {
|
|
426
|
-
this._state.globalCompositeOperation = value;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
get imageSmoothingEnabled(): boolean { return this._state.imageSmoothingEnabled; }
|
|
431
|
-
set imageSmoothingEnabled(value: boolean) { this._state.imageSmoothingEnabled = !!value; }
|
|
432
|
-
|
|
433
|
-
get imageSmoothingQuality(): ImageSmoothingQuality { return this._state.imageSmoothingQuality; }
|
|
434
|
-
set imageSmoothingQuality(value: ImageSmoothingQuality) {
|
|
435
|
-
if (value === 'low' || value === 'medium' || value === 'high') {
|
|
436
|
-
this._state.imageSmoothingQuality = value;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Line dash
|
|
441
|
-
setLineDash(segments: number[]): void {
|
|
442
|
-
// Per spec, ignore if any value is negative or non-finite
|
|
443
|
-
if (segments.some(v => v < 0 || !isFinite(v))) return;
|
|
444
|
-
this._state.lineDash = [...segments];
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
getLineDash(): number[] {
|
|
448
|
-
return [...this._state.lineDash];
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
get lineDashOffset(): number { return this._state.lineDashOffset; }
|
|
452
|
-
set lineDashOffset(value: number) {
|
|
453
|
-
if (isFinite(value)) this._state.lineDashOffset = value;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// ---- Shadow properties (stored in state, rendering in Phase 5) ----
|
|
457
|
-
get shadowColor(): string { return this._state.shadowColor; }
|
|
458
|
-
set shadowColor(value: string) { this._state.shadowColor = value; }
|
|
459
|
-
get shadowBlur(): number { return this._state.shadowBlur; }
|
|
460
|
-
set shadowBlur(value: number) { if (value >= 0 && isFinite(value)) this._state.shadowBlur = value; }
|
|
461
|
-
get shadowOffsetX(): number { return this._state.shadowOffsetX; }
|
|
462
|
-
set shadowOffsetX(value: number) { if (isFinite(value)) this._state.shadowOffsetX = value; }
|
|
463
|
-
get shadowOffsetY(): number { return this._state.shadowOffsetY; }
|
|
464
|
-
set shadowOffsetY(value: number) { if (isFinite(value)) this._state.shadowOffsetY = value; }
|
|
465
|
-
|
|
466
|
-
// ---- Text properties (stored in state, rendering in Phase 4) ----
|
|
467
|
-
get font(): string { return this._state.font; }
|
|
468
|
-
set font(value: string) { this._state.font = value; }
|
|
469
|
-
get textAlign(): CanvasTextAlign { return this._state.textAlign; }
|
|
470
|
-
set textAlign(value: CanvasTextAlign) { this._state.textAlign = value; }
|
|
471
|
-
get textBaseline(): CanvasTextBaseline { return this._state.textBaseline; }
|
|
472
|
-
set textBaseline(value: CanvasTextBaseline) { this._state.textBaseline = value; }
|
|
473
|
-
get direction(): CanvasDirection { return this._state.direction; }
|
|
474
|
-
set direction(value: CanvasDirection) { this._state.direction = value; }
|
|
475
|
-
|
|
476
|
-
// ---- Path methods ----
|
|
477
|
-
|
|
478
|
-
beginPath(): void {
|
|
479
|
-
this._ensureSurface();
|
|
480
|
-
this._ctx.newPath();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
moveTo(x: number, y: number): void {
|
|
484
|
-
this._ensureSurface();
|
|
485
|
-
this._ctx.moveTo(x, y);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
lineTo(x: number, y: number): void {
|
|
489
|
-
this._ensureSurface();
|
|
490
|
-
this._ctx.lineTo(x, y);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
closePath(): void {
|
|
494
|
-
this._ensureSurface();
|
|
495
|
-
this._ctx.closePath();
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void {
|
|
499
|
-
this._ensureSurface();
|
|
500
|
-
this._ctx.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
|
|
504
|
-
this._ensureSurface();
|
|
505
|
-
let cx: number, cy: number;
|
|
506
|
-
if (this._ctx.hasCurrentPoint()) {
|
|
507
|
-
[cx, cy] = this._ctx.getCurrentPoint();
|
|
508
|
-
} else {
|
|
509
|
-
cx = cpx;
|
|
510
|
-
cy = cpy;
|
|
511
|
-
}
|
|
512
|
-
const { cp1x, cp1y, cp2x, cp2y } = quadraticToCubic(cx, cy, cpx, cpy, x, y);
|
|
513
|
-
this._ctx.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise = false): void {
|
|
517
|
-
this._ensureSurface();
|
|
518
|
-
// Browsers draw a full circle when |endAngle - startAngle| >= 2π,
|
|
519
|
-
// regardless of direction. Cairo's arcNegative would produce a
|
|
520
|
-
// zero-length arc for arcNegative(x,y,r,0,2π) because it normalizes
|
|
521
|
-
// endAngle to be < startAngle, collapsing the arc to nothing.
|
|
522
|
-
if (Math.abs(endAngle - startAngle) >= 2 * Math.PI) {
|
|
523
|
-
this._ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
if (counterclockwise) {
|
|
527
|
-
this._ctx.arcNegative(x, y, radius, startAngle, endAngle);
|
|
528
|
-
} else {
|
|
529
|
-
this._ctx.arc(x, y, radius, startAngle, endAngle);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void {
|
|
534
|
-
this._ensureSurface();
|
|
535
|
-
let x0: number, y0: number;
|
|
536
|
-
if (this._ctx.hasCurrentPoint()) {
|
|
537
|
-
[x0, y0] = this._ctx.getCurrentPoint();
|
|
538
|
-
} else {
|
|
539
|
-
x0 = x1;
|
|
540
|
-
y0 = y1;
|
|
541
|
-
this._ctx.moveTo(x1, y1);
|
|
542
|
-
}
|
|
543
|
-
cairoArcTo(this._ctx, x0, y0, x1, y1, x2, y2, radius);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
ellipse(
|
|
547
|
-
x: number, y: number,
|
|
548
|
-
radiusX: number, radiusY: number,
|
|
549
|
-
rotation: number,
|
|
550
|
-
startAngle: number, endAngle: number,
|
|
551
|
-
counterclockwise = false,
|
|
552
|
-
): void {
|
|
553
|
-
this._ensureSurface();
|
|
554
|
-
if (radiusX < 0 || radiusY < 0) {
|
|
555
|
-
throw new RangeError('The radii provided are negative');
|
|
556
|
-
}
|
|
557
|
-
cairoEllipse(this._ctx, x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
rect(x: number, y: number, w: number, h: number): void {
|
|
561
|
-
this._ensureSurface();
|
|
562
|
-
this._ctx.rectangle(x, y, w, h);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
roundRect(x: number, y: number, w: number, h: number, radii: number | number[] = 0): void {
|
|
566
|
-
this._ensureSurface();
|
|
567
|
-
cairoRoundRect(this._ctx, x, y, w, h, radii);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// ---- Drawing methods ----
|
|
571
|
-
|
|
572
|
-
fill(fillRule?: CanvasFillRule): void;
|
|
573
|
-
fill(path: Path2D, fillRule?: CanvasFillRule): void;
|
|
574
|
-
fill(pathOrRule?: Path2D | CanvasFillRule, fillRule?: CanvasFillRule): void {
|
|
575
|
-
this._ensureSurface();
|
|
576
|
-
this._applyCompositing();
|
|
577
|
-
this._applyFillStyle();
|
|
578
|
-
|
|
579
|
-
let rule: CanvasFillRule | undefined;
|
|
580
|
-
if (pathOrRule instanceof Path2D) {
|
|
581
|
-
this._ctx.newPath();
|
|
582
|
-
pathOrRule._replayOnCairo(this._ctx);
|
|
583
|
-
rule = fillRule;
|
|
584
|
-
} else {
|
|
585
|
-
rule = pathOrRule;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
|
|
589
|
-
this._ctx.fillPreserve();
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
stroke(): void;
|
|
593
|
-
stroke(path: Path2D): void;
|
|
594
|
-
stroke(path?: Path2D): void {
|
|
595
|
-
this._ensureSurface();
|
|
596
|
-
this._applyCompositing();
|
|
597
|
-
this._applyStrokeStyle();
|
|
598
|
-
this._applyLineStyle();
|
|
599
|
-
|
|
600
|
-
if (path instanceof Path2D) {
|
|
601
|
-
this._ctx.newPath();
|
|
602
|
-
path._replayOnCairo(this._ctx);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
this._ctx.strokePreserve();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
fillRect(x: number, y: number, w: number, h: number): void {
|
|
609
|
-
this._ensureSurface();
|
|
610
|
-
this._applyCompositing();
|
|
611
|
-
// Per spec: fillRect must not affect the current path.
|
|
612
|
-
// Save current path, draw the rect in an isolated path, then restore.
|
|
613
|
-
const savedPath = this._ctx.copyPath();
|
|
614
|
-
if (this._hasShadow()) {
|
|
615
|
-
this._renderShadow(() => {
|
|
616
|
-
this._ctx.newPath();
|
|
617
|
-
this._ctx.rectangle(x, y, w, h);
|
|
618
|
-
this._ctx.fill();
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
this._applyFillStyle();
|
|
622
|
-
this._ctx.newPath();
|
|
623
|
-
this._ctx.rectangle(x, y, w, h);
|
|
624
|
-
this._ctx.fill();
|
|
625
|
-
this._ctx.newPath();
|
|
626
|
-
this._ctx.appendPath(savedPath);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
strokeRect(x: number, y: number, w: number, h: number): void {
|
|
630
|
-
this._ensureSurface();
|
|
631
|
-
this._applyCompositing();
|
|
632
|
-
// Per spec: strokeRect must not affect the current path.
|
|
633
|
-
const savedPath = this._ctx.copyPath();
|
|
634
|
-
if (this._hasShadow()) {
|
|
635
|
-
this._renderShadow(() => {
|
|
636
|
-
this._ctx.newPath();
|
|
637
|
-
this._ctx.rectangle(x, y, w, h);
|
|
638
|
-
this._ctx.stroke();
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
this._applyStrokeStyle();
|
|
642
|
-
this._applyLineStyle();
|
|
643
|
-
this._ctx.newPath();
|
|
644
|
-
this._ctx.rectangle(x, y, w, h);
|
|
645
|
-
this._ctx.stroke();
|
|
646
|
-
this._ctx.newPath();
|
|
647
|
-
this._ctx.appendPath(savedPath);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
clearRect(x: number, y: number, w: number, h: number): void {
|
|
651
|
-
this._ensureSurface();
|
|
652
|
-
// Per spec: clearRect must not affect the current path.
|
|
653
|
-
const savedPath = this._ctx.copyPath();
|
|
654
|
-
this._ctx.save();
|
|
655
|
-
this._ctx.setOperator(Cairo.Operator.CLEAR);
|
|
656
|
-
this._ctx.newPath();
|
|
657
|
-
this._ctx.rectangle(x, y, w, h);
|
|
658
|
-
this._ctx.fill();
|
|
659
|
-
this._ctx.restore();
|
|
660
|
-
this._ctx.newPath();
|
|
661
|
-
this._ctx.appendPath(savedPath);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// ---- Clipping ----
|
|
665
|
-
|
|
666
|
-
clip(fillRule?: CanvasFillRule): void;
|
|
667
|
-
clip(path: Path2D, fillRule?: CanvasFillRule): void;
|
|
668
|
-
clip(pathOrRule?: Path2D | CanvasFillRule, fillRule?: CanvasFillRule): void {
|
|
669
|
-
this._ensureSurface();
|
|
670
|
-
let rule: CanvasFillRule | undefined;
|
|
671
|
-
if (pathOrRule instanceof Path2D) {
|
|
672
|
-
this._ctx.newPath();
|
|
673
|
-
pathOrRule._replayOnCairo(this._ctx);
|
|
674
|
-
rule = fillRule;
|
|
675
|
-
} else {
|
|
676
|
-
rule = pathOrRule;
|
|
677
|
-
}
|
|
678
|
-
this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
|
|
679
|
-
this._ctx.clip();
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ---- Hit testing ----
|
|
683
|
-
|
|
684
|
-
isPointInPath(x: number, y: number, fillRule?: CanvasFillRule): boolean;
|
|
685
|
-
isPointInPath(path: Path2D, x: number, y: number, fillRule?: CanvasFillRule): boolean;
|
|
686
|
-
isPointInPath(pathOrX: Path2D | number, xOrY: number, fillRuleOrY?: CanvasFillRule | number, fillRule?: CanvasFillRule): boolean {
|
|
687
|
-
this._ensureSurface();
|
|
688
|
-
let x: number, y: number, rule: CanvasFillRule | undefined;
|
|
689
|
-
if (pathOrX instanceof Path2D) {
|
|
690
|
-
this._ctx.newPath();
|
|
691
|
-
pathOrX._replayOnCairo(this._ctx);
|
|
692
|
-
x = xOrY; y = fillRuleOrY as number; rule = fillRule;
|
|
693
|
-
} else {
|
|
694
|
-
x = pathOrX; y = xOrY; rule = fillRuleOrY as CanvasFillRule | undefined;
|
|
695
|
-
}
|
|
696
|
-
this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
|
|
697
|
-
return this._ctx.inFill(x, y);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
isPointInStroke(x: number, y: number): boolean;
|
|
701
|
-
isPointInStroke(path: Path2D, x: number, y: number): boolean;
|
|
702
|
-
isPointInStroke(pathOrX: Path2D | number, xOrY: number, y?: number): boolean {
|
|
703
|
-
this._ensureSurface();
|
|
704
|
-
this._applyLineStyle();
|
|
705
|
-
if (pathOrX instanceof Path2D) {
|
|
706
|
-
this._ctx.newPath();
|
|
707
|
-
pathOrX._replayOnCairo(this._ctx);
|
|
708
|
-
return this._ctx.inStroke(xOrY, y!);
|
|
709
|
-
}
|
|
710
|
-
return this._ctx.inStroke(pathOrX, xOrY);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// ---- Gradient / Pattern factories ----
|
|
714
|
-
|
|
715
|
-
createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient {
|
|
716
|
-
return new OurCanvasGradient('linear', x0, y0, x1, y1) as unknown as CanvasGradient;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient {
|
|
720
|
-
return new OurCanvasGradient('radial', x0, y0, x1, y1, r0, r1) as unknown as CanvasGradient;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
createPattern(image: unknown, repetition: string | null): CanvasPattern | null {
|
|
724
|
-
return OurCanvasPattern.create(image, repetition) as unknown as CanvasPattern | null;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// ---- Image data methods ----
|
|
728
|
-
|
|
729
|
-
createImageData(sw: number, sh: number): ImageData;
|
|
730
|
-
createImageData(imagedata: ImageData): ImageData;
|
|
731
|
-
createImageData(swOrImageData: number | ImageData, sh?: number): ImageData {
|
|
732
|
-
if (typeof swOrImageData === 'number') {
|
|
733
|
-
return new OurImageData(Math.abs(swOrImageData), Math.abs(sh!)) as unknown as ImageData;
|
|
734
|
-
}
|
|
735
|
-
return new OurImageData(swOrImageData.width, swOrImageData.height) as unknown as ImageData;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
getImageData(sx: number, sy: number, sw: number, sh: number): ImageData {
|
|
739
|
-
this._ensureSurface();
|
|
740
|
-
this._surface.flush();
|
|
741
|
-
|
|
742
|
-
// Use Gdk.pixbuf_get_from_surface to read pixels
|
|
743
|
-
const pixbuf = Gdk.pixbuf_get_from_surface(this._surface, sx, sy, sw, sh);
|
|
744
|
-
if (!pixbuf) {
|
|
745
|
-
return new OurImageData(sw, sh) as unknown as ImageData;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const pixels = pixbuf.get_pixels();
|
|
749
|
-
const hasAlpha = pixbuf.get_has_alpha();
|
|
750
|
-
const rowstride = pixbuf.get_rowstride();
|
|
751
|
-
const nChannels = pixbuf.get_n_channels();
|
|
752
|
-
const out = new Uint8ClampedArray(sw * sh * 4);
|
|
753
|
-
|
|
754
|
-
for (let y = 0; y < sh; y++) {
|
|
755
|
-
for (let x = 0; x < sw; x++) {
|
|
756
|
-
const srcIdx = y * rowstride + x * nChannels;
|
|
757
|
-
const dstIdx = (y * sw + x) * 4;
|
|
758
|
-
out[dstIdx] = pixels[srcIdx]; // R
|
|
759
|
-
out[dstIdx + 1] = pixels[srcIdx + 1]; // G
|
|
760
|
-
out[dstIdx + 2] = pixels[srcIdx + 2]; // B
|
|
761
|
-
out[dstIdx + 3] = hasAlpha ? pixels[srcIdx + 3] : 255; // A
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
return new OurImageData(out, sw, sh) as unknown as ImageData;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
putImageData(imageData: ImageData, dx: number, dy: number, dirtyX?: number, dirtyY?: number, dirtyWidth?: number, dirtyHeight?: number): void {
|
|
769
|
-
this._ensureSurface();
|
|
770
|
-
|
|
771
|
-
// Determine the dirty region
|
|
772
|
-
const sx = dirtyX ?? 0;
|
|
773
|
-
const sy = dirtyY ?? 0;
|
|
774
|
-
const sw = dirtyWidth ?? imageData.width;
|
|
775
|
-
const sh = dirtyHeight ?? imageData.height;
|
|
776
|
-
|
|
777
|
-
// Create a GdkPixbuf from the ImageData RGBA
|
|
778
|
-
const srcData = imageData.data;
|
|
779
|
-
const srcWidth = imageData.width;
|
|
780
|
-
|
|
781
|
-
// Create a temporary buffer for the dirty region (RGBA, no padding)
|
|
782
|
-
const regionData = new Uint8Array(sw * sh * 4);
|
|
783
|
-
for (let y = 0; y < sh; y++) {
|
|
784
|
-
for (let x = 0; x < sw; x++) {
|
|
785
|
-
const srcIdx = ((sy + y) * srcWidth + (sx + x)) * 4;
|
|
786
|
-
const dstIdx = (y * sw + x) * 4;
|
|
787
|
-
regionData[dstIdx] = srcData[srcIdx];
|
|
788
|
-
regionData[dstIdx + 1] = srcData[srcIdx + 1];
|
|
789
|
-
regionData[dstIdx + 2] = srcData[srcIdx + 2];
|
|
790
|
-
regionData[dstIdx + 3] = srcData[srcIdx + 3];
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
|
|
795
|
-
regionData as unknown as import('@girs/glib-2.0').default.Bytes,
|
|
796
|
-
GdkPixbuf.Colorspace.RGB,
|
|
797
|
-
true, // has_alpha
|
|
798
|
-
8, // bits_per_sample
|
|
799
|
-
sw,
|
|
800
|
-
sh,
|
|
801
|
-
sw * 4, // rowstride
|
|
802
|
-
);
|
|
803
|
-
|
|
804
|
-
// putImageData per spec ignores compositing — always uses SOURCE operator
|
|
805
|
-
this._ctx.save();
|
|
806
|
-
this._ctx.setOperator(Cairo.Operator.SOURCE);
|
|
807
|
-
Gdk.cairo_set_source_pixbuf(this._ctx, pixbuf, dx + sx, dy + sy);
|
|
808
|
-
this._ctx.rectangle(dx + sx, dy + sy, sw, sh);
|
|
809
|
-
this._ctx.fill();
|
|
810
|
-
this._ctx.restore();
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// ---- drawImage ----
|
|
814
|
-
|
|
815
|
-
drawImage(image: unknown, dx: number, dy: number): void;
|
|
816
|
-
drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void;
|
|
817
|
-
drawImage(image: unknown, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
|
|
818
|
-
drawImage(
|
|
819
|
-
image: unknown,
|
|
820
|
-
a1: number, a2: number,
|
|
821
|
-
a3?: number, a4?: number,
|
|
822
|
-
a5?: number, a6?: number,
|
|
823
|
-
a7?: number, a8?: number,
|
|
824
|
-
): void {
|
|
825
|
-
this._ensureSurface();
|
|
826
|
-
this._applyCompositing();
|
|
827
|
-
|
|
828
|
-
let sx: number, sy: number, sw: number, sh: number;
|
|
829
|
-
let dx: number, dy: number, dw: number, dh: number;
|
|
830
|
-
|
|
831
|
-
// Get source surface/pixbuf
|
|
832
|
-
const sourceInfo = this._getDrawImageSource(image);
|
|
833
|
-
if (!sourceInfo) return;
|
|
834
|
-
const { pixbuf, imgWidth, imgHeight } = sourceInfo;
|
|
835
|
-
|
|
836
|
-
if (a3 === undefined) {
|
|
837
|
-
// drawImage(image, dx, dy)
|
|
838
|
-
sx = 0; sy = 0; sw = imgWidth; sh = imgHeight;
|
|
839
|
-
dx = a1; dy = a2; dw = imgWidth; dh = imgHeight;
|
|
840
|
-
} else if (a5 === undefined) {
|
|
841
|
-
// drawImage(image, dx, dy, dw, dh)
|
|
842
|
-
sx = 0; sy = 0; sw = imgWidth; sh = imgHeight;
|
|
843
|
-
dx = a1; dy = a2; dw = a3; dh = a4!;
|
|
844
|
-
} else {
|
|
845
|
-
// drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
|
|
846
|
-
sx = a1; sy = a2; sw = a3; sh = a4!;
|
|
847
|
-
dx = a5; dy = a6!; dw = a7!; dh = a8!;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Spec: drawImage with any zero-width/height source or destination
|
|
851
|
-
// rectangle is a no-op (and MUST NOT throw). Without this guard,
|
|
852
|
-
// `scale(dw / sw, dh / sh)` produces 0 or Infinity which Cairo
|
|
853
|
-
// rejects with "invalid matrix (not invertible)".
|
|
854
|
-
//
|
|
855
|
-
// Non-finite (NaN / Infinity / -Infinity) inputs reach us when the
|
|
856
|
-
// caller derives a dimension from a not-yet-resized canvas (e.g.
|
|
857
|
-
// Excalibur's logo overlay computes `Math.min(logoWidth, n * 0.75)`
|
|
858
|
-
// before the engine's pixelRatio / canvas size are known). Treat
|
|
859
|
-
// them the same as 0: spec-correct, and avoids cascading Cairo
|
|
860
|
-
// matrix failures that abort frames mid-paint.
|
|
861
|
-
if (
|
|
862
|
-
!Number.isFinite(sx) || !Number.isFinite(sy) ||
|
|
863
|
-
!Number.isFinite(sw) || !Number.isFinite(sh) ||
|
|
864
|
-
!Number.isFinite(dx) || !Number.isFinite(dy) ||
|
|
865
|
-
!Number.isFinite(dw) || !Number.isFinite(dh) ||
|
|
866
|
-
sw === 0 || sh === 0 || dw === 0 || dh === 0
|
|
867
|
-
) {
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// Clip to the destination rectangle so the source pattern is only
|
|
872
|
-
// painted inside it; this lets us use paint() (which fills the
|
|
873
|
-
// entire clip) + paintWithAlpha() for globalAlpha support.
|
|
874
|
-
this._ctx.save();
|
|
875
|
-
this._ctx.rectangle(dx, dy, dw, dh);
|
|
876
|
-
this._ctx.clip();
|
|
877
|
-
|
|
878
|
-
// Scale the source to fill the destination
|
|
879
|
-
this._ctx.translate(dx, dy);
|
|
880
|
-
this._ctx.scale(dw / sw, dh / sh);
|
|
881
|
-
this._ctx.translate(-sx, -sy);
|
|
882
|
-
|
|
883
|
-
Gdk.cairo_set_source_pixbuf(this._ctx, pixbuf, 0, 0);
|
|
884
|
-
|
|
885
|
-
// Apply Cairo interpolation filter based on imageSmoothingEnabled +
|
|
886
|
-
// imageSmoothingQuality. setSource installs a fresh SurfacePattern and
|
|
887
|
-
// resets any filter to Cairo's default (BILINEAR), so setFilter MUST
|
|
888
|
-
// be called between setSource and paint. Without this, Excalibur's
|
|
889
|
-
// pixel-art mode (imageSmoothingEnabled=false) renders blurry because
|
|
890
|
-
// Cairo uses bilinear interpolation by default.
|
|
891
|
-
//
|
|
892
|
-
// Cairo.Filter values (verified runtime in GJS 1.86):
|
|
893
|
-
// FAST=0 GOOD=1 BEST=2 NEAREST=3 BILINEAR=4 GAUSSIAN=5
|
|
894
|
-
// GIR typings are missing setFilter on Pattern — `asCairoPattern`
|
|
895
|
-
// narrows to the augmented shape (see cairo-types.ts).
|
|
896
|
-
const pat = asCairoPattern(this._ctx.getSource?.());
|
|
897
|
-
if (pat) {
|
|
898
|
-
let filter: Cairo.Filter;
|
|
899
|
-
if (!this._state.imageSmoothingEnabled) {
|
|
900
|
-
filter = Cairo.Filter.NEAREST;
|
|
901
|
-
} else if (this._state.imageSmoothingQuality === 'high') {
|
|
902
|
-
filter = Cairo.Filter.BEST;
|
|
903
|
-
} else {
|
|
904
|
-
filter = Cairo.Filter.BILINEAR;
|
|
905
|
-
}
|
|
906
|
-
pat.setFilter(filter);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// paint() vs fill(): paint() composites the current source over the
|
|
910
|
-
// current clip region uniformly, honoring paintWithAlpha for global
|
|
911
|
-
// alpha multiplication. fill() would require a rectangle path and
|
|
912
|
-
// doesn't support per-draw alpha, so paint() is the spec-correct
|
|
913
|
-
// choice for drawImage. The clip above confines the paint to dx,dy,dw,dh.
|
|
914
|
-
if (this._state.globalAlpha < 1) {
|
|
915
|
-
this._ctx.paintWithAlpha(this._state.globalAlpha);
|
|
916
|
-
} else {
|
|
917
|
-
this._ctx.paint();
|
|
918
|
-
}
|
|
919
|
-
this._ctx.restore();
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
private _getDrawImageSource(image: unknown): { pixbuf: GdkPixbuf.Pixbuf; imgWidth: number; imgHeight: number } | null {
|
|
923
|
-
// HTMLImageElement (GdkPixbuf-backed)
|
|
924
|
-
if (isPixbufImageSource(image)) {
|
|
925
|
-
const pixbuf = image._pixbuf;
|
|
926
|
-
return { pixbuf, imgWidth: pixbuf.get_width(), imgHeight: pixbuf.get_height() };
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// HTMLCanvasElement with a 2D context
|
|
930
|
-
if (isCanvasImageSource(image)) {
|
|
931
|
-
const w = image.width ?? 0;
|
|
932
|
-
const h = image.height ?? 0;
|
|
933
|
-
// Reject non-positive / non-finite dimensions before they reach
|
|
934
|
-
// GdkPixbuf — `pixbuf_get_from_surface` logs a GLib-CRITICAL on
|
|
935
|
-
// `width > 0 && height > 0` assertion failure for NaN/0 inputs.
|
|
936
|
-
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
|
937
|
-
return null;
|
|
938
|
-
}
|
|
939
|
-
const ctx2d = image.getContext('2d');
|
|
940
|
-
if (ctx2d && typeof ctx2d._getSurface === 'function') {
|
|
941
|
-
const surface = ctx2d._getSurface();
|
|
942
|
-
surface.flush();
|
|
943
|
-
const pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, w, h);
|
|
944
|
-
if (pixbuf) {
|
|
945
|
-
return { pixbuf, imgWidth: w, imgHeight: h };
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
return null;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// ---- Text methods (PangoCairo) ----
|
|
954
|
-
|
|
955
|
-
/** Create a PangoCairo layout configured with current font/text settings. */
|
|
956
|
-
private _createTextLayout(text: string): Pango.Layout {
|
|
957
|
-
const layout = PangoCairo.create_layout(this._ctx);
|
|
958
|
-
layout.set_text(text, -1);
|
|
959
|
-
|
|
960
|
-
// Force LTR base direction so text is never rendered mirrored
|
|
961
|
-
// regardless of system locale or Pango context defaults.
|
|
962
|
-
const pangoCtx = layout.get_context();
|
|
963
|
-
pangoCtx.set_base_dir(Pango.Direction.LTR);
|
|
964
|
-
layout.context_changed();
|
|
965
|
-
|
|
966
|
-
// Parse CSS font string into Pango font description
|
|
967
|
-
const fontDesc = this._parseFontToDescription(this._state.font);
|
|
968
|
-
layout.set_font_description(fontDesc);
|
|
969
|
-
|
|
970
|
-
return layout;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/** Parse a CSS font string (e.g. "bold 16px Arial") into a Pango.FontDescription. */
|
|
974
|
-
private _parseFontToDescription(cssFont: string): Pango.FontDescription {
|
|
975
|
-
// CSS font: [style] [variant] [weight] size[/line-height] family[, family...]
|
|
976
|
-
const match = cssFont.match(
|
|
977
|
-
/^\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
|
|
978
|
-
);
|
|
979
|
-
|
|
980
|
-
if (!match) {
|
|
981
|
-
// Fallback: pass directly to Pango (may have DPI-scaling quirks)
|
|
982
|
-
return Pango.font_description_from_string(cssFont);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
const style = match[1] || '';
|
|
986
|
-
const weight = match[3] || '';
|
|
987
|
-
let size = parseFloat(match[4]) || 10;
|
|
988
|
-
const unit = (match[5] || 'px').toLowerCase();
|
|
989
|
-
const family = (match[6] || 'sans-serif').replace(/['"]/g, '').trim();
|
|
990
|
-
|
|
991
|
-
// Normalise everything to CSS pixels.
|
|
992
|
-
// We use set_absolute_size() below which bypasses Pango's DPI scaling,
|
|
993
|
-
// so 1 CSS pixel == 1 device pixel on a 1:1 surface (standard for Canvas2D).
|
|
994
|
-
if (unit === 'pt') size = size * 96 / 72; // 1pt = 96/72 px
|
|
995
|
-
else if (unit === 'em' || unit === 'rem') size = size * 16; // assume 16px base
|
|
996
|
-
else if (unit === '%') size = (size / 100) * 16;
|
|
997
|
-
// 'px' stays as-is
|
|
998
|
-
|
|
999
|
-
// Build description string WITHOUT size — size is set via set_absolute_size.
|
|
1000
|
-
let pangoStr = family;
|
|
1001
|
-
if (style === 'italic') pangoStr += ' Italic';
|
|
1002
|
-
else if (style === 'oblique') pangoStr += ' Oblique';
|
|
1003
|
-
if (weight === 'bold' || weight === 'bolder' || parseInt(weight) >= 600) pangoStr += ' Bold';
|
|
1004
|
-
else if (weight === 'lighter' || (parseInt(weight) > 0 && parseInt(weight) <= 300)) pangoStr += ' Light';
|
|
1005
|
-
|
|
1006
|
-
const desc = Pango.font_description_from_string(pangoStr);
|
|
1007
|
-
// Absolute size: Pango.SCALE units per device pixel, no DPI conversion.
|
|
1008
|
-
// This ensures "9px Round9x13" renders at exactly 9 pixels — pixel-perfect.
|
|
1009
|
-
desc.set_absolute_size(size * Pango.SCALE);
|
|
1010
|
-
return desc;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Compute the x-offset for text alignment relative to the given x coordinate.
|
|
1015
|
-
*/
|
|
1016
|
-
private _getTextAlignOffset(layout: Pango.Layout): number {
|
|
1017
|
-
const [, logicalRect] = layout.get_pixel_extents();
|
|
1018
|
-
const width = logicalRect.width;
|
|
1019
|
-
|
|
1020
|
-
switch (this._state.textAlign) {
|
|
1021
|
-
case 'center': return -width / 2;
|
|
1022
|
-
case 'right':
|
|
1023
|
-
case 'end': return -width;
|
|
1024
|
-
case 'left':
|
|
1025
|
-
case 'start':
|
|
1026
|
-
default: return 0;
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
/**
|
|
1031
|
-
* Compute the y-offset for text baseline positioning.
|
|
1032
|
-
*
|
|
1033
|
-
* PangoCairo.show_layout() places the layout TOP-LEFT at the current Cairo point
|
|
1034
|
-
* (not the baseline). Within the layout, the first line's baseline is at
|
|
1035
|
-
* approximately `ascent` pixels below the layout top.
|
|
1036
|
-
*
|
|
1037
|
-
* For CSS textBaseline semantics, we shift the current point UP (negative offset)
|
|
1038
|
-
* so the layout top lands at the right position relative to the user's y coordinate.
|
|
1039
|
-
*/
|
|
1040
|
-
private _getTextBaselineOffset(layout: Pango.Layout): number {
|
|
1041
|
-
const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
|
|
1042
|
-
const context = layout.get_context();
|
|
1043
|
-
const metrics = context.get_metrics(fontDesc, null);
|
|
1044
|
-
const ascent = metrics.get_ascent() / Pango.SCALE;
|
|
1045
|
-
const descent = metrics.get_descent() / Pango.SCALE;
|
|
1046
|
-
const height = ascent + descent;
|
|
1047
|
-
|
|
1048
|
-
// layout top = current point; baseline within layout ≈ ascent below top.
|
|
1049
|
-
// yOff is added to user's y to get the layout top-left y.
|
|
1050
|
-
switch (this._state.textBaseline) {
|
|
1051
|
-
case 'top': return 0; // top of em square = y
|
|
1052
|
-
case 'hanging': return -(ascent * 0.2); // hanging ≈ 0.2*ascent below top
|
|
1053
|
-
case 'middle': return -(height / 2); // center of em square = y
|
|
1054
|
-
case 'alphabetic': return -ascent; // baseline = y
|
|
1055
|
-
case 'ideographic': return -(ascent + descent * 0.5); // below alphabetic baseline
|
|
1056
|
-
case 'bottom': return -height; // bottom of em square = y
|
|
1057
|
-
default: return -ascent; // default = alphabetic
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
fillText(text: string, x: number, y: number, _maxWidth?: number): void {
|
|
1062
|
-
this._ensureSurface();
|
|
1063
|
-
this._applyCompositing();
|
|
1064
|
-
|
|
1065
|
-
const layout = this._createTextLayout(text);
|
|
1066
|
-
const xOff = this._getTextAlignOffset(layout);
|
|
1067
|
-
const yOff = this._getTextBaselineOffset(layout);
|
|
1068
|
-
|
|
1069
|
-
// Shadow pass: draw text at offset position with shadowColor.
|
|
1070
|
-
// shadowOffsetX/Y are in CSS pixels (not scaled by CTM per Canvas2D spec),
|
|
1071
|
-
// so we convert them to user-space before applying to moveTo.
|
|
1072
|
-
// shadowBlur is approximated with a 5-tap cross kernel: one center tap at full
|
|
1073
|
-
// alpha plus four arm taps at half alpha, spread by blur_u in each direction.
|
|
1074
|
-
// This simulates Gaussian spreading without an actual blur pass.
|
|
1075
|
-
if (this._hasShadow()) {
|
|
1076
|
-
const sc = parseColor(this._state.shadowColor);
|
|
1077
|
-
if (sc) {
|
|
1078
|
-
const [sdx, sdy] = this._deviceToUserDistance(
|
|
1079
|
-
this._state.shadowOffsetX,
|
|
1080
|
-
this._state.shadowOffsetY,
|
|
1081
|
-
);
|
|
1082
|
-
const blur = this._state.shadowBlur;
|
|
1083
|
-
type Tap = [number, number, number];
|
|
1084
|
-
let taps: Tap[];
|
|
1085
|
-
if (blur > 0) {
|
|
1086
|
-
const [bu] = this._deviceToUserDistance(blur, 0);
|
|
1087
|
-
const [, bv] = this._deviceToUserDistance(0, blur);
|
|
1088
|
-
taps = [
|
|
1089
|
-
[sdx, sdy, sc.a],
|
|
1090
|
-
[sdx + bu, sdy, sc.a * 0.5],
|
|
1091
|
-
[sdx - bu, sdy, sc.a * 0.5],
|
|
1092
|
-
[sdx, sdy + bv, sc.a * 0.5],
|
|
1093
|
-
[sdx, sdy - bv, sc.a * 0.5],
|
|
1094
|
-
];
|
|
1095
|
-
} else {
|
|
1096
|
-
taps = [[sdx, sdy, sc.a]];
|
|
1097
|
-
}
|
|
1098
|
-
const aa = this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE;
|
|
1099
|
-
for (const [tx, ty, ta] of taps) {
|
|
1100
|
-
this._ctx.save();
|
|
1101
|
-
this._ctx.setAntialias(aa);
|
|
1102
|
-
this._ctx.setSourceRGBA(sc.r, sc.g, sc.b, ta);
|
|
1103
|
-
this._ctx.moveTo(x + xOff + tx, y + yOff + ty);
|
|
1104
|
-
PangoCairo.show_layout(this._ctx, layout);
|
|
1105
|
-
this._ctx.restore();
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
this._applyFillStyle();
|
|
1111
|
-
this._ctx.save();
|
|
1112
|
-
// Disable anti-aliasing so pixel/bitmap fonts render crisp (matching browser
|
|
1113
|
-
// behaviour for fonts with no outline hints). cairo_save/restore covers antialias.
|
|
1114
|
-
this._ctx.setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
|
|
1115
|
-
this._ctx.moveTo(x + xOff, y + yOff);
|
|
1116
|
-
PangoCairo.show_layout(this._ctx, layout);
|
|
1117
|
-
this._ctx.restore();
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
strokeText(text: string, x: number, y: number, _maxWidth?: number): void {
|
|
1121
|
-
this._ensureSurface();
|
|
1122
|
-
this._applyCompositing();
|
|
1123
|
-
this._applyStrokeStyle();
|
|
1124
|
-
this._applyLineStyle();
|
|
1125
|
-
|
|
1126
|
-
const layout = this._createTextLayout(text);
|
|
1127
|
-
const xOff = this._getTextAlignOffset(layout);
|
|
1128
|
-
const yOff = this._getTextBaselineOffset(layout);
|
|
1129
|
-
|
|
1130
|
-
this._ctx.save();
|
|
1131
|
-
this._ctx.setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
|
|
1132
|
-
this._ctx.moveTo(x + xOff, y + yOff);
|
|
1133
|
-
PangoCairo.layout_path(this._ctx, layout);
|
|
1134
|
-
this._ctx.stroke();
|
|
1135
|
-
this._ctx.restore();
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
measureText(text: string): TextMetrics {
|
|
1139
|
-
this._ensureSurface();
|
|
1140
|
-
const layout = this._createTextLayout(text);
|
|
1141
|
-
const [inkRect, logicalRect] = layout.get_pixel_extents();
|
|
1142
|
-
|
|
1143
|
-
// Baseline of first line in pixels from layout top (Pango.SCALE units → px).
|
|
1144
|
-
const baselinePx = layout.get_baseline() / Pango.SCALE;
|
|
1145
|
-
|
|
1146
|
-
// actualBoundingBox: ink-based, relative to baseline (positive = above/right of baseline).
|
|
1147
|
-
// inkRect.y is pixels below layout top — compare against baseline to get baseline-relative values.
|
|
1148
|
-
const actualAscent = Math.max(0, baselinePx - inkRect.y);
|
|
1149
|
-
const actualDescent = Math.max(0, (inkRect.y + inkRect.height) - baselinePx);
|
|
1150
|
-
|
|
1151
|
-
// fontBoundingBox: font-level metrics (same for all glyphs at this font/size).
|
|
1152
|
-
const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
|
|
1153
|
-
const metrics = layout.get_context().get_metrics(fontDesc, null);
|
|
1154
|
-
const fontAscent = metrics.get_ascent() / Pango.SCALE;
|
|
1155
|
-
const fontDescent = metrics.get_descent() / Pango.SCALE;
|
|
1156
|
-
|
|
1157
|
-
return {
|
|
1158
|
-
width: logicalRect.width,
|
|
1159
|
-
actualBoundingBoxAscent: actualAscent,
|
|
1160
|
-
actualBoundingBoxDescent: actualDescent,
|
|
1161
|
-
actualBoundingBoxLeft: Math.max(0, -inkRect.x),
|
|
1162
|
-
actualBoundingBoxRight: inkRect.x + inkRect.width,
|
|
1163
|
-
fontBoundingBoxAscent: fontAscent,
|
|
1164
|
-
fontBoundingBoxDescent: fontDescent,
|
|
1165
|
-
alphabeticBaseline: 0,
|
|
1166
|
-
emHeightAscent: fontAscent,
|
|
1167
|
-
emHeightDescent: fontDescent,
|
|
1168
|
-
hangingBaseline: fontAscent * 0.8,
|
|
1169
|
-
ideographicBaseline: -fontDescent,
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// ---- toDataURL/toBlob support ----
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Write the canvas surface to a PNG file and return as data URL.
|
|
1177
|
-
* Used by HTMLCanvasElement.toDataURL() when a '2d' context is active.
|
|
1178
|
-
*/
|
|
1179
|
-
_toDataURL(type?: string, _quality?: number): string {
|
|
1180
|
-
if (type && type !== 'image/png') {
|
|
1181
|
-
// Cairo only supports PNG natively
|
|
1182
|
-
// For other formats, return PNG anyway (per spec, PNG is the required format)
|
|
1183
|
-
}
|
|
1184
|
-
this._surface.flush();
|
|
1185
|
-
|
|
1186
|
-
// Write to a temp file, read back as base64
|
|
1187
|
-
const Gio = imports.gi.Gio;
|
|
1188
|
-
const GLib = imports.gi.GLib;
|
|
1189
|
-
const [, tempPath] = GLib.file_open_tmp('canvas-XXXXXX.png');
|
|
1190
|
-
try {
|
|
1191
|
-
this._surface.writeToPNG(tempPath);
|
|
1192
|
-
const file = Gio.File.new_for_path(tempPath);
|
|
1193
|
-
const [, contents] = file.load_contents(null);
|
|
1194
|
-
const base64 = GLib.base64_encode(contents);
|
|
1195
|
-
return `data:image/png;base64,${base64}`;
|
|
1196
|
-
} finally {
|
|
1197
|
-
try { GLib.unlink(tempPath); } catch (_e) { /* ignore */ }
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// ---- Cleanup ----
|
|
1202
|
-
|
|
1203
|
-
/** Release native Cairo resources. Call when the canvas is discarded. */
|
|
1204
|
-
_dispose(): void {
|
|
1205
|
-
this._ctx.$dispose();
|
|
1206
|
-
this._surface.finish();
|
|
1207
|
-
}
|
|
1208
|
-
}
|