@gjsify/canvas2d-core 0.1.7 → 0.1.9

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