@gjsify/canvas2d-core 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,25 +99,25 @@ function cairoRoundRect(ctx, x, y, w, h, radii) {
99
99
  const COMPOSITE_OP_MAP = {
100
100
  "source-over": 2,
101
101
  // OVER
102
- "source-in": 5,
102
+ "source-in": 3,
103
103
  // IN
104
- "source-out": 6,
104
+ "source-out": 4,
105
105
  // OUT
106
- "source-atop": 7,
106
+ "source-atop": 5,
107
107
  // ATOP
108
- "destination-over": 8,
108
+ "destination-over": 7,
109
109
  // DEST_OVER
110
- "destination-in": 9,
110
+ "destination-in": 8,
111
111
  // DEST_IN
112
- "destination-out": 10,
112
+ "destination-out": 9,
113
113
  // DEST_OUT
114
- "destination-atop": 11,
114
+ "destination-atop": 10,
115
115
  // DEST_ATOP
116
116
  "lighter": 12,
117
117
  // ADD
118
118
  "copy": 1,
119
119
  // SOURCE
120
- "xor": 13,
120
+ "xor": 11,
121
121
  // XOR
122
122
  "multiply": 14,
123
123
  // MULTIPLY
@@ -59,6 +59,7 @@ class CanvasRenderingContext2D {
59
59
  this._ctx.setSource(style._getCairoPattern());
60
60
  } else if (style instanceof OurCanvasPattern) {
61
61
  this._ctx.setSource(style._getCairoPattern());
62
+ this._applyPatternFilter();
62
63
  }
63
64
  }
64
65
  /** Apply the current stroke style to the Cairo context. */
@@ -72,6 +73,27 @@ class CanvasRenderingContext2D {
72
73
  this._ctx.setSource(style._getCairoPattern());
73
74
  } else if (style instanceof OurCanvasPattern) {
74
75
  this._ctx.setSource(style._getCairoPattern());
76
+ this._applyPatternFilter();
77
+ }
78
+ }
79
+ /**
80
+ * Apply the current imageSmoothingEnabled + imageSmoothingQuality state
81
+ * to the currently installed Cairo source pattern. Per Canvas 2D spec,
82
+ * the filter is read from the context at *draw* time, not at pattern
83
+ * creation — so we re-apply it on every fill/stroke.
84
+ */
85
+ _applyPatternFilter() {
86
+ const pat = this._ctx.getSource?.();
87
+ if (pat && typeof pat.setFilter === "function") {
88
+ let filter;
89
+ if (!this._state.imageSmoothingEnabled) {
90
+ filter = Cairo.Filter.NEAREST;
91
+ } else if (this._state.imageSmoothingQuality === "high") {
92
+ filter = Cairo.Filter.BEST;
93
+ } else {
94
+ filter = Cairo.Filter.BILINEAR;
95
+ }
96
+ pat.setFilter(filter);
75
97
  }
76
98
  }
77
99
  /** Apply line properties to the Cairo context. */
@@ -102,32 +124,45 @@ class CanvasRenderingContext2D {
102
124
  return c !== null && c.a > 0;
103
125
  }
104
126
  /**
105
- * Render a shadow for the current path by painting to a temp surface,
106
- * applying a simple box blur approximation, and compositing back.
107
- * This is called before the actual fill/stroke when shadows are active.
127
+ * Convert a distance from device pixels to Cairo user space by inverting
128
+ * the linear part of the current CTM (translation doesn't affect distances).
129
+ *
130
+ * Canvas 2D spec: shadowOffsetX/Y are in CSS pixels and are NOT scaled by
131
+ * the current transform. This helper converts them to user-space offsets so
132
+ * that `ctx.moveTo(x + sdx, y + sdy)` produces the correct pixel offset
133
+ * regardless of any ctx.scale() or ctx.rotate() in effect.
108
134
  */
109
- _renderShadow(drawOp) {
110
- const blur = this._state.shadowBlur;
111
- const offX = this._state.shadowOffsetX;
112
- const offY = this._state.shadowOffsetY;
113
- const color = parseColor(this._state.shadowColor);
114
- if (!color) return;
115
- const pad = Math.ceil(blur * 2);
116
- const w = this._surfaceWidth + pad * 2;
117
- const h = this._surfaceHeight + pad * 2;
118
- const shadowSurface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
119
- const shadowCtx = new Cairo.Context(shadowSurface);
120
- shadowCtx.translate(pad, pad);
121
- shadowCtx.setSourceRGBA(color.r, color.g, color.b, color.a * this._state.globalAlpha);
122
- drawOp.call(this);
123
- shadowCtx.$dispose();
124
- shadowSurface.finish();
125
- this._ctx.save();
126
- this._applyCompositing();
127
- this._ctx.setSourceRGBA(color.r, color.g, color.b, color.a * this._state.globalAlpha);
128
- this._ctx.translate(offX, offY);
129
- drawOp();
130
- this._ctx.restore();
135
+ _deviceToUserDistance(dx, dy) {
136
+ const origin = this._ctx.userToDevice(0, 0);
137
+ const xAxis = this._ctx.userToDevice(1, 0);
138
+ const yAxis = this._ctx.userToDevice(0, 1);
139
+ const a = (xAxis[0] ?? 0) - (origin[0] ?? 0);
140
+ const b = (xAxis[1] ?? 0) - (origin[1] ?? 0);
141
+ const c = (yAxis[0] ?? 0) - (origin[0] ?? 0);
142
+ const d = (yAxis[1] ?? 0) - (origin[1] ?? 0);
143
+ const det = a * d - b * c;
144
+ if (Math.abs(det) < 1e-10) return [dx, dy];
145
+ return [
146
+ (d * dx - c * dy) / det,
147
+ (-b * dx + a * dy) / det
148
+ ];
149
+ }
150
+ /**
151
+ * Shadow rendering is intentionally a no-op.
152
+ *
153
+ * Proper Canvas 2D shadows require a Gaussian blur pass on an isolated
154
+ * temporary surface, which cannot be emulated reliably without a full
155
+ * Path2D replay or pixel-level manipulation. The previous implementation
156
+ * attempted to use a temp surface but never replayed the path onto it
157
+ * (because `drawOp` closes over the main context), leaving the shadow
158
+ * surface empty while still leaking memory.
159
+ *
160
+ * Excalibur and most 2D game engines bake glow/outline effects into
161
+ * sprites rather than relying on canvas shadows, so this no-op does not
162
+ * affect the showcase. A correct implementation is tracked as a
163
+ * separate Canvas 2D Phase-5 enhancement.
164
+ */
165
+ _renderShadow(_drawOp) {
131
166
  }
132
167
  // ---- State ----
133
168
  save() {
@@ -164,9 +199,17 @@ class CanvasRenderingContext2D {
164
199
  */
165
200
  transform(a, b, c, d, e, f) {
166
201
  this._ensureSurface();
167
- const matrix = new Cairo.Matrix();
168
- matrix.init(a, b, c, d, e, f);
169
- this._ctx.transform(matrix);
202
+ if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(c) || !Number.isFinite(d) || !Number.isFinite(e) || !Number.isFinite(f)) {
203
+ return;
204
+ }
205
+ const tx = e;
206
+ const ty = f;
207
+ const sx = Math.hypot(a, b);
208
+ const sy = Math.hypot(c, d);
209
+ const rotation = Math.atan2(b, a);
210
+ this._ctx.translate(tx, ty);
211
+ if (rotation !== 0) this._ctx.rotate(rotation);
212
+ if (sx !== 1 || sy !== 1) this._ctx.scale(sx, sy);
170
213
  }
171
214
  setTransform(a, b, c, d, e, f) {
172
215
  this._ensureSurface();
@@ -192,36 +235,45 @@ class CanvasRenderingContext2D {
192
235
  * Return the current transformation matrix as a DOMMatrix-like object.
193
236
  */
194
237
  getTransform() {
195
- const m = this._ctx.getMatrix?.();
196
- if (m) {
197
- return {
198
- a: m.xx ?? 1,
199
- b: m.yx ?? 0,
200
- c: m.xy ?? 0,
201
- d: m.yy ?? 1,
202
- e: m.x0 ?? 0,
203
- f: m.y0 ?? 0,
204
- m11: m.xx ?? 1,
205
- m12: m.yx ?? 0,
206
- m13: 0,
207
- m14: 0,
208
- m21: m.xy ?? 0,
209
- m22: m.yy ?? 1,
210
- m23: 0,
211
- m24: 0,
212
- m31: 0,
213
- m32: 0,
214
- m33: 1,
215
- m34: 0,
216
- m41: m.x0 ?? 0,
217
- m42: m.y0 ?? 0,
218
- m43: 0,
219
- m44: 1,
220
- is2D: true,
221
- isIdentity: m.xx === 1 && m.yx === 0 && m.xy === 0 && m.yy === 1 && m.x0 === 0 && m.y0 === 0
222
- };
223
- }
224
- return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, is2D: true, isIdentity: true };
238
+ const origin = this._ctx.userToDevice(0, 0);
239
+ const xAxis = this._ctx.userToDevice(1, 0);
240
+ const yAxis = this._ctx.userToDevice(0, 1);
241
+ const e = origin[0] ?? 0;
242
+ const f = origin[1] ?? 0;
243
+ const a = (xAxis[0] ?? 0) - e;
244
+ const b = (xAxis[1] ?? 0) - f;
245
+ const c = (yAxis[0] ?? 0) - e;
246
+ const d = (yAxis[1] ?? 0) - f;
247
+ const DOMMatrixCtor = globalThis.DOMMatrix;
248
+ if (typeof DOMMatrixCtor === "function") {
249
+ return new DOMMatrixCtor([a, b, c, d, e, f]);
250
+ }
251
+ return {
252
+ a,
253
+ b,
254
+ c,
255
+ d,
256
+ e,
257
+ f,
258
+ m11: a,
259
+ m12: b,
260
+ m13: 0,
261
+ m14: 0,
262
+ m21: c,
263
+ m22: d,
264
+ m23: 0,
265
+ m24: 0,
266
+ m31: 0,
267
+ m32: 0,
268
+ m33: 1,
269
+ m34: 0,
270
+ m41: e,
271
+ m42: f,
272
+ m43: 0,
273
+ m44: 1,
274
+ is2D: true,
275
+ isIdentity: a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0
276
+ };
225
277
  }
226
278
  resetTransform() {
227
279
  this._ensureSurface();
@@ -677,13 +729,33 @@ class CanvasRenderingContext2D {
677
729
  dw = a7;
678
730
  dh = a8;
679
731
  }
732
+ if (sw === 0 || sh === 0 || dw === 0 || dh === 0) {
733
+ return;
734
+ }
680
735
  this._ctx.save();
736
+ this._ctx.rectangle(dx, dy, dw, dh);
737
+ this._ctx.clip();
681
738
  this._ctx.translate(dx, dy);
682
739
  this._ctx.scale(dw / sw, dh / sh);
683
740
  this._ctx.translate(-sx, -sy);
684
741
  Gdk.cairo_set_source_pixbuf(this._ctx, pixbuf, 0, 0);
685
- this._ctx.rectangle(sx, sy, sw, sh);
686
- this._ctx.fill();
742
+ const pat = this._ctx.getSource?.();
743
+ if (pat && typeof pat.setFilter === "function") {
744
+ let filter;
745
+ if (!this._state.imageSmoothingEnabled) {
746
+ filter = Cairo.Filter.NEAREST;
747
+ } else if (this._state.imageSmoothingQuality === "high") {
748
+ filter = Cairo.Filter.BEST;
749
+ } else {
750
+ filter = Cairo.Filter.BILINEAR;
751
+ }
752
+ pat.setFilter(filter);
753
+ }
754
+ if (this._state.globalAlpha < 1) {
755
+ this._ctx.paintWithAlpha(this._state.globalAlpha);
756
+ } else {
757
+ this._ctx.paint();
758
+ }
687
759
  this._ctx.restore();
688
760
  }
689
761
  _getDrawImageSource(image) {
@@ -727,18 +799,19 @@ class CanvasRenderingContext2D {
727
799
  const style = match[1] || "";
728
800
  const weight = match[3] || "";
729
801
  let size = parseFloat(match[4]) || 10;
730
- const unit = match[5] || "px";
802
+ const unit = (match[5] || "px").toLowerCase();
731
803
  const family = (match[6] || "sans-serif").replace(/['"]/g, "").trim();
732
- if (unit === "px") size = size * 0.75;
733
- else if (unit === "em" || unit === "rem") size = size * 12;
734
- else if (unit === "%") size = size / 100 * 12;
804
+ if (unit === "pt") size = size * 96 / 72;
805
+ else if (unit === "em" || unit === "rem") size = size * 16;
806
+ else if (unit === "%") size = size / 100 * 16;
735
807
  let pangoStr = family;
736
808
  if (style === "italic") pangoStr += " Italic";
737
809
  else if (style === "oblique") pangoStr += " Oblique";
738
810
  if (weight === "bold" || weight === "bolder" || parseInt(weight) >= 600) pangoStr += " Bold";
739
811
  else if (weight === "lighter" || parseInt(weight) > 0 && parseInt(weight) <= 300) pangoStr += " Light";
740
- pangoStr += ` ${Math.round(size)}`;
741
- return Pango.font_description_from_string(pangoStr);
812
+ const desc = Pango.font_description_from_string(pangoStr);
813
+ desc.set_absolute_size(size * Pango.SCALE);
814
+ return desc;
742
815
  }
743
816
  /**
744
817
  * Compute the x-offset for text alignment relative to the given x coordinate.
@@ -801,11 +874,45 @@ class CanvasRenderingContext2D {
801
874
  fillText(text, x, y, _maxWidth) {
802
875
  this._ensureSurface();
803
876
  this._applyCompositing();
804
- this._applyFillStyle();
805
877
  const layout = this._createTextLayout(text);
806
878
  const xOff = this._getTextAlignOffset(layout);
807
879
  const yOff = this._getTextBaselineOffset(layout);
880
+ if (this._hasShadow()) {
881
+ const sc = parseColor(this._state.shadowColor);
882
+ if (sc) {
883
+ const [sdx, sdy] = this._deviceToUserDistance(
884
+ this._state.shadowOffsetX,
885
+ this._state.shadowOffsetY
886
+ );
887
+ const blur = this._state.shadowBlur;
888
+ let taps;
889
+ if (blur > 0) {
890
+ const [bu] = this._deviceToUserDistance(blur, 0);
891
+ const [, bv] = this._deviceToUserDistance(0, blur);
892
+ taps = [
893
+ [sdx, sdy, sc.a],
894
+ [sdx + bu, sdy, sc.a * 0.5],
895
+ [sdx - bu, sdy, sc.a * 0.5],
896
+ [sdx, sdy + bv, sc.a * 0.5],
897
+ [sdx, sdy - bv, sc.a * 0.5]
898
+ ];
899
+ } else {
900
+ taps = [[sdx, sdy, sc.a]];
901
+ }
902
+ const aa = this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE;
903
+ for (const [tx, ty, ta] of taps) {
904
+ this._ctx.save();
905
+ this._ctx.setAntialias(aa);
906
+ this._ctx.setSourceRGBA(sc.r, sc.g, sc.b, ta);
907
+ this._ctx.moveTo(x + xOff + tx, y + yOff + ty);
908
+ PangoCairo.show_layout(this._ctx, layout);
909
+ this._ctx.restore();
910
+ }
911
+ }
912
+ }
913
+ this._applyFillStyle();
808
914
  this._ctx.save();
915
+ this._ctx.setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
809
916
  this._ctx.moveTo(x + xOff, y + yOff);
810
917
  PangoCairo.show_layout(this._ctx, layout);
811
918
  this._ctx.restore();
@@ -819,6 +926,7 @@ class CanvasRenderingContext2D {
819
926
  const xOff = this._getTextAlignOffset(layout);
820
927
  const yOff = this._getTextBaselineOffset(layout);
821
928
  this._ctx.save();
929
+ this._ctx.setAntialias(this._state.imageSmoothingEnabled ? Cairo.Antialias.DEFAULT : Cairo.Antialias.NONE);
822
930
  this._ctx.moveTo(x + xOff, y + yOff);
823
931
  PangoCairo.layout_path(this._ctx, layout);
824
932
  this._ctx.stroke();
@@ -828,24 +936,26 @@ class CanvasRenderingContext2D {
828
936
  this._ensureSurface();
829
937
  const layout = this._createTextLayout(text);
830
938
  const [inkRect, logicalRect] = layout.get_pixel_extents();
939
+ const baselinePx = layout.get_baseline() / Pango.SCALE;
940
+ const actualAscent = Math.max(0, baselinePx - inkRect.y);
941
+ const actualDescent = Math.max(0, inkRect.y + inkRect.height - baselinePx);
831
942
  const fontDesc = layout.get_font_description() || this._parseFontToDescription(this._state.font);
832
- const context = layout.get_context();
833
- const metrics = context.get_metrics(fontDesc, null);
834
- const ascent = metrics.get_ascent() / Pango.SCALE;
835
- const descent = metrics.get_descent() / Pango.SCALE;
943
+ const metrics = layout.get_context().get_metrics(fontDesc, null);
944
+ const fontAscent = metrics.get_ascent() / Pango.SCALE;
945
+ const fontDescent = metrics.get_descent() / Pango.SCALE;
836
946
  return {
837
947
  width: logicalRect.width,
838
- actualBoundingBoxAscent: ascent,
839
- actualBoundingBoxDescent: descent,
840
- actualBoundingBoxLeft: -inkRect.x,
948
+ actualBoundingBoxAscent: actualAscent,
949
+ actualBoundingBoxDescent: actualDescent,
950
+ actualBoundingBoxLeft: Math.max(0, -inkRect.x),
841
951
  actualBoundingBoxRight: inkRect.x + inkRect.width,
842
- fontBoundingBoxAscent: ascent,
843
- fontBoundingBoxDescent: descent,
952
+ fontBoundingBoxAscent: fontAscent,
953
+ fontBoundingBoxDescent: fontDescent,
844
954
  alphabeticBaseline: 0,
845
- emHeightAscent: ascent,
846
- emHeightDescent: descent,
847
- hangingBaseline: ascent * 0.8,
848
- ideographicBaseline: -descent
955
+ emHeightAscent: fontAscent,
956
+ emHeightDescent: fontDescent,
957
+ hangingBaseline: fontAscent * 0.8,
958
+ ideographicBaseline: -fontDescent
849
959
  };
850
960
  }
851
961
  // ---- toDataURL/toBlob support ----
package/lib/esm/color.js CHANGED
@@ -166,6 +166,23 @@ function parseColor(color) {
166
166
  a: rgbMatch[4] !== void 0 ? parseComponent(rgbMatch[4], 1) : 1
167
167
  };
168
168
  }
169
+ const hslMatch = trimmed.match(
170
+ /^hsla?\(\s*(\d+(?:\.\d+)?)\s*[,\s]\s*(\d+(?:\.\d+)?)(%)?\s*[,\s]\s*(\d+(?:\.\d+)?)(%)?\s*(?:[,/]\s*(\d+(?:\.\d+)?%?))?\s*\)$/
171
+ );
172
+ if (hslMatch) {
173
+ let h = parseFloat(hslMatch[1]);
174
+ let s = parseFloat(hslMatch[2]);
175
+ const sPct = hslMatch[3] === "%";
176
+ let l = parseFloat(hslMatch[4]);
177
+ const lPct = hslMatch[5] === "%";
178
+ const a = hslMatch[6] !== void 0 ? parseComponent(hslMatch[6], 1) : 1;
179
+ if (h > 1) h /= 360;
180
+ if (sPct) s /= 100;
181
+ else if (s > 1) s /= 100;
182
+ if (lPct) l /= 100;
183
+ else if (l > 1) l /= 100;
184
+ return hslToRGBA(h, s, l, Math.max(0, Math.min(1, a)));
185
+ }
169
186
  return null;
170
187
  }
171
188
  function parseHex(hex) {
@@ -200,6 +217,27 @@ function parseComponent(value, max) {
200
217
  }
201
218
  return parseFloat(value);
202
219
  }
220
+ function hue2rgb(p, q, t) {
221
+ if (t < 0) t += 1;
222
+ if (t > 1) t -= 1;
223
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
224
+ if (t < 1 / 2) return q;
225
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
226
+ return p;
227
+ }
228
+ function hslToRGBA(h, s, l, a) {
229
+ let r, g, b;
230
+ if (s === 0) {
231
+ r = g = b = l;
232
+ } else {
233
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
234
+ const p = 2 * l - q;
235
+ r = hue2rgb(p, q, h + 1 / 3);
236
+ g = hue2rgb(p, q, h);
237
+ b = hue2rgb(p, q, h - 1 / 3);
238
+ }
239
+ return { r, g, b, a };
240
+ }
203
241
  const BLACK = { r: 0, g: 0, b: 0, a: 1 };
204
242
  const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
205
243
  export {
@@ -46,7 +46,18 @@ export declare function cairoEllipse(ctx: Cairo.Context, x: number, y: number, r
46
46
  * Implements the Canvas roundRect(x, y, w, h, radii) method.
47
47
  */
48
48
  export declare function cairoRoundRect(ctx: Cairo.Context, x: number, y: number, w: number, h: number, radii: number | number[]): void;
49
- /** Map Canvas globalCompositeOperation to Cairo.Operator values */
49
+ /**
50
+ * Map Canvas globalCompositeOperation to Cairo.Operator values.
51
+ *
52
+ * Cairo.Operator enum (verified runtime in GJS 1.86):
53
+ * CLEAR=0, SOURCE=1, OVER=2, IN=3, OUT=4, ATOP=5,
54
+ * DEST=6, DEST_OVER=7, DEST_IN=8, DEST_OUT=9, DEST_ATOP=10,
55
+ * XOR=11, ADD=12, SATURATE=13,
56
+ * MULTIPLY=14, SCREEN=15, OVERLAY=16, DARKEN=17, LIGHTEN=18,
57
+ * COLOR_DODGE=19, COLOR_BURN=20, HARD_LIGHT=21, SOFT_LIGHT=22,
58
+ * DIFFERENCE=23, EXCLUSION=24, HSL_HUE=25, HSL_SATURATION=26,
59
+ * HSL_COLOR=27, HSL_LUMINOSITY=28
60
+ */
50
61
  export declare const COMPOSITE_OP_MAP: Record<string, number>;
51
62
  /** Map Canvas lineCap to Cairo.LineCap values */
52
63
  export declare const LINE_CAP_MAP: Record<string, number>;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -21,6 +21,13 @@ export declare class CanvasRenderingContext2D {
21
21
  private _applyFillStyle;
22
22
  /** Apply the current stroke style to the Cairo context. */
23
23
  private _applyStrokeStyle;
24
+ /**
25
+ * Apply the current imageSmoothingEnabled + imageSmoothingQuality state
26
+ * to the currently installed Cairo source pattern. Per Canvas 2D spec,
27
+ * the filter is read from the context at *draw* time, not at pattern
28
+ * creation — so we re-apply it on every fill/stroke.
29
+ */
30
+ private _applyPatternFilter;
24
31
  /** Apply line properties to the Cairo context. */
25
32
  private _applyLineStyle;
26
33
  /** Apply compositing operator. */
@@ -30,9 +37,29 @@ export declare class CanvasRenderingContext2D {
30
37
  /** Check if shadow rendering is needed. */
31
38
  private _hasShadow;
32
39
  /**
33
- * Render a shadow for the current path by painting to a temp surface,
34
- * applying a simple box blur approximation, and compositing back.
35
- * This is called before the actual fill/stroke when shadows are active.
40
+ * Convert a distance from device pixels to Cairo user space by inverting
41
+ * the linear part of the current CTM (translation doesn't affect distances).
42
+ *
43
+ * Canvas 2D spec: shadowOffsetX/Y are in CSS pixels and are NOT scaled by
44
+ * the current transform. This helper converts them to user-space offsets so
45
+ * that `ctx.moveTo(x + sdx, y + sdy)` produces the correct pixel offset
46
+ * regardless of any ctx.scale() or ctx.rotate() in effect.
47
+ */
48
+ private _deviceToUserDistance;
49
+ /**
50
+ * Shadow rendering is intentionally a no-op.
51
+ *
52
+ * Proper Canvas 2D shadows require a Gaussian blur pass on an isolated
53
+ * temporary surface, which cannot be emulated reliably without a full
54
+ * Path2D replay or pixel-level manipulation. The previous implementation
55
+ * attempted to use a temp surface but never replayed the path onto it
56
+ * (because `drawOp` closes over the main context), leaving the shadow
57
+ * surface empty while still leaking memory.
58
+ *
59
+ * Excalibur and most 2D game engines bake glow/outline effects into
60
+ * sprites rather than relying on canvas shadows, so this no-op does not
61
+ * affect the showcase. A correct implementation is tracked as a
62
+ * separate Canvas 2D Phase-5 enhancement.
36
63
  */
37
64
  private _renderShadow;
38
65
  save(): void;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => Promise<void>;
2
+ export default _default;
@@ -6,7 +6,11 @@ export interface RGBA {
6
6
  }
7
7
  /**
8
8
  * Parse a CSS color string into RGBA components (0-1 range).
9
- * Supports: #rgb, #rrggbb, #rgba, #rrggbbaa, rgb(), rgba(), named colors, 'transparent'.
9
+ * Supports: #rgb, #rrggbb, #rgba, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), named colors, 'transparent'.
10
+ *
11
+ * Also handles Excalibur's non-standard HSL format where h/s/l are all in 0-1 range (not degrees/%).
12
+ * Excalibur's Color.toString() returns `hsla(h, s, l, a)` with values in 0-1 normalized form
13
+ * (e.g. Color.White → "hsla(0, 0, 1, 1)", Color.Black → "hsla(0, 0, 0, 1)").
10
14
  */
11
15
  export declare function parseColor(color: string): RGBA | null;
12
16
  /** Default color: opaque black */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/canvas2d-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Cairo-backed Canvas 2D core (CanvasRenderingContext2D, Path2D, ImageData) — no GTK dependency",
5
5
  "type": "module",
6
6
  "module": "lib/esm/index.js",
@@ -30,18 +30,18 @@
30
30
  "offscreen"
31
31
  ],
32
32
  "dependencies": {
33
- "@girs/gdk-4.0": "^4.0.0-4.0.0-rc.1",
34
- "@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-rc.1",
35
- "@girs/gjs": "^4.0.0-rc.1",
36
- "@girs/glib-2.0": "^2.88.0-4.0.0-rc.1",
37
- "@girs/gobject-2.0": "^2.88.0-4.0.0-rc.1",
38
- "@girs/pango-1.0": "^1.57.1-4.0.0-rc.1",
39
- "@girs/pangocairo-1.0": "^1.0.0-4.0.0-rc.1"
33
+ "@girs/gdk-4.0": "^4.0.0-4.0.0-rc.2",
34
+ "@girs/gdkpixbuf-2.0": "^2.0.0-4.0.0-rc.2",
35
+ "@girs/gjs": "^4.0.0-rc.2",
36
+ "@girs/glib-2.0": "^2.88.0-4.0.0-rc.2",
37
+ "@girs/gobject-2.0": "^2.88.0-4.0.0-rc.2",
38
+ "@girs/pango-1.0": "^1.57.1-4.0.0-rc.2",
39
+ "@girs/pangocairo-1.0": "^1.0.0-4.0.0-rc.2"
40
40
  },
41
41
  "devDependencies": {
42
- "@gjsify/cli": "^0.1.8",
43
- "@gjsify/unit": "^0.1.8",
44
- "@types/node": "^25.5.2",
42
+ "@gjsify/cli": "^0.1.9",
43
+ "@gjsify/unit": "^0.1.9",
44
+ "@types/node": "^25.6.0",
45
45
  "typescript": "^6.0.2"
46
46
  }
47
47
  }
@@ -198,19 +198,30 @@ export function cairoRoundRect(
198
198
  ctx.closePath();
199
199
  }
200
200
 
201
- /** Map Canvas globalCompositeOperation to Cairo.Operator values */
201
+ /**
202
+ * Map Canvas globalCompositeOperation to Cairo.Operator values.
203
+ *
204
+ * Cairo.Operator enum (verified runtime in GJS 1.86):
205
+ * CLEAR=0, SOURCE=1, OVER=2, IN=3, OUT=4, ATOP=5,
206
+ * DEST=6, DEST_OVER=7, DEST_IN=8, DEST_OUT=9, DEST_ATOP=10,
207
+ * XOR=11, ADD=12, SATURATE=13,
208
+ * MULTIPLY=14, SCREEN=15, OVERLAY=16, DARKEN=17, LIGHTEN=18,
209
+ * COLOR_DODGE=19, COLOR_BURN=20, HARD_LIGHT=21, SOFT_LIGHT=22,
210
+ * DIFFERENCE=23, EXCLUSION=24, HSL_HUE=25, HSL_SATURATION=26,
211
+ * HSL_COLOR=27, HSL_LUMINOSITY=28
212
+ */
202
213
  export const COMPOSITE_OP_MAP: Record<string, number> = {
203
214
  'source-over': 2, // OVER
204
- 'source-in': 5, // IN
205
- 'source-out': 6, // OUT
206
- 'source-atop': 7, // ATOP
207
- 'destination-over': 8, // DEST_OVER
208
- 'destination-in': 9, // DEST_IN
209
- 'destination-out': 10, // DEST_OUT
210
- 'destination-atop': 11,// DEST_ATOP
215
+ 'source-in': 3, // IN
216
+ 'source-out': 4, // OUT
217
+ 'source-atop': 5, // ATOP
218
+ 'destination-over': 7, // DEST_OVER
219
+ 'destination-in': 8, // DEST_IN
220
+ 'destination-out': 9, // DEST_OUT
221
+ 'destination-atop': 10,// DEST_ATOP
211
222
  'lighter': 12, // ADD
212
223
  'copy': 1, // SOURCE
213
- 'xor': 13, // XOR
224
+ 'xor': 11, // XOR
214
225
  'multiply': 14, // MULTIPLY
215
226
  'screen': 15, // SCREEN
216
227
  'overlay': 16, // OVERLAY