@gjsify/canvas2d 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,981 @@
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 { parseColor } from './color.js';
14
+ import {
15
+ quadraticToCubic,
16
+ cairoArcTo,
17
+ cairoEllipse,
18
+ cairoRoundRect,
19
+ COMPOSITE_OP_MAP,
20
+ LINE_CAP_MAP,
21
+ LINE_JOIN_MAP,
22
+ } from './cairo-utils.js';
23
+ import { type CanvasState, createDefaultState, cloneState } from './canvas-state.js';
24
+ import { OurImageData } from './image-data.js';
25
+ import { CanvasGradient as OurCanvasGradient } from './canvas-gradient.js';
26
+ import { CanvasPattern as OurCanvasPattern } from './canvas-pattern.js';
27
+ import { Path2D } from './canvas-path.js';
28
+
29
+ /**
30
+ * CanvasRenderingContext2D backed by Cairo.ImageSurface.
31
+ * Implements the Canvas 2D API for GJS.
32
+ */
33
+ export class CanvasRenderingContext2D {
34
+ readonly canvas: any;
35
+
36
+ private _surface: Cairo.ImageSurface;
37
+ private _ctx: Cairo.Context;
38
+ private _state: CanvasState;
39
+ private _stateStack: CanvasState[] = [];
40
+ private _surfaceWidth: number;
41
+ private _surfaceHeight: number;
42
+
43
+ constructor(canvas: any, _options?: any) {
44
+ this.canvas = canvas;
45
+ this._surfaceWidth = canvas.width || 300;
46
+ this._surfaceHeight = canvas.height || 150;
47
+ this._surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, this._surfaceWidth, this._surfaceHeight);
48
+ this._ctx = new Cairo.Context(this._surface);
49
+ this._state = createDefaultState();
50
+ }
51
+
52
+ // ---- Internal helpers ----
53
+
54
+ /** Ensure the surface matches the current canvas dimensions. Recreate if resized. */
55
+ private _ensureSurface(): void {
56
+ const w = this.canvas.width || 300;
57
+ const h = this.canvas.height || 150;
58
+ if (w !== this._surfaceWidth || h !== this._surfaceHeight) {
59
+ this._ctx.$dispose();
60
+ this._surface.finish();
61
+ this._surfaceWidth = w;
62
+ this._surfaceHeight = h;
63
+ this._surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
64
+ this._ctx = new Cairo.Context(this._surface);
65
+ this._state = createDefaultState();
66
+ this._stateStack = [];
67
+ }
68
+ }
69
+
70
+ /** Apply the current fill style (color, gradient, or pattern) to the Cairo context. */
71
+ private _applyFillStyle(): void {
72
+ const style = this._state.fillStyle;
73
+ if (typeof style === 'string') {
74
+ const c = this._state.fillColor;
75
+ const a = c.a * this._state.globalAlpha;
76
+ this._ctx.setSourceRGBA(c.r, c.g, c.b, a);
77
+ } else if (style instanceof OurCanvasGradient) {
78
+ this._ctx.setSource(style._getCairoPattern());
79
+ } else if (style instanceof OurCanvasPattern) {
80
+ this._ctx.setSource(style._getCairoPattern());
81
+ }
82
+ }
83
+
84
+ /** Apply the current stroke style to the Cairo context. */
85
+ private _applyStrokeStyle(): void {
86
+ const style = this._state.strokeStyle;
87
+ if (typeof style === 'string') {
88
+ const c = this._state.strokeColor;
89
+ const a = c.a * this._state.globalAlpha;
90
+ this._ctx.setSourceRGBA(c.r, c.g, c.b, a);
91
+ } else if (style instanceof OurCanvasGradient) {
92
+ this._ctx.setSource(style._getCairoPattern());
93
+ } else if (style instanceof OurCanvasPattern) {
94
+ this._ctx.setSource(style._getCairoPattern());
95
+ }
96
+ }
97
+
98
+ /** Apply line properties to the Cairo context. */
99
+ private _applyLineStyle(): void {
100
+ this._ctx.setLineWidth(this._state.lineWidth);
101
+ this._ctx.setLineCap(LINE_CAP_MAP[this._state.lineCap] as Cairo.LineCap);
102
+ this._ctx.setLineJoin(LINE_JOIN_MAP[this._state.lineJoin] as Cairo.LineJoin);
103
+ this._ctx.setMiterLimit(this._state.miterLimit);
104
+ this._ctx.setDash(this._state.lineDash, this._state.lineDashOffset);
105
+ }
106
+
107
+ /** Apply compositing operator. */
108
+ private _applyCompositing(): void {
109
+ const op = COMPOSITE_OP_MAP[this._state.globalCompositeOperation];
110
+ if (op !== undefined) {
111
+ this._ctx.setOperator(op as Cairo.Operator);
112
+ }
113
+ }
114
+
115
+ /** Get the Cairo ImageSurface (used by other contexts like drawImage). */
116
+ _getSurface(): Cairo.ImageSurface {
117
+ return this._surface;
118
+ }
119
+
120
+ /** Check if shadow rendering is needed. */
121
+ private _hasShadow(): boolean {
122
+ if (this._state.shadowBlur === 0 && this._state.shadowOffsetX === 0 && this._state.shadowOffsetY === 0) {
123
+ return false;
124
+ }
125
+ const c = parseColor(this._state.shadowColor);
126
+ return c !== null && c.a > 0;
127
+ }
128
+
129
+ /**
130
+ * Render a shadow for the current path by painting to a temp surface,
131
+ * applying a simple box blur approximation, and compositing back.
132
+ * This is called before the actual fill/stroke when shadows are active.
133
+ */
134
+ private _renderShadow(drawOp: () => void): void {
135
+ const blur = this._state.shadowBlur;
136
+ const offX = this._state.shadowOffsetX;
137
+ const offY = this._state.shadowOffsetY;
138
+ const color = parseColor(this._state.shadowColor);
139
+ if (!color) return;
140
+
141
+ const pad = Math.ceil(blur * 2);
142
+ const w = this._surfaceWidth + pad * 2;
143
+ const h = this._surfaceHeight + pad * 2;
144
+
145
+ // Create temp surface for shadow
146
+ const shadowSurface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
147
+ const shadowCtx = new Cairo.Context(shadowSurface);
148
+
149
+ // Copy the current path/state to the shadow context and draw in shadow color
150
+ shadowCtx.translate(pad, pad);
151
+ shadowCtx.setSourceRGBA(color.r, color.g, color.b, color.a * this._state.globalAlpha);
152
+ drawOp.call(this);
153
+ // We can't easily replay the path on a different context without Path2D,
154
+ // so shadow support is approximate: we just paint the shadow color under the actual draw
155
+ shadowCtx.$dispose();
156
+ shadowSurface.finish();
157
+
158
+ // For now, apply shadow as a simple offset + color overlay
159
+ // Full Gaussian blur would require pixel manipulation (Phase 5 enhancement)
160
+ this._ctx.save();
161
+ this._applyCompositing();
162
+ this._ctx.setSourceRGBA(color.r, color.g, color.b, color.a * this._state.globalAlpha);
163
+ this._ctx.translate(offX, offY);
164
+ // Re-fill/stroke the current path with shadow color
165
+ drawOp();
166
+ this._ctx.restore();
167
+ }
168
+
169
+ // ---- State ----
170
+
171
+ save(): void {
172
+ this._ensureSurface();
173
+ this._stateStack.push(cloneState(this._state));
174
+ this._ctx.save();
175
+ }
176
+
177
+ restore(): void {
178
+ this._ensureSurface();
179
+ const prev = this._stateStack.pop();
180
+ if (prev) {
181
+ this._state = prev;
182
+ this._ctx.restore();
183
+ }
184
+ }
185
+
186
+ // ---- Transforms ----
187
+
188
+ translate(x: number, y: number): void {
189
+ this._ensureSurface();
190
+ this._ctx.translate(x, y);
191
+ }
192
+
193
+ rotate(angle: number): void {
194
+ this._ensureSurface();
195
+ this._ctx.rotate(angle);
196
+ }
197
+
198
+ scale(x: number, y: number): void {
199
+ this._ensureSurface();
200
+ this._ctx.scale(x, y);
201
+ }
202
+
203
+ /**
204
+ * Multiply the current transformation matrix by the given values.
205
+ * Matrix: [a c e]
206
+ * [b d f]
207
+ * [0 0 1]
208
+ */
209
+ transform(a: number, b: number, c: number, d: number, e: number, f: number): void {
210
+ this._ensureSurface();
211
+ // Cairo's matrix constructor: Matrix(xx, yx, xy, yy, x0, y0)
212
+ // Canvas matrix [a,b,c,d,e,f] maps to Cairo Matrix(a, b, c, d, e, f)
213
+ const matrix = new Cairo.Matrix();
214
+ (matrix as any).init(a, b, c, d, e, f);
215
+ (this._ctx as any).transform(matrix);
216
+ }
217
+
218
+ /**
219
+ * Reset the transform to identity, then apply the given matrix.
220
+ */
221
+ setTransform(matrix?: DOMMatrix2DInit): void;
222
+ setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;
223
+ setTransform(a?: number | DOMMatrix2DInit, b?: number, c?: number, d?: number, e?: number, f?: number): void {
224
+ this._ensureSurface();
225
+ if (typeof a === 'object' && a !== null) {
226
+ const m = a;
227
+ this._ctx.identityMatrix();
228
+ this.transform(
229
+ m.a ?? m.m11 ?? 1, m.b ?? m.m12 ?? 0,
230
+ m.c ?? m.m21 ?? 0, m.d ?? m.m22 ?? 1,
231
+ m.e ?? m.m41 ?? 0, m.f ?? m.m42 ?? 0,
232
+ );
233
+ } else if (typeof a === 'number') {
234
+ this._ctx.identityMatrix();
235
+ this.transform(a, b!, c!, d!, e!, f!);
236
+ } else {
237
+ this._ctx.identityMatrix();
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Return the current transformation matrix as a DOMMatrix-like object.
243
+ */
244
+ getTransform(): DOMMatrix {
245
+ // Cairo doesn't expose getMatrix in GJS types, but it exists at runtime
246
+ const m = (this._ctx as any).getMatrix?.();
247
+ if (m) {
248
+ // Cairo Matrix fields: xx, yx, xy, yy, x0, y0
249
+ return {
250
+ a: m.xx ?? 1, b: m.yx ?? 0,
251
+ c: m.xy ?? 0, d: m.yy ?? 1,
252
+ e: m.x0 ?? 0, f: m.y0 ?? 0,
253
+ m11: m.xx ?? 1, m12: m.yx ?? 0,
254
+ m13: 0, m14: 0,
255
+ m21: m.xy ?? 0, m22: m.yy ?? 1,
256
+ m23: 0, m24: 0,
257
+ m31: 0, m32: 0, m33: 1, m34: 0,
258
+ m41: m.x0 ?? 0, m42: m.y0 ?? 0,
259
+ m43: 0, m44: 1,
260
+ is2D: true,
261
+ isIdentity: (m.xx === 1 && m.yx === 0 && m.xy === 0 && m.yy === 1 && m.x0 === 0 && m.y0 === 0),
262
+ } as any;
263
+ }
264
+ // Fallback: return identity
265
+ return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, is2D: true, isIdentity: true } as any;
266
+ }
267
+
268
+ resetTransform(): void {
269
+ this._ensureSurface();
270
+ this._ctx.identityMatrix();
271
+ }
272
+
273
+ // ---- Style properties ----
274
+
275
+ get fillStyle(): string | CanvasGradient | CanvasPattern {
276
+ return this._state.fillStyle;
277
+ }
278
+
279
+ set fillStyle(value: string | CanvasGradient | CanvasPattern) {
280
+ if (typeof value === 'string') {
281
+ const parsed = parseColor(value);
282
+ if (parsed) {
283
+ this._state.fillStyle = value;
284
+ this._state.fillColor = parsed;
285
+ }
286
+ } else {
287
+ this._state.fillStyle = value;
288
+ }
289
+ }
290
+
291
+ get strokeStyle(): string | CanvasGradient | CanvasPattern {
292
+ return this._state.strokeStyle;
293
+ }
294
+
295
+ set strokeStyle(value: string | CanvasGradient | CanvasPattern) {
296
+ if (typeof value === 'string') {
297
+ const parsed = parseColor(value);
298
+ if (parsed) {
299
+ this._state.strokeStyle = value;
300
+ this._state.strokeColor = parsed;
301
+ }
302
+ } else {
303
+ this._state.strokeStyle = value;
304
+ }
305
+ }
306
+
307
+ get lineWidth(): number { return this._state.lineWidth; }
308
+ set lineWidth(value: number) {
309
+ if (value > 0 && isFinite(value)) this._state.lineWidth = value;
310
+ }
311
+
312
+ get lineCap(): CanvasLineCap { return this._state.lineCap; }
313
+ set lineCap(value: CanvasLineCap) {
314
+ if (value === 'butt' || value === 'round' || value === 'square') {
315
+ this._state.lineCap = value;
316
+ }
317
+ }
318
+
319
+ get lineJoin(): CanvasLineJoin { return this._state.lineJoin; }
320
+ set lineJoin(value: CanvasLineJoin) {
321
+ if (value === 'miter' || value === 'round' || value === 'bevel') {
322
+ this._state.lineJoin = value;
323
+ }
324
+ }
325
+
326
+ get miterLimit(): number { return this._state.miterLimit; }
327
+ set miterLimit(value: number) {
328
+ if (value > 0 && isFinite(value)) this._state.miterLimit = value;
329
+ }
330
+
331
+ get globalAlpha(): number { return this._state.globalAlpha; }
332
+ set globalAlpha(value: number) {
333
+ if (value >= 0 && value <= 1 && isFinite(value)) this._state.globalAlpha = value;
334
+ }
335
+
336
+ get globalCompositeOperation(): GlobalCompositeOperation {
337
+ return this._state.globalCompositeOperation;
338
+ }
339
+
340
+ set globalCompositeOperation(value: GlobalCompositeOperation) {
341
+ if (COMPOSITE_OP_MAP[value] !== undefined) {
342
+ this._state.globalCompositeOperation = value;
343
+ }
344
+ }
345
+
346
+ get imageSmoothingEnabled(): boolean { return this._state.imageSmoothingEnabled; }
347
+ set imageSmoothingEnabled(value: boolean) { this._state.imageSmoothingEnabled = !!value; }
348
+
349
+ get imageSmoothingQuality(): ImageSmoothingQuality { return this._state.imageSmoothingQuality; }
350
+ set imageSmoothingQuality(value: ImageSmoothingQuality) {
351
+ if (value === 'low' || value === 'medium' || value === 'high') {
352
+ this._state.imageSmoothingQuality = value;
353
+ }
354
+ }
355
+
356
+ // Line dash
357
+ setLineDash(segments: number[]): void {
358
+ // Per spec, ignore if any value is negative or non-finite
359
+ if (segments.some(v => v < 0 || !isFinite(v))) return;
360
+ this._state.lineDash = [...segments];
361
+ }
362
+
363
+ getLineDash(): number[] {
364
+ return [...this._state.lineDash];
365
+ }
366
+
367
+ get lineDashOffset(): number { return this._state.lineDashOffset; }
368
+ set lineDashOffset(value: number) {
369
+ if (isFinite(value)) this._state.lineDashOffset = value;
370
+ }
371
+
372
+ // ---- Shadow properties (stored in state, rendering in Phase 5) ----
373
+ get shadowColor(): string { return this._state.shadowColor; }
374
+ set shadowColor(value: string) { this._state.shadowColor = value; }
375
+ get shadowBlur(): number { return this._state.shadowBlur; }
376
+ set shadowBlur(value: number) { if (value >= 0 && isFinite(value)) this._state.shadowBlur = value; }
377
+ get shadowOffsetX(): number { return this._state.shadowOffsetX; }
378
+ set shadowOffsetX(value: number) { if (isFinite(value)) this._state.shadowOffsetX = value; }
379
+ get shadowOffsetY(): number { return this._state.shadowOffsetY; }
380
+ set shadowOffsetY(value: number) { if (isFinite(value)) this._state.shadowOffsetY = value; }
381
+
382
+ // ---- Text properties (stored in state, rendering in Phase 4) ----
383
+ get font(): string { return this._state.font; }
384
+ set font(value: string) { this._state.font = value; }
385
+ get textAlign(): CanvasTextAlign { return this._state.textAlign; }
386
+ set textAlign(value: CanvasTextAlign) { this._state.textAlign = value; }
387
+ get textBaseline(): CanvasTextBaseline { return this._state.textBaseline; }
388
+ set textBaseline(value: CanvasTextBaseline) { this._state.textBaseline = value; }
389
+ get direction(): CanvasDirection { return this._state.direction; }
390
+ set direction(value: CanvasDirection) { this._state.direction = value; }
391
+
392
+ // ---- Path methods ----
393
+
394
+ beginPath(): void {
395
+ this._ensureSurface();
396
+ this._ctx.newPath();
397
+ }
398
+
399
+ moveTo(x: number, y: number): void {
400
+ this._ensureSurface();
401
+ this._ctx.moveTo(x, y);
402
+ }
403
+
404
+ lineTo(x: number, y: number): void {
405
+ this._ensureSurface();
406
+ this._ctx.lineTo(x, y);
407
+ }
408
+
409
+ closePath(): void {
410
+ this._ensureSurface();
411
+ this._ctx.closePath();
412
+ }
413
+
414
+ bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void {
415
+ this._ensureSurface();
416
+ this._ctx.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
417
+ }
418
+
419
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
420
+ this._ensureSurface();
421
+ let cx: number, cy: number;
422
+ if (this._ctx.hasCurrentPoint()) {
423
+ [cx, cy] = this._ctx.getCurrentPoint();
424
+ } else {
425
+ cx = cpx;
426
+ cy = cpy;
427
+ }
428
+ const { cp1x, cp1y, cp2x, cp2y } = quadraticToCubic(cx, cy, cpx, cpy, x, y);
429
+ this._ctx.curveTo(cp1x, cp1y, cp2x, cp2y, x, y);
430
+ }
431
+
432
+ arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise = false): void {
433
+ this._ensureSurface();
434
+ // Browsers draw a full circle when |endAngle - startAngle| >= 2π,
435
+ // regardless of direction. Cairo's arcNegative would produce a
436
+ // zero-length arc for arcNegative(x,y,r,0,2π) because it normalizes
437
+ // endAngle to be < startAngle, collapsing the arc to nothing.
438
+ if (Math.abs(endAngle - startAngle) >= 2 * Math.PI) {
439
+ this._ctx.arc(x, y, radius, 0, 2 * Math.PI);
440
+ return;
441
+ }
442
+ if (counterclockwise) {
443
+ this._ctx.arcNegative(x, y, radius, startAngle, endAngle);
444
+ } else {
445
+ this._ctx.arc(x, y, radius, startAngle, endAngle);
446
+ }
447
+ }
448
+
449
+ arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void {
450
+ this._ensureSurface();
451
+ let x0: number, y0: number;
452
+ if (this._ctx.hasCurrentPoint()) {
453
+ [x0, y0] = this._ctx.getCurrentPoint();
454
+ } else {
455
+ x0 = x1;
456
+ y0 = y1;
457
+ this._ctx.moveTo(x1, y1);
458
+ }
459
+ cairoArcTo(this._ctx, x0, y0, x1, y1, x2, y2, radius);
460
+ }
461
+
462
+ ellipse(
463
+ x: number, y: number,
464
+ radiusX: number, radiusY: number,
465
+ rotation: number,
466
+ startAngle: number, endAngle: number,
467
+ counterclockwise = false,
468
+ ): void {
469
+ this._ensureSurface();
470
+ if (radiusX < 0 || radiusY < 0) {
471
+ throw new RangeError('The radii provided are negative');
472
+ }
473
+ cairoEllipse(this._ctx, x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise);
474
+ }
475
+
476
+ rect(x: number, y: number, w: number, h: number): void {
477
+ this._ensureSurface();
478
+ this._ctx.rectangle(x, y, w, h);
479
+ }
480
+
481
+ roundRect(x: number, y: number, w: number, h: number, radii: number | number[] = 0): void {
482
+ this._ensureSurface();
483
+ cairoRoundRect(this._ctx, x, y, w, h, radii);
484
+ }
485
+
486
+ // ---- Drawing methods ----
487
+
488
+ fill(fillRule?: CanvasFillRule): void;
489
+ fill(path: Path2D, fillRule?: CanvasFillRule): void;
490
+ fill(pathOrRule?: Path2D | CanvasFillRule, fillRule?: CanvasFillRule): void {
491
+ this._ensureSurface();
492
+ this._applyCompositing();
493
+ this._applyFillStyle();
494
+
495
+ let rule: CanvasFillRule | undefined;
496
+ if (pathOrRule instanceof Path2D) {
497
+ this._ctx.newPath();
498
+ pathOrRule._replayOnCairo(this._ctx);
499
+ rule = fillRule;
500
+ } else {
501
+ rule = pathOrRule;
502
+ }
503
+
504
+ this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
505
+ this._ctx.fillPreserve();
506
+ }
507
+
508
+ stroke(): void;
509
+ stroke(path: Path2D): void;
510
+ stroke(path?: Path2D): void {
511
+ this._ensureSurface();
512
+ this._applyCompositing();
513
+ this._applyStrokeStyle();
514
+ this._applyLineStyle();
515
+
516
+ if (path instanceof Path2D) {
517
+ this._ctx.newPath();
518
+ path._replayOnCairo(this._ctx);
519
+ }
520
+
521
+ this._ctx.strokePreserve();
522
+ }
523
+
524
+ fillRect(x: number, y: number, w: number, h: number): void {
525
+ this._ensureSurface();
526
+ this._applyCompositing();
527
+ // Per spec: fillRect must not affect the current path.
528
+ // Save current path, draw the rect in an isolated path, then restore.
529
+ const savedPath = this._ctx.copyPath();
530
+ if (this._hasShadow()) {
531
+ this._renderShadow(() => {
532
+ this._ctx.newPath();
533
+ this._ctx.rectangle(x, y, w, h);
534
+ this._ctx.fill();
535
+ });
536
+ }
537
+ this._applyFillStyle();
538
+ this._ctx.newPath();
539
+ this._ctx.rectangle(x, y, w, h);
540
+ this._ctx.fill();
541
+ this._ctx.newPath();
542
+ this._ctx.appendPath(savedPath);
543
+ }
544
+
545
+ strokeRect(x: number, y: number, w: number, h: number): void {
546
+ this._ensureSurface();
547
+ this._applyCompositing();
548
+ // Per spec: strokeRect must not affect the current path.
549
+ const savedPath = this._ctx.copyPath();
550
+ if (this._hasShadow()) {
551
+ this._renderShadow(() => {
552
+ this._ctx.newPath();
553
+ this._ctx.rectangle(x, y, w, h);
554
+ this._ctx.stroke();
555
+ });
556
+ }
557
+ this._applyStrokeStyle();
558
+ this._applyLineStyle();
559
+ this._ctx.newPath();
560
+ this._ctx.rectangle(x, y, w, h);
561
+ this._ctx.stroke();
562
+ this._ctx.newPath();
563
+ this._ctx.appendPath(savedPath);
564
+ }
565
+
566
+ clearRect(x: number, y: number, w: number, h: number): void {
567
+ this._ensureSurface();
568
+ // Per spec: clearRect must not affect the current path.
569
+ const savedPath = this._ctx.copyPath();
570
+ this._ctx.save();
571
+ this._ctx.setOperator(Cairo.Operator.CLEAR);
572
+ this._ctx.newPath();
573
+ this._ctx.rectangle(x, y, w, h);
574
+ this._ctx.fill();
575
+ this._ctx.restore();
576
+ this._ctx.newPath();
577
+ this._ctx.appendPath(savedPath);
578
+ }
579
+
580
+ // ---- Clipping ----
581
+
582
+ clip(fillRule?: CanvasFillRule): void;
583
+ clip(path: Path2D, fillRule?: CanvasFillRule): void;
584
+ clip(pathOrRule?: Path2D | CanvasFillRule, fillRule?: CanvasFillRule): void {
585
+ this._ensureSurface();
586
+ let rule: CanvasFillRule | undefined;
587
+ if (pathOrRule instanceof Path2D) {
588
+ this._ctx.newPath();
589
+ pathOrRule._replayOnCairo(this._ctx);
590
+ rule = fillRule;
591
+ } else {
592
+ rule = pathOrRule;
593
+ }
594
+ this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
595
+ this._ctx.clip();
596
+ }
597
+
598
+ // ---- Hit testing ----
599
+
600
+ isPointInPath(x: number, y: number, fillRule?: CanvasFillRule): boolean;
601
+ isPointInPath(path: Path2D, x: number, y: number, fillRule?: CanvasFillRule): boolean;
602
+ isPointInPath(pathOrX: Path2D | number, xOrY: number, fillRuleOrY?: CanvasFillRule | number, fillRule?: CanvasFillRule): boolean {
603
+ this._ensureSurface();
604
+ let x: number, y: number, rule: CanvasFillRule | undefined;
605
+ if (pathOrX instanceof Path2D) {
606
+ this._ctx.newPath();
607
+ pathOrX._replayOnCairo(this._ctx);
608
+ x = xOrY; y = fillRuleOrY as number; rule = fillRule;
609
+ } else {
610
+ x = pathOrX; y = xOrY; rule = fillRuleOrY as CanvasFillRule | undefined;
611
+ }
612
+ this._ctx.setFillRule(rule === 'evenodd' ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING);
613
+ return this._ctx.inFill(x, y);
614
+ }
615
+
616
+ isPointInStroke(x: number, y: number): boolean;
617
+ isPointInStroke(path: Path2D, x: number, y: number): boolean;
618
+ isPointInStroke(pathOrX: Path2D | number, xOrY: number, y?: number): boolean {
619
+ this._ensureSurface();
620
+ this._applyLineStyle();
621
+ if (pathOrX instanceof Path2D) {
622
+ this._ctx.newPath();
623
+ pathOrX._replayOnCairo(this._ctx);
624
+ return this._ctx.inStroke(xOrY, y!);
625
+ }
626
+ return this._ctx.inStroke(pathOrX, xOrY);
627
+ }
628
+
629
+ // ---- Gradient / Pattern factories ----
630
+
631
+ createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient {
632
+ return new OurCanvasGradient('linear', x0, y0, x1, y1) as any;
633
+ }
634
+
635
+ createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient {
636
+ return new OurCanvasGradient('radial', x0, y0, x1, y1, r0, r1) as any;
637
+ }
638
+
639
+ createPattern(image: any, repetition: string | null): CanvasPattern | null {
640
+ return OurCanvasPattern.create(image, repetition) as any;
641
+ }
642
+
643
+ // ---- Image data methods ----
644
+
645
+ createImageData(sw: number, sh: number): ImageData;
646
+ createImageData(imagedata: ImageData): ImageData;
647
+ createImageData(swOrImageData: number | ImageData, sh?: number): ImageData {
648
+ if (typeof swOrImageData === 'number') {
649
+ return new OurImageData(Math.abs(swOrImageData), Math.abs(sh!)) as any;
650
+ }
651
+ return new OurImageData(swOrImageData.width, swOrImageData.height) as any;
652
+ }
653
+
654
+ getImageData(sx: number, sy: number, sw: number, sh: number): ImageData {
655
+ this._ensureSurface();
656
+ this._surface.flush();
657
+
658
+ // Use Gdk.pixbuf_get_from_surface to read pixels
659
+ const pixbuf = Gdk.pixbuf_get_from_surface(this._surface, sx, sy, sw, sh);
660
+ if (!pixbuf) {
661
+ return new OurImageData(sw, sh) as any;
662
+ }
663
+
664
+ const pixels = pixbuf.get_pixels();
665
+ const hasAlpha = pixbuf.get_has_alpha();
666
+ const rowstride = pixbuf.get_rowstride();
667
+ const nChannels = pixbuf.get_n_channels();
668
+ const out = new Uint8ClampedArray(sw * sh * 4);
669
+
670
+ for (let y = 0; y < sh; y++) {
671
+ for (let x = 0; x < sw; x++) {
672
+ const srcIdx = y * rowstride + x * nChannels;
673
+ const dstIdx = (y * sw + x) * 4;
674
+ out[dstIdx] = pixels[srcIdx]; // R
675
+ out[dstIdx + 1] = pixels[srcIdx + 1]; // G
676
+ out[dstIdx + 2] = pixels[srcIdx + 2]; // B
677
+ out[dstIdx + 3] = hasAlpha ? pixels[srcIdx + 3] : 255; // A
678
+ }
679
+ }
680
+
681
+ return new OurImageData(out, sw, sh) as any;
682
+ }
683
+
684
+ putImageData(imageData: ImageData, dx: number, dy: number, dirtyX?: number, dirtyY?: number, dirtyWidth?: number, dirtyHeight?: number): void {
685
+ this._ensureSurface();
686
+
687
+ // Determine the dirty region
688
+ const sx = dirtyX ?? 0;
689
+ const sy = dirtyY ?? 0;
690
+ const sw = dirtyWidth ?? imageData.width;
691
+ const sh = dirtyHeight ?? imageData.height;
692
+
693
+ // Create a GdkPixbuf from the ImageData RGBA
694
+ const srcData = imageData.data;
695
+ const srcWidth = imageData.width;
696
+
697
+ // Create a temporary buffer for the dirty region (RGBA, no padding)
698
+ const regionData = new Uint8Array(sw * sh * 4);
699
+ for (let y = 0; y < sh; y++) {
700
+ for (let x = 0; x < sw; x++) {
701
+ const srcIdx = ((sy + y) * srcWidth + (sx + x)) * 4;
702
+ const dstIdx = (y * sw + x) * 4;
703
+ regionData[dstIdx] = srcData[srcIdx];
704
+ regionData[dstIdx + 1] = srcData[srcIdx + 1];
705
+ regionData[dstIdx + 2] = srcData[srcIdx + 2];
706
+ regionData[dstIdx + 3] = srcData[srcIdx + 3];
707
+ }
708
+ }
709
+
710
+ const pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
711
+ regionData as unknown as import('@girs/glib-2.0').default.Bytes,
712
+ GdkPixbuf.Colorspace.RGB,
713
+ true, // has_alpha
714
+ 8, // bits_per_sample
715
+ sw,
716
+ sh,
717
+ sw * 4, // rowstride
718
+ );
719
+
720
+ // putImageData per spec ignores compositing — always uses SOURCE operator
721
+ this._ctx.save();
722
+ this._ctx.setOperator(Cairo.Operator.SOURCE);
723
+ Gdk.cairo_set_source_pixbuf(this._ctx as any, pixbuf, dx + sx, dy + sy);
724
+ this._ctx.rectangle(dx + sx, dy + sy, sw, sh);
725
+ this._ctx.fill();
726
+ this._ctx.restore();
727
+ }
728
+
729
+ // ---- drawImage ----
730
+
731
+ drawImage(image: any, dx: number, dy: number): void;
732
+ drawImage(image: any, dx: number, dy: number, dw: number, dh: number): void;
733
+ drawImage(image: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
734
+ drawImage(
735
+ image: any,
736
+ a1: number, a2: number,
737
+ a3?: number, a4?: number,
738
+ a5?: number, a6?: number,
739
+ a7?: number, a8?: number,
740
+ ): void {
741
+ this._ensureSurface();
742
+ this._applyCompositing();
743
+
744
+ let sx: number, sy: number, sw: number, sh: number;
745
+ let dx: number, dy: number, dw: number, dh: number;
746
+
747
+ // Get source surface/pixbuf
748
+ const sourceInfo = this._getDrawImageSource(image);
749
+ if (!sourceInfo) return;
750
+ const { pixbuf, imgWidth, imgHeight } = sourceInfo;
751
+
752
+ if (a3 === undefined) {
753
+ // drawImage(image, dx, dy)
754
+ sx = 0; sy = 0; sw = imgWidth; sh = imgHeight;
755
+ dx = a1; dy = a2; dw = imgWidth; dh = imgHeight;
756
+ } else if (a5 === undefined) {
757
+ // drawImage(image, dx, dy, dw, dh)
758
+ sx = 0; sy = 0; sw = imgWidth; sh = imgHeight;
759
+ dx = a1; dy = a2; dw = a3; dh = a4!;
760
+ } else {
761
+ // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
762
+ sx = a1; sy = a2; sw = a3; sh = a4!;
763
+ dx = a5; dy = a6!; dw = a7!; dh = a8!;
764
+ }
765
+
766
+ // Scale the source to fill the destination
767
+ this._ctx.save();
768
+ this._ctx.translate(dx, dy);
769
+ this._ctx.scale(dw / sw, dh / sh);
770
+ this._ctx.translate(-sx, -sy);
771
+
772
+ Gdk.cairo_set_source_pixbuf(this._ctx as any, pixbuf, 0, 0);
773
+ this._ctx.rectangle(sx, sy, sw, sh);
774
+ this._ctx.fill();
775
+ this._ctx.restore();
776
+ }
777
+
778
+ private _getDrawImageSource(image: any): { pixbuf: GdkPixbuf.Pixbuf; imgWidth: number; imgHeight: number } | null {
779
+ // HTMLImageElement (GdkPixbuf-backed)
780
+ if (typeof image?.isPixbuf === 'function' && image.isPixbuf()) {
781
+ const pixbuf = image._pixbuf as GdkPixbuf.Pixbuf;
782
+ return { pixbuf, imgWidth: pixbuf.get_width(), imgHeight: pixbuf.get_height() };
783
+ }
784
+
785
+ // HTMLCanvasElement with a 2D context
786
+ if (typeof image?.getContext === 'function') {
787
+ const ctx2d = image.getContext('2d');
788
+ if (ctx2d && typeof ctx2d._getSurface === 'function') {
789
+ const surface = ctx2d._getSurface() as Cairo.ImageSurface;
790
+ surface.flush();
791
+ const pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, image.width, image.height);
792
+ if (pixbuf) {
793
+ return { pixbuf, imgWidth: image.width, imgHeight: image.height };
794
+ }
795
+ }
796
+ }
797
+
798
+ return null;
799
+ }
800
+
801
+ // ---- Text methods (PangoCairo) ----
802
+
803
+ /** Create a PangoCairo layout configured with current font/text settings. */
804
+ private _createTextLayout(text: string): Pango.Layout {
805
+ const layout = PangoCairo.create_layout(this._ctx as any);
806
+ layout.set_text(text, -1);
807
+
808
+ // Parse CSS font string into Pango font description
809
+ const fontDesc = this._parseFontToDescription(this._state.font);
810
+ layout.set_font_description(fontDesc);
811
+
812
+ return layout;
813
+ }
814
+
815
+ /** Parse a CSS font string (e.g. "bold 16px Arial") into a Pango.FontDescription. */
816
+ private _parseFontToDescription(cssFont: string): Pango.FontDescription {
817
+ // CSS font: [style] [variant] [weight] size[/line-height] family[, family...]
818
+ // Pango expects: "Family Weight Style Size" format
819
+ const match = cssFont.match(
820
+ /^\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
821
+ );
822
+
823
+ if (!match) {
824
+ // Fallback: pass directly to Pango
825
+ return Pango.font_description_from_string(cssFont);
826
+ }
827
+
828
+ const style = match[1] || '';
829
+ const weight = match[3] || '';
830
+ let size = parseFloat(match[4]) || 10;
831
+ const unit = match[5] || 'px';
832
+ const family = (match[6] || 'sans-serif').replace(/['"]/g, '').trim();
833
+
834
+ // Convert units to points (Pango uses points)
835
+ if (unit === 'px') size = size * 0.75; // 1px = 0.75pt approximately
836
+ else if (unit === 'em' || unit === 'rem') size = size * 12; // assume 16px base = 12pt
837
+ else if (unit === '%') size = (size / 100) * 12;
838
+
839
+ let pangoStr = family;
840
+ if (style === 'italic') pangoStr += ' Italic';
841
+ else if (style === 'oblique') pangoStr += ' Oblique';
842
+ if (weight === 'bold' || weight === 'bolder' || (parseInt(weight) >= 600)) pangoStr += ' Bold';
843
+ else if (weight === 'lighter' || (parseInt(weight) > 0 && parseInt(weight) <= 300)) pangoStr += ' Light';
844
+ pangoStr += ` ${Math.round(size)}`;
845
+
846
+ return Pango.font_description_from_string(pangoStr);
847
+ }
848
+
849
+ /**
850
+ * Compute the x-offset for text alignment relative to the given x coordinate.
851
+ */
852
+ private _getTextAlignOffset(layout: Pango.Layout): number {
853
+ const [, logicalRect] = layout.get_pixel_extents();
854
+ const width = logicalRect.width;
855
+
856
+ switch (this._state.textAlign) {
857
+ case 'center': return -width / 2;
858
+ case 'right':
859
+ case 'end': return -width;
860
+ case 'left':
861
+ case 'start':
862
+ default: return 0;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Compute the y-offset for text baseline positioning.
868
+ */
869
+ private _getTextBaselineOffset(layout: Pango.Layout): number {
870
+ const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
871
+ const context = layout.get_context();
872
+ const metrics = context.get_metrics(fontDesc, null);
873
+ const ascent = metrics.get_ascent() / Pango.SCALE;
874
+ const descent = metrics.get_descent() / Pango.SCALE;
875
+ const height = ascent + descent;
876
+
877
+ switch (this._state.textBaseline) {
878
+ case 'top': return ascent;
879
+ case 'hanging': return ascent * 0.8;
880
+ case 'middle': return ascent - height / 2;
881
+ case 'alphabetic': return 0;
882
+ case 'ideographic': return -descent * 0.5;
883
+ case 'bottom': return -descent;
884
+ default: return 0;
885
+ }
886
+ }
887
+
888
+ fillText(text: string, x: number, y: number, _maxWidth?: number): void {
889
+ this._ensureSurface();
890
+ this._applyCompositing();
891
+ this._applyFillStyle();
892
+
893
+ const layout = this._createTextLayout(text);
894
+ const xOff = this._getTextAlignOffset(layout);
895
+ const yOff = this._getTextBaselineOffset(layout);
896
+
897
+ this._ctx.save();
898
+ this._ctx.moveTo(x + xOff, y + yOff);
899
+ PangoCairo.show_layout(this._ctx as any, layout);
900
+ this._ctx.restore();
901
+ }
902
+
903
+ strokeText(text: string, x: number, y: number, _maxWidth?: number): void {
904
+ this._ensureSurface();
905
+ this._applyCompositing();
906
+ this._applyStrokeStyle();
907
+ this._applyLineStyle();
908
+
909
+ const layout = this._createTextLayout(text);
910
+ const xOff = this._getTextAlignOffset(layout);
911
+ const yOff = this._getTextBaselineOffset(layout);
912
+
913
+ this._ctx.save();
914
+ this._ctx.moveTo(x + xOff, y + yOff);
915
+ PangoCairo.layout_path(this._ctx as any, layout);
916
+ this._ctx.stroke();
917
+ this._ctx.restore();
918
+ }
919
+
920
+ measureText(text: string): TextMetrics {
921
+ this._ensureSurface();
922
+ const layout = this._createTextLayout(text);
923
+ const [inkRect, logicalRect] = layout.get_pixel_extents();
924
+ const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
925
+ const context = layout.get_context();
926
+ const metrics = context.get_metrics(fontDesc, null);
927
+ const ascent = metrics.get_ascent() / Pango.SCALE;
928
+ const descent = metrics.get_descent() / Pango.SCALE;
929
+
930
+ return {
931
+ width: logicalRect.width,
932
+ actualBoundingBoxAscent: ascent,
933
+ actualBoundingBoxDescent: descent,
934
+ actualBoundingBoxLeft: -inkRect.x,
935
+ actualBoundingBoxRight: inkRect.x + inkRect.width,
936
+ fontBoundingBoxAscent: ascent,
937
+ fontBoundingBoxDescent: descent,
938
+ alphabeticBaseline: 0,
939
+ emHeightAscent: ascent,
940
+ emHeightDescent: descent,
941
+ hangingBaseline: ascent * 0.8,
942
+ ideographicBaseline: -descent,
943
+ };
944
+ }
945
+
946
+ // ---- toDataURL/toBlob support ----
947
+
948
+ /**
949
+ * Write the canvas surface to a PNG file and return as data URL.
950
+ * Used by HTMLCanvasElement.toDataURL() when a '2d' context is active.
951
+ */
952
+ _toDataURL(type?: string, _quality?: number): string {
953
+ if (type && type !== 'image/png') {
954
+ // Cairo only supports PNG natively
955
+ // For other formats, return PNG anyway (per spec, PNG is the required format)
956
+ }
957
+ this._surface.flush();
958
+
959
+ // Write to a temp file, read back as base64
960
+ const Gio = imports.gi.Gio;
961
+ const GLib = imports.gi.GLib;
962
+ const [, tempPath] = GLib.file_open_tmp('canvas-XXXXXX.png');
963
+ try {
964
+ this._surface.writeToPNG(tempPath);
965
+ const file = Gio.File.new_for_path(tempPath);
966
+ const [, contents] = file.load_contents(null);
967
+ const base64 = GLib.base64_encode(contents);
968
+ return `data:image/png;base64,${base64}`;
969
+ } finally {
970
+ try { GLib.unlink(tempPath); } catch (_e) { /* ignore */ }
971
+ }
972
+ }
973
+
974
+ // ---- Cleanup ----
975
+
976
+ /** Release native Cairo resources. Call when the canvas is discarded. */
977
+ _dispose(): void {
978
+ this._ctx.$dispose();
979
+ this._surface.finish();
980
+ }
981
+ }