@gjsify/canvas2d-core 0.1.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 ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@gjsify/canvas2d-core",
3
+ "version": "0.1.3",
4
+ "description": "Cairo-backed Canvas 2D core (CanvasRenderingContext2D, Path2D, ImageData) — no GTK dependency",
5
+ "type": "module",
6
+ "module": "lib/esm/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/esm/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "clear": "rm -rf lib tmp tsconfig.tsbuildinfo test.gjs.mjs || exit 0",
16
+ "check": "tsc --noEmit",
17
+ "build": "yarn build:gjsify && yarn build:types",
18
+ "build:gjsify": "gjsify build --library 'src/**/*.{ts,js}' --exclude 'src/**/*.spec.{mts,ts}' 'src/test.{mts,ts}'",
19
+ "build:types": "tsc",
20
+ "build:test": "yarn build:test:gjs",
21
+ "build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
22
+ "test": "yarn build:gjsify && yarn build:test && yarn test:gjs",
23
+ "test:gjs": "gjs -m test.gjs.mjs"
24
+ },
25
+ "keywords": [
26
+ "gjs",
27
+ "canvas",
28
+ "canvas2d",
29
+ "cairo",
30
+ "offscreen"
31
+ ],
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"
40
+ },
41
+ "devDependencies": {
42
+ "@gjsify/cli": "^0.1.3",
43
+ "@gjsify/unit": "^0.1.3",
44
+ "@types/node": "^25.5.2",
45
+ "typescript": "^6.0.2"
46
+ }
47
+ }
@@ -0,0 +1,243 @@
1
+ // Cairo utility helpers for Canvas 2D context
2
+ // Handles format conversions and path operations not directly available in Cairo.
3
+
4
+ import type Cairo from 'cairo';
5
+
6
+ /**
7
+ * Convert quadratic Bezier control point to cubic Bezier control points.
8
+ * Canvas 2D has quadraticCurveTo but Cairo only has cubic curveTo.
9
+ *
10
+ * Given current point (cx, cy), quadratic control point (cpx, cpy), and end (x, y):
11
+ * cp1 = current + 2/3 * (cp - current)
12
+ * cp2 = end + 2/3 * (cp - end)
13
+ */
14
+ export function quadraticToCubic(
15
+ cx: number, cy: number,
16
+ cpx: number, cpy: number,
17
+ x: number, y: number,
18
+ ): { cp1x: number; cp1y: number; cp2x: number; cp2y: number } {
19
+ return {
20
+ cp1x: cx + (2 / 3) * (cpx - cx),
21
+ cp1y: cy + (2 / 3) * (cpy - cy),
22
+ cp2x: x + (2 / 3) * (cpx - x),
23
+ cp2y: y + (2 / 3) * (cpy - y),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Compute arcTo parameters.
29
+ * Canvas arcTo(x1,y1,x2,y2,radius) draws a line from current point to the tangent point,
30
+ * then an arc of the given radius tangent to both lines (current→p1 and p1→p2).
31
+ *
32
+ * Returns the two tangent points and arc center, or null if degenerate (collinear points).
33
+ */
34
+ export function computeArcTo(
35
+ x0: number, y0: number,
36
+ x1: number, y1: number,
37
+ x2: number, y2: number,
38
+ radius: number,
39
+ ): { tx0: number; ty0: number; tx1: number; ty1: number; cx: number; cy: number; startAngle: number; endAngle: number; counterclockwise: boolean } | null {
40
+ // Direction vectors
41
+ const dx0 = x0 - x1;
42
+ const dy0 = y0 - y1;
43
+ const dx1 = x2 - x1;
44
+ const dy1 = y2 - y1;
45
+
46
+ // Lengths
47
+ const len0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
48
+ const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
49
+
50
+ if (len0 === 0 || len1 === 0) return null;
51
+
52
+ // Normalize
53
+ const ux0 = dx0 / len0;
54
+ const uy0 = dy0 / len0;
55
+ const ux1 = dx1 / len1;
56
+ const uy1 = dy1 / len1;
57
+
58
+ // Cross product to determine direction
59
+ const cross = ux0 * uy1 - uy0 * ux1;
60
+ if (Math.abs(cross) < 1e-10) return null; // Collinear
61
+
62
+ // Half-angle between the two direction vectors
63
+ const dot = ux0 * ux1 + uy0 * uy1;
64
+ const halfAngle = Math.acos(Math.max(-1, Math.min(1, dot))) / 2;
65
+
66
+ // Distance from p1 to tangent point
67
+ const tanDist = radius / Math.tan(halfAngle);
68
+
69
+ // Tangent points
70
+ const tx0 = x1 + ux0 * tanDist;
71
+ const ty0 = y1 + uy0 * tanDist;
72
+ const tx1 = x1 + ux1 * tanDist;
73
+ const ty1 = y1 + uy1 * tanDist;
74
+
75
+ // Center of the arc circle: perpendicular to the bisector, distance = radius / sin(halfAngle)
76
+ const centerDist = radius / Math.sin(halfAngle);
77
+ const bisectX = (ux0 + ux1) / 2;
78
+ const bisectY = (uy0 + uy1) / 2;
79
+ const bisectLen = Math.sqrt(bisectX * bisectX + bisectY * bisectY);
80
+ const cx = x1 + (bisectX / bisectLen) * centerDist;
81
+ const cy = y1 + (bisectY / bisectLen) * centerDist;
82
+
83
+ // Start and end angles
84
+ const startAngle = Math.atan2(ty0 - cy, tx0 - cx);
85
+ const endAngle = Math.atan2(ty1 - cy, tx1 - cx);
86
+
87
+ // Counterclockwise if cross product is positive
88
+ const counterclockwise = cross > 0;
89
+
90
+ return { tx0, ty0, tx1, ty1, cx, cy, startAngle, endAngle, counterclockwise };
91
+ }
92
+
93
+ /**
94
+ * Apply an arcTo operation to a Cairo context.
95
+ */
96
+ export function cairoArcTo(
97
+ ctx: Cairo.Context,
98
+ x0: number, y0: number,
99
+ x1: number, y1: number,
100
+ x2: number, y2: number,
101
+ radius: number,
102
+ ): void {
103
+ const result = computeArcTo(x0, y0, x1, y1, x2, y2, radius);
104
+ if (!result) {
105
+ // Degenerate: just draw a line to (x1, y1)
106
+ ctx.lineTo(x1, y1);
107
+ return;
108
+ }
109
+
110
+ const { tx0, ty0, cx, cy, startAngle, endAngle, counterclockwise } = result;
111
+
112
+ // Line from current point to first tangent point
113
+ ctx.lineTo(tx0, ty0);
114
+
115
+ // Arc
116
+ if (counterclockwise) {
117
+ ctx.arcNegative(cx, cy, radius, startAngle, endAngle);
118
+ } else {
119
+ ctx.arc(cx, cy, radius, startAngle, endAngle);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Draw an ellipse on a Cairo context.
125
+ * Canvas ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise)
126
+ * is implemented via save/translate/rotate/scale/arc/restore.
127
+ */
128
+ export function cairoEllipse(
129
+ ctx: Cairo.Context,
130
+ x: number, y: number,
131
+ radiusX: number, radiusY: number,
132
+ rotation: number,
133
+ startAngle: number, endAngle: number,
134
+ counterclockwise: boolean,
135
+ ): void {
136
+ ctx.save();
137
+ ctx.translate(x, y);
138
+ ctx.rotate(rotation);
139
+ ctx.scale(radiusX, radiusY);
140
+
141
+ if (counterclockwise) {
142
+ ctx.arcNegative(0, 0, 1, startAngle, endAngle);
143
+ } else {
144
+ ctx.arc(0, 0, 1, startAngle, endAngle);
145
+ }
146
+
147
+ ctx.restore();
148
+ }
149
+
150
+ /**
151
+ * Draw a rounded rectangle path on a Cairo context.
152
+ * Implements the Canvas roundRect(x, y, w, h, radii) method.
153
+ */
154
+ export function cairoRoundRect(
155
+ ctx: Cairo.Context,
156
+ x: number, y: number,
157
+ w: number, h: number,
158
+ radii: number | number[],
159
+ ): void {
160
+ // Normalize radii to [topLeft, topRight, bottomRight, bottomLeft]
161
+ let tl: number, tr: number, br: number, bl: number;
162
+ if (typeof radii === 'number') {
163
+ tl = tr = br = bl = radii;
164
+ } else if (radii.length === 1) {
165
+ tl = tr = br = bl = radii[0];
166
+ } else if (radii.length === 2) {
167
+ tl = br = radii[0];
168
+ tr = bl = radii[1];
169
+ } else if (radii.length === 3) {
170
+ tl = radii[0];
171
+ tr = bl = radii[1];
172
+ br = radii[2];
173
+ } else {
174
+ tl = radii[0];
175
+ tr = radii[1];
176
+ br = radii[2];
177
+ bl = radii[3];
178
+ }
179
+
180
+ // Clamp radii so they don't exceed half the width/height
181
+ const maxR = Math.min(w / 2, h / 2);
182
+ tl = Math.min(tl, maxR);
183
+ tr = Math.min(tr, maxR);
184
+ br = Math.min(br, maxR);
185
+ bl = Math.min(bl, maxR);
186
+
187
+ const PI_2 = Math.PI / 2;
188
+
189
+ ctx.newSubPath();
190
+ // Top-left corner
191
+ ctx.arc(x + tl, y + tl, tl, Math.PI, Math.PI + PI_2);
192
+ // Top-right corner
193
+ ctx.arc(x + w - tr, y + tr, tr, -PI_2, 0);
194
+ // Bottom-right corner
195
+ ctx.arc(x + w - br, y + h - br, br, 0, PI_2);
196
+ // Bottom-left corner
197
+ ctx.arc(x + bl, y + h - bl, bl, PI_2, Math.PI);
198
+ ctx.closePath();
199
+ }
200
+
201
+ /** Map Canvas globalCompositeOperation to Cairo.Operator values */
202
+ export const COMPOSITE_OP_MAP: Record<string, number> = {
203
+ '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
211
+ 'lighter': 12, // ADD
212
+ 'copy': 1, // SOURCE
213
+ 'xor': 13, // XOR
214
+ 'multiply': 14, // MULTIPLY
215
+ 'screen': 15, // SCREEN
216
+ 'overlay': 16, // OVERLAY
217
+ 'darken': 17, // DARKEN
218
+ 'lighten': 18, // LIGHTEN
219
+ 'color-dodge': 19, // COLOR_DODGE
220
+ 'color-burn': 20, // COLOR_BURN
221
+ 'hard-light': 21, // HARD_LIGHT
222
+ 'soft-light': 22, // SOFT_LIGHT
223
+ 'difference': 23, // DIFFERENCE
224
+ 'exclusion': 24, // EXCLUSION
225
+ 'hue': 25, // HSL_HUE
226
+ 'saturation': 26, // HSL_SATURATION
227
+ 'color': 27, // HSL_COLOR
228
+ 'luminosity': 28, // HSL_LUMINOSITY
229
+ };
230
+
231
+ /** Map Canvas lineCap to Cairo.LineCap values */
232
+ export const LINE_CAP_MAP: Record<string, number> = {
233
+ 'butt': 0,
234
+ 'round': 1,
235
+ 'square': 2,
236
+ };
237
+
238
+ /** Map Canvas lineJoin to Cairo.LineJoin values */
239
+ export const LINE_JOIN_MAP: Record<string, number> = {
240
+ 'miter': 0,
241
+ 'round': 1,
242
+ 'bevel': 2,
243
+ };
@@ -0,0 +1,36 @@
1
+ // CanvasGradient implementation backed by Cairo gradient patterns
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/CanvasGradient
3
+
4
+ import Cairo from 'cairo';
5
+ import { parseColor } from './color.js';
6
+
7
+ /**
8
+ * CanvasGradient wrapping a Cairo LinearGradient or RadialGradient.
9
+ */
10
+ export class CanvasGradient {
11
+ private _pattern: Cairo.LinearGradient | Cairo.RadialGradient;
12
+
13
+ constructor(
14
+ type: 'linear' | 'radial',
15
+ x0: number, y0: number,
16
+ x1: number, y1: number,
17
+ r0?: number, r1?: number,
18
+ ) {
19
+ if (type === 'radial') {
20
+ this._pattern = new Cairo.RadialGradient(x0, y0, r0!, x1, y1, r1!);
21
+ } else {
22
+ this._pattern = new Cairo.LinearGradient(x0, y0, x1, y1);
23
+ }
24
+ }
25
+
26
+ addColorStop(offset: number, color: string): void {
27
+ const parsed = parseColor(color);
28
+ if (!parsed) return;
29
+ this._pattern.addColorStopRGBA(offset, parsed.r, parsed.g, parsed.b, parsed.a);
30
+ }
31
+
32
+ /** @internal Get the underlying Cairo pattern for rendering. */
33
+ _getCairoPattern(): Cairo.LinearGradient | Cairo.RadialGradient {
34
+ return this._pattern;
35
+ }
36
+ }
@@ -0,0 +1,131 @@
1
+ // Path2D implementation for Canvas 2D context
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/Path2D
3
+ // Records path operations and replays them on a Cairo context.
4
+
5
+ import { quadraticToCubic, cairoRoundRect } from './cairo-utils.js';
6
+
7
+ /** A recorded path operation. */
8
+ type PathOp =
9
+ | { type: 'moveTo'; x: number; y: number }
10
+ | { type: 'lineTo'; x: number; y: number }
11
+ | { type: 'closePath' }
12
+ | { type: 'bezierCurveTo'; cp1x: number; cp1y: number; cp2x: number; cp2y: number; x: number; y: number }
13
+ | { type: 'quadraticCurveTo'; cpx: number; cpy: number; x: number; y: number }
14
+ | { type: 'arc'; x: number; y: number; radius: number; startAngle: number; endAngle: number; ccw: boolean }
15
+ | { type: 'ellipse'; x: number; y: number; rx: number; ry: number; rotation: number; startAngle: number; endAngle: number; ccw: boolean }
16
+ | { type: 'rect'; x: number; y: number; w: number; h: number }
17
+ | { type: 'roundRect'; x: number; y: number; w: number; h: number; radii: number | number[] };
18
+
19
+ /**
20
+ * Path2D records path operations for later replay on a CanvasRenderingContext2D.
21
+ */
22
+ export class Path2D {
23
+ /** @internal Recorded operations */
24
+ _ops: PathOp[] = [];
25
+
26
+ constructor(pathOrSvg?: Path2D | string) {
27
+ if (pathOrSvg instanceof Path2D) {
28
+ this._ops = [...pathOrSvg._ops];
29
+ }
30
+ // SVG path string parsing is not implemented (complex, rarely needed)
31
+ }
32
+
33
+ addPath(path: Path2D): void {
34
+ this._ops.push(...path._ops);
35
+ }
36
+
37
+ moveTo(x: number, y: number): void {
38
+ this._ops.push({ type: 'moveTo', x, y });
39
+ }
40
+
41
+ lineTo(x: number, y: number): void {
42
+ this._ops.push({ type: 'lineTo', x, y });
43
+ }
44
+
45
+ closePath(): void {
46
+ this._ops.push({ type: 'closePath' });
47
+ }
48
+
49
+ bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void {
50
+ this._ops.push({ type: 'bezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y });
51
+ }
52
+
53
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void {
54
+ this._ops.push({ type: 'quadraticCurveTo', cpx, cpy, x, y });
55
+ }
56
+
57
+ arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise = false): void {
58
+ this._ops.push({ type: 'arc', x, y, radius, startAngle, endAngle, ccw: counterclockwise });
59
+ }
60
+
61
+ ellipse(x: number, y: number, radiusX: number, radiusY: number, rotation: number, startAngle: number, endAngle: number, counterclockwise = false): void {
62
+ if (radiusX < 0 || radiusY < 0) throw new RangeError('The radii provided are negative');
63
+ this._ops.push({ type: 'ellipse', x, y, rx: radiusX, ry: radiusY, rotation, startAngle, endAngle, ccw: counterclockwise });
64
+ }
65
+
66
+ rect(x: number, y: number, w: number, h: number): void {
67
+ this._ops.push({ type: 'rect', x, y, w, h });
68
+ }
69
+
70
+ roundRect(x: number, y: number, w: number, h: number, radii: number | number[] = 0): void {
71
+ this._ops.push({ type: 'roundRect', x, y, w, h, radii });
72
+ }
73
+
74
+ /**
75
+ * @internal Replay all recorded path operations onto a Cairo context.
76
+ */
77
+ _replayOnCairo(ctx: import('cairo').default.Context): void {
78
+ let lastX = 0, lastY = 0;
79
+
80
+ for (const op of this._ops) {
81
+ switch (op.type) {
82
+ case 'moveTo':
83
+ ctx.moveTo(op.x, op.y);
84
+ lastX = op.x; lastY = op.y;
85
+ break;
86
+ case 'lineTo':
87
+ ctx.lineTo(op.x, op.y);
88
+ lastX = op.x; lastY = op.y;
89
+ break;
90
+ case 'closePath':
91
+ ctx.closePath();
92
+ break;
93
+ case 'bezierCurveTo':
94
+ ctx.curveTo(op.cp1x, op.cp1y, op.cp2x, op.cp2y, op.x, op.y);
95
+ lastX = op.x; lastY = op.y;
96
+ break;
97
+ case 'quadraticCurveTo': {
98
+ const { cp1x, cp1y, cp2x, cp2y } = quadraticToCubic(lastX, lastY, op.cpx, op.cpy, op.x, op.y);
99
+ ctx.curveTo(cp1x, cp1y, cp2x, cp2y, op.x, op.y);
100
+ lastX = op.x; lastY = op.y;
101
+ break;
102
+ }
103
+ case 'arc':
104
+ if (op.ccw) {
105
+ ctx.arcNegative(op.x, op.y, op.radius, op.startAngle, op.endAngle);
106
+ } else {
107
+ ctx.arc(op.x, op.y, op.radius, op.startAngle, op.endAngle);
108
+ }
109
+ break;
110
+ case 'ellipse':
111
+ ctx.save();
112
+ ctx.translate(op.x, op.y);
113
+ ctx.rotate(op.rotation);
114
+ ctx.scale(op.rx, op.ry);
115
+ if (op.ccw) {
116
+ ctx.arcNegative(0, 0, 1, op.startAngle, op.endAngle);
117
+ } else {
118
+ ctx.arc(0, 0, 1, op.startAngle, op.endAngle);
119
+ }
120
+ ctx.restore();
121
+ break;
122
+ case 'rect':
123
+ ctx.rectangle(op.x, op.y, op.w, op.h);
124
+ break;
125
+ case 'roundRect':
126
+ cairoRoundRect(ctx, op.x, op.y, op.w, op.h, op.radii);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,75 @@
1
+ // CanvasPattern implementation backed by Cairo SurfacePattern
2
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/CanvasPattern
3
+
4
+ import Cairo from 'cairo';
5
+ import Gdk from 'gi://Gdk?version=4.0';
6
+
7
+ /**
8
+ * CanvasPattern wrapping a Cairo SurfacePattern.
9
+ */
10
+ export class CanvasPattern {
11
+ private _pattern: Cairo.SurfacePattern;
12
+
13
+ private constructor(surface: Cairo.ImageSurface, repetition: string | null) {
14
+ this._pattern = new Cairo.SurfacePattern(surface);
15
+
16
+ // Set extend mode based on repetition
17
+ // setExtend exists at runtime on SurfacePattern but is missing from GIR types
18
+ const pat = this._pattern as any;
19
+ switch (repetition) {
20
+ case 'repeat':
21
+ case '':
22
+ case null:
23
+ pat.setExtend(Cairo.Extend.REPEAT);
24
+ break;
25
+ case 'repeat-x':
26
+ case 'repeat-y':
27
+ // Cairo doesn't have separate x/y repeat — use REPEAT as approximation
28
+ pat.setExtend(Cairo.Extend.REPEAT);
29
+ break;
30
+ case 'no-repeat':
31
+ pat.setExtend(Cairo.Extend.NONE);
32
+ break;
33
+ }
34
+ }
35
+
36
+ /** Create a CanvasPattern from a supported image source. Returns null if unsupported. */
37
+ static create(image: any, repetition: string | null): CanvasPattern | null {
38
+ // HTMLImageElement (GdkPixbuf-backed)
39
+ if ('isPixbuf' in image && typeof (image as any).isPixbuf === 'function' && (image as any).isPixbuf()) {
40
+ const pixbuf = (image as any)._pixbuf as import('@girs/gdkpixbuf-2.0').default.Pixbuf;
41
+ // Create a Cairo surface from the pixbuf
42
+ const w = pixbuf.get_width();
43
+ const h = pixbuf.get_height();
44
+ const surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
45
+ const ctx = new Cairo.Context(surface);
46
+ Gdk.cairo_set_source_pixbuf(ctx as any, pixbuf, 0, 0);
47
+ ctx.paint();
48
+ ctx.$dispose();
49
+ return new CanvasPattern(surface, repetition);
50
+ }
51
+
52
+ // HTMLCanvasElement with a 2D context
53
+ if (typeof image?.getContext === 'function') {
54
+ const ctx2d = image.getContext('2d');
55
+ if (ctx2d && typeof ctx2d._getSurface === 'function') {
56
+ const sourceSurface = ctx2d._getSurface() as Cairo.ImageSurface;
57
+ const w = sourceSurface.getWidth();
58
+ const h = sourceSurface.getHeight();
59
+ const surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, w, h);
60
+ const ctx = new Cairo.Context(surface);
61
+ ctx.setSourceSurface(sourceSurface, 0, 0);
62
+ ctx.paint();
63
+ ctx.$dispose();
64
+ return new CanvasPattern(surface, repetition);
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /** @internal Get the underlying Cairo pattern for rendering. */
72
+ _getCairoPattern(): Cairo.SurfacePattern {
73
+ return this._pattern;
74
+ }
75
+ }