@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/lib/esm/cairo-utils.js +172 -0
- package/lib/esm/canvas-gradient.js +23 -0
- package/lib/esm/canvas-path.js +104 -0
- package/lib/esm/canvas-pattern.js +58 -0
- package/lib/esm/canvas-rendering-context-2d.js +885 -0
- package/lib/esm/canvas-state.js +39 -0
- package/lib/esm/color.js +209 -0
- package/lib/esm/image-data.js +22 -0
- package/lib/esm/index.js +14 -0
- package/lib/types/cairo-utils.d.ts +54 -0
- package/lib/types/canvas-gradient.d.ts +11 -0
- package/lib/types/canvas-path.d.ts +80 -0
- package/lib/types/canvas-pattern.d.ts +12 -0
- package/lib/types/canvas-rendering-context-2d.d.ts +164 -0
- package/lib/types/canvas-state.d.ts +27 -0
- package/lib/types/color.d.ts +15 -0
- package/lib/types/image-data.d.ts +12 -0
- package/lib/types/index.d.ts +6 -0
- package/package.json +47 -0
- package/src/cairo-utils.ts +243 -0
- package/src/canvas-gradient.ts +36 -0
- package/src/canvas-path.ts +131 -0
- package/src/canvas-pattern.ts +75 -0
- package/src/canvas-rendering-context-2d.ts +1007 -0
- package/src/canvas-state.ts +77 -0
- package/src/canvas-text.spec.ts +241 -0
- package/src/color.ts +125 -0
- package/src/image-data.ts +34 -0
- package/src/index.ts +14 -0
- package/src/test.mts +6 -0
- package/tmp/.tsbuildinfo +1 -0
- package/tsconfig.json +47 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Drawing state for Canvas 2D context save()/restore() stack.
|
|
2
|
+
// Each save() pushes a clone of the current state; restore() pops it.
|
|
3
|
+
|
|
4
|
+
import type { RGBA } from './color.js';
|
|
5
|
+
import { BLACK } from './color.js';
|
|
6
|
+
|
|
7
|
+
export interface CanvasState {
|
|
8
|
+
// Fill & stroke
|
|
9
|
+
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
10
|
+
fillColor: RGBA;
|
|
11
|
+
strokeStyle: string | CanvasGradient | CanvasPattern;
|
|
12
|
+
strokeColor: RGBA;
|
|
13
|
+
|
|
14
|
+
// Line properties
|
|
15
|
+
lineWidth: number;
|
|
16
|
+
lineCap: CanvasLineCap;
|
|
17
|
+
lineJoin: CanvasLineJoin;
|
|
18
|
+
miterLimit: number;
|
|
19
|
+
lineDash: number[];
|
|
20
|
+
lineDashOffset: number;
|
|
21
|
+
|
|
22
|
+
// Compositing
|
|
23
|
+
globalAlpha: number;
|
|
24
|
+
globalCompositeOperation: GlobalCompositeOperation;
|
|
25
|
+
|
|
26
|
+
// Shadows (Phase 5 — tracked in state for save/restore correctness)
|
|
27
|
+
shadowColor: string;
|
|
28
|
+
shadowBlur: number;
|
|
29
|
+
shadowOffsetX: number;
|
|
30
|
+
shadowOffsetY: number;
|
|
31
|
+
|
|
32
|
+
// Text (Phase 4)
|
|
33
|
+
font: string;
|
|
34
|
+
textAlign: CanvasTextAlign;
|
|
35
|
+
textBaseline: CanvasTextBaseline;
|
|
36
|
+
direction: CanvasDirection;
|
|
37
|
+
|
|
38
|
+
// Image smoothing
|
|
39
|
+
imageSmoothingEnabled: boolean;
|
|
40
|
+
imageSmoothingQuality: ImageSmoothingQuality;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createDefaultState(): CanvasState {
|
|
44
|
+
return {
|
|
45
|
+
fillStyle: '#000000',
|
|
46
|
+
fillColor: { ...BLACK },
|
|
47
|
+
strokeStyle: '#000000',
|
|
48
|
+
strokeColor: { ...BLACK },
|
|
49
|
+
lineWidth: 1,
|
|
50
|
+
lineCap: 'butt',
|
|
51
|
+
lineJoin: 'miter',
|
|
52
|
+
miterLimit: 10,
|
|
53
|
+
lineDash: [],
|
|
54
|
+
lineDashOffset: 0,
|
|
55
|
+
globalAlpha: 1,
|
|
56
|
+
globalCompositeOperation: 'source-over',
|
|
57
|
+
shadowColor: 'rgba(0, 0, 0, 0)',
|
|
58
|
+
shadowBlur: 0,
|
|
59
|
+
shadowOffsetX: 0,
|
|
60
|
+
shadowOffsetY: 0,
|
|
61
|
+
font: '10px sans-serif',
|
|
62
|
+
textAlign: 'start',
|
|
63
|
+
textBaseline: 'alphabetic',
|
|
64
|
+
direction: 'ltr',
|
|
65
|
+
imageSmoothingEnabled: true,
|
|
66
|
+
imageSmoothingQuality: 'low',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cloneState(state: CanvasState): CanvasState {
|
|
71
|
+
return {
|
|
72
|
+
...state,
|
|
73
|
+
fillColor: { ...state.fillColor },
|
|
74
|
+
strokeColor: { ...state.strokeColor },
|
|
75
|
+
lineDash: [...state.lineDash],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Canvas 2D text rendering tests — baseline positioning and alignment
|
|
2
|
+
// Reference: refs/wpt/html/canvas/element/text/2d.text.draw.baseline.*.html
|
|
3
|
+
// Ported from W3C WPT canvas text tests (3-Clause BSD, web-platform-tests contributors)
|
|
4
|
+
// Reimplemented for GJS using PangoCairo (no DOM, no font face loading)
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from '@gjsify/unit';
|
|
7
|
+
import { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
|
|
8
|
+
|
|
9
|
+
// --- Helpers ---
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a minimal canvas mock + context for testing.
|
|
13
|
+
* CanvasRenderingContext2D only needs canvas.width / canvas.height.
|
|
14
|
+
*/
|
|
15
|
+
function makeCtx(width: number, height: number): CanvasRenderingContext2D {
|
|
16
|
+
const canvas = { width, height };
|
|
17
|
+
return new CanvasRenderingContext2D(canvas);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the first and last row (y-coordinate) that contains at least one
|
|
22
|
+
* "green" pixel (G > 128, R < 64, B < 64) in the given ImageData.
|
|
23
|
+
* Returns { first: -1, last: -1 } when no green pixel is found.
|
|
24
|
+
*/
|
|
25
|
+
function findGreenRowBounds(data: Uint8ClampedArray, width: number, height: number) {
|
|
26
|
+
let first = -1;
|
|
27
|
+
let last = -1;
|
|
28
|
+
for (let y = 0; y < height; y++) {
|
|
29
|
+
for (let x = 0; x < width; x++) {
|
|
30
|
+
const i = (y * width + x) * 4;
|
|
31
|
+
if (data[i + 1] > 128 && data[i] < 64 && data[i + 2] < 64) {
|
|
32
|
+
if (first === -1) first = y;
|
|
33
|
+
last = y;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { first, last };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Draw text on a background-filled canvas and return the bounding rows of the
|
|
43
|
+
* rendered text pixels.
|
|
44
|
+
*
|
|
45
|
+
* @param baseline CSS textBaseline value
|
|
46
|
+
* @param yCoord The y argument passed to fillText
|
|
47
|
+
* @param width Canvas width
|
|
48
|
+
* @param height Canvas height
|
|
49
|
+
*/
|
|
50
|
+
function drawAndMeasure(
|
|
51
|
+
baseline: CanvasTextBaseline,
|
|
52
|
+
yCoord: number,
|
|
53
|
+
width = 300,
|
|
54
|
+
height = 100,
|
|
55
|
+
) {
|
|
56
|
+
const ctx = makeCtx(width, height);
|
|
57
|
+
|
|
58
|
+
// Red background
|
|
59
|
+
ctx.fillStyle = '#f00';
|
|
60
|
+
ctx.fillRect(0, 0, width, height);
|
|
61
|
+
|
|
62
|
+
// Green text — use a wide string to guarantee pixels across multiple columns
|
|
63
|
+
ctx.fillStyle = '#0f0';
|
|
64
|
+
ctx.font = '24px sans-serif';
|
|
65
|
+
ctx.textBaseline = baseline;
|
|
66
|
+
ctx.textAlign = 'left';
|
|
67
|
+
ctx.fillText('XXXXXXX', 0, yCoord);
|
|
68
|
+
|
|
69
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
70
|
+
return findGreenRowBounds(imageData.data, width, height);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Tests ---
|
|
74
|
+
|
|
75
|
+
export default async () => {
|
|
76
|
+
|
|
77
|
+
await describe('CanvasRenderingContext2D.fillText — textBaseline positioning', async () => {
|
|
78
|
+
|
|
79
|
+
// textBaseline='top': text top edge is at the provided y coordinate.
|
|
80
|
+
// With y=5, the first green row must appear near y=5 (within ±8px).
|
|
81
|
+
await it("textBaseline='top': text starts near y", async () => {
|
|
82
|
+
const { first, last } = drawAndMeasure('top', 5);
|
|
83
|
+
expect(first).toBeGreaterThan(-1); // text was rendered
|
|
84
|
+
expect(first).toBeLessThan(15); // top edge close to y=5
|
|
85
|
+
expect(last).toBeGreaterThan(first); // text has height
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// textBaseline='bottom': text bottom edge is at y. With y=95 on a 100px canvas,
|
|
89
|
+
// the last green row must appear near y=95 (within ±8px).
|
|
90
|
+
await it("textBaseline='bottom': text ends near y", async () => {
|
|
91
|
+
const { first, last } = drawAndMeasure('bottom', 95, 300, 100);
|
|
92
|
+
expect(last).toBeGreaterThan(-1); // text was rendered
|
|
93
|
+
expect(last).toBeGreaterThan(85); // bottom edge close to y=95
|
|
94
|
+
expect(first).toBeLessThan(last); // text has height
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// textBaseline='middle': the vertical midpoint of the text is at y.
|
|
98
|
+
// With y=50 on a 100px canvas, text must span across the center:
|
|
99
|
+
// first green row < 50 AND last green row > 50.
|
|
100
|
+
await it("textBaseline='middle': text is centered on y", async () => {
|
|
101
|
+
const { first, last } = drawAndMeasure('middle', 50, 300, 100);
|
|
102
|
+
expect(first).toBeGreaterThan(-1);
|
|
103
|
+
expect(first).toBeLessThan(50); // text extends above center
|
|
104
|
+
expect(last).toBeGreaterThan(50); // text extends below center
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// textBaseline='alphabetic': the baseline (a reference Latin line) is at y.
|
|
108
|
+
// Ascent extends above y, descent extends below.
|
|
109
|
+
// With y=50 on a 100px canvas:
|
|
110
|
+
// - first green row < 50 (ascent above baseline)
|
|
111
|
+
// - last green row > 50 (descent below baseline — use 'g' which has a descender)
|
|
112
|
+
await it("textBaseline='alphabetic': baseline is at y, ascent above, descent below", async () => {
|
|
113
|
+
// Use text with descenders (g, p, q, y) so pixels extend below the baseline.
|
|
114
|
+
const canvas = { width: 300, height: 100 };
|
|
115
|
+
const ctx = new CanvasRenderingContext2D(canvas);
|
|
116
|
+
ctx.fillStyle = '#f00';
|
|
117
|
+
ctx.fillRect(0, 0, 300, 100);
|
|
118
|
+
ctx.fillStyle = '#0f0';
|
|
119
|
+
ctx.font = '24px sans-serif';
|
|
120
|
+
ctx.textBaseline = 'alphabetic';
|
|
121
|
+
ctx.textAlign = 'left';
|
|
122
|
+
ctx.fillText('Xgpqy', 0, 50); // descenders ensure pixels below baseline
|
|
123
|
+
const imageData = ctx.getImageData(0, 0, 300, 100);
|
|
124
|
+
const { first, last } = findGreenRowBounds(imageData.data, 300, 100);
|
|
125
|
+
expect(first).toBeGreaterThan(-1);
|
|
126
|
+
expect(first).toBeLessThan(50); // text extends above y
|
|
127
|
+
expect(last).toBeGreaterThan(50); // descent below y
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// textBaseline='hanging': the hanging baseline is at y (used for Devanagari etc.).
|
|
131
|
+
// Approximates the top of the cap height (~0.8 * ascent below the em-square top).
|
|
132
|
+
// Most of the text should extend BELOW y, with very little above.
|
|
133
|
+
await it("textBaseline='hanging': text mostly extends below y", async () => {
|
|
134
|
+
const { first, last } = drawAndMeasure('hanging', 5, 300, 100);
|
|
135
|
+
expect(first).toBeGreaterThan(-1);
|
|
136
|
+
// Text should start near y=5; because hanging ≈ 0.2*ascent from top,
|
|
137
|
+
// first green row should be within 10px of y=5.
|
|
138
|
+
expect(first).toBeLessThan(20);
|
|
139
|
+
expect(last).toBeGreaterThan(first);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// textBaseline='ideographic': ideographic baseline is at y.
|
|
143
|
+
// Below the alphabetic baseline; the bulk of text is ABOVE y.
|
|
144
|
+
await it("textBaseline='ideographic': most text is above y", async () => {
|
|
145
|
+
const { first, last } = drawAndMeasure('ideographic', 70, 300, 100);
|
|
146
|
+
expect(first).toBeGreaterThan(-1);
|
|
147
|
+
expect(first).toBeLessThan(70); // text extends above y
|
|
148
|
+
// last may be ≥ 70 (descent below ideographic line) or ≤ 70
|
|
149
|
+
expect(last).toBeGreaterThan(first);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Regression: 'top' at y=0 should NOT be far below y=0.
|
|
153
|
+
// Before fix, yOff = +ascent pushed text ~20px too low (wrong).
|
|
154
|
+
await it("textBaseline='top' regression: first pixel is near y=0, not shifted down by ascent", async () => {
|
|
155
|
+
const { first } = drawAndMeasure('top', 0, 300, 80);
|
|
156
|
+
// Before fix: first ≈ 18+ (shifted by ascent). After fix: first ≈ 0-9
|
|
157
|
+
// (exact value varies with PangoCairo version and font hinting).
|
|
158
|
+
expect(first).toBeLessThan(12);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Regression: 'alphabetic' at y=40 should put text above AND below y=40.
|
|
162
|
+
// Before fix, yOff=0 put the layout TOP at y=40, so all text was below y=40.
|
|
163
|
+
await it("textBaseline='alphabetic' regression: text extends above y, not only below", async () => {
|
|
164
|
+
const { first } = drawAndMeasure('alphabetic', 40, 300, 80);
|
|
165
|
+
expect(first).toBeLessThan(40); // ascent extends above y=40
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Regression: 'middle' at y=40 with 24px font → text spans ~28..52
|
|
169
|
+
// Before fix: yOff = ascent - height/2 ≈ +10, so layout top at ~50, text at 50..74
|
|
170
|
+
// → first ≈ 50, which is NOT less than 40.
|
|
171
|
+
await it("textBaseline='middle' regression: text straddles y, not shifted below y", async () => {
|
|
172
|
+
const { first } = drawAndMeasure('middle', 40, 300, 80);
|
|
173
|
+
expect(first).toBeLessThan(40);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await describe('CanvasRenderingContext2D.fillText — textAlign positioning', async () => {
|
|
178
|
+
|
|
179
|
+
await it("textAlign='center': text is centered on x", async () => {
|
|
180
|
+
const width = 200;
|
|
181
|
+
const height = 60;
|
|
182
|
+
const ctx = makeCtx(width, height);
|
|
183
|
+
ctx.fillStyle = '#f00';
|
|
184
|
+
ctx.fillRect(0, 0, width, height);
|
|
185
|
+
ctx.fillStyle = '#0f0';
|
|
186
|
+
ctx.font = '20px sans-serif';
|
|
187
|
+
ctx.textBaseline = 'top';
|
|
188
|
+
ctx.textAlign = 'center';
|
|
189
|
+
ctx.fillText('XX', width / 2, 0);
|
|
190
|
+
|
|
191
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
192
|
+
// Find leftmost and rightmost green column
|
|
193
|
+
let leftCol = -1, rightCol = -1;
|
|
194
|
+
for (let x = 0; x < width; x++) {
|
|
195
|
+
for (let y = 0; y < height; y++) {
|
|
196
|
+
const i = (y * width + x) * 4;
|
|
197
|
+
if (imageData.data[i + 1] > 128 && imageData.data[i] < 64 && imageData.data[i + 2] < 64) {
|
|
198
|
+
if (leftCol === -1) leftCol = x;
|
|
199
|
+
rightCol = x;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
expect(leftCol).toBeGreaterThan(-1);
|
|
205
|
+
// Text should be roughly centered: left and right distances from center should be similar
|
|
206
|
+
const distLeft = width / 2 - leftCol;
|
|
207
|
+
const distRight = rightCol - width / 2;
|
|
208
|
+
// Both sides should have roughly equal extent (within 20px)
|
|
209
|
+
expect(Math.abs(distLeft - distRight)).toBeLessThan(20);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await it("textAlign='left': text starts at x", async () => {
|
|
213
|
+
const width = 200;
|
|
214
|
+
const height = 60;
|
|
215
|
+
const ctx = makeCtx(width, height);
|
|
216
|
+
ctx.fillStyle = '#f00';
|
|
217
|
+
ctx.fillRect(0, 0, width, height);
|
|
218
|
+
ctx.fillStyle = '#0f0';
|
|
219
|
+
ctx.font = '20px sans-serif';
|
|
220
|
+
ctx.textBaseline = 'top';
|
|
221
|
+
ctx.textAlign = 'left';
|
|
222
|
+
ctx.fillText('XX', 20, 0);
|
|
223
|
+
|
|
224
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
225
|
+
let leftCol = -1;
|
|
226
|
+
outer: for (let x = 0; x < width; x++) {
|
|
227
|
+
for (let y = 0; y < height; y++) {
|
|
228
|
+
const i = (y * width + x) * 4;
|
|
229
|
+
if (imageData.data[i + 1] > 128 && imageData.data[i] < 64 && imageData.data[i + 2] < 64) {
|
|
230
|
+
leftCol = x;
|
|
231
|
+
break outer;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// leftmost green pixel should be near x=20
|
|
236
|
+
expect(leftCol).toBeGreaterThan(-1);
|
|
237
|
+
expect(leftCol).toBeGreaterThan(14);
|
|
238
|
+
expect(leftCol).toBeLessThan(30);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
};
|
package/src/color.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// CSS color parser for Canvas 2D context
|
|
2
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
|
|
3
|
+
|
|
4
|
+
export interface RGBA {
|
|
5
|
+
r: number; // 0-1
|
|
6
|
+
g: number; // 0-1
|
|
7
|
+
b: number; // 0-1
|
|
8
|
+
a: number; // 0-1
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// CSS named colors (all 148 standard colors)
|
|
12
|
+
const NAMED_COLORS: Record<string, string> = {
|
|
13
|
+
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4',
|
|
14
|
+
azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
|
15
|
+
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a',
|
|
16
|
+
burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e',
|
|
17
|
+
coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
|
|
18
|
+
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b',
|
|
19
|
+
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
20
|
+
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
21
|
+
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
22
|
+
darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
23
|
+
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
24
|
+
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
25
|
+
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
26
|
+
goldenrod: '#daa520', gray: '#808080', green: '#008000', greenyellow: '#adff2f',
|
|
27
|
+
grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c',
|
|
28
|
+
indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
|
|
29
|
+
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6',
|
|
30
|
+
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
31
|
+
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
32
|
+
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
33
|
+
lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
34
|
+
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
35
|
+
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
36
|
+
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc',
|
|
37
|
+
mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1',
|
|
38
|
+
moccasin: '#ffe4b5', navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6',
|
|
39
|
+
olive: '#808000', olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500',
|
|
40
|
+
orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee',
|
|
41
|
+
palevioletred: '#db7093', papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f',
|
|
42
|
+
pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080',
|
|
43
|
+
rebeccapurple: '#663399', red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1',
|
|
44
|
+
saddlebrown: '#8b4513', salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57',
|
|
45
|
+
seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb',
|
|
46
|
+
slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
|
|
47
|
+
springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080',
|
|
48
|
+
thistle: '#d8bfd8', tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee',
|
|
49
|
+
wheat: '#f5deb3', white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00',
|
|
50
|
+
yellowgreen: '#9acd32',
|
|
51
|
+
transparent: '#00000000',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a CSS color string into RGBA components (0-1 range).
|
|
56
|
+
* Supports: #rgb, #rrggbb, #rgba, #rrggbbaa, rgb(), rgba(), named colors, 'transparent'.
|
|
57
|
+
*/
|
|
58
|
+
export function parseColor(color: string): RGBA | null {
|
|
59
|
+
if (!color || typeof color !== 'string') return null;
|
|
60
|
+
|
|
61
|
+
const trimmed = color.trim().toLowerCase();
|
|
62
|
+
|
|
63
|
+
// Named colors
|
|
64
|
+
const named = NAMED_COLORS[trimmed];
|
|
65
|
+
if (named) return parseHex(named);
|
|
66
|
+
|
|
67
|
+
// Hex formats
|
|
68
|
+
if (trimmed.startsWith('#')) return parseHex(trimmed);
|
|
69
|
+
|
|
70
|
+
// rgb()/rgba()
|
|
71
|
+
const rgbMatch = trimmed.match(
|
|
72
|
+
/^rgba?\(\s*(\d+(?:\.\d+)?%?)\s*[,\s]\s*(\d+(?:\.\d+)?%?)\s*[,\s]\s*(\d+(?:\.\d+)?%?)\s*(?:[,/]\s*(\d+(?:\.\d+)?%?))?\s*\)$/
|
|
73
|
+
);
|
|
74
|
+
if (rgbMatch) {
|
|
75
|
+
return {
|
|
76
|
+
r: parseComponent(rgbMatch[1], 255) / 255,
|
|
77
|
+
g: parseComponent(rgbMatch[2], 255) / 255,
|
|
78
|
+
b: parseComponent(rgbMatch[3], 255) / 255,
|
|
79
|
+
a: rgbMatch[4] !== undefined ? parseComponent(rgbMatch[4], 1) : 1,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseHex(hex: string): RGBA | null {
|
|
87
|
+
const h = hex.slice(1);
|
|
88
|
+
let r: number, g: number, b: number, a = 1;
|
|
89
|
+
|
|
90
|
+
if (h.length === 3) {
|
|
91
|
+
r = parseInt(h[0] + h[0], 16) / 255;
|
|
92
|
+
g = parseInt(h[1] + h[1], 16) / 255;
|
|
93
|
+
b = parseInt(h[2] + h[2], 16) / 255;
|
|
94
|
+
} else if (h.length === 4) {
|
|
95
|
+
r = parseInt(h[0] + h[0], 16) / 255;
|
|
96
|
+
g = parseInt(h[1] + h[1], 16) / 255;
|
|
97
|
+
b = parseInt(h[2] + h[2], 16) / 255;
|
|
98
|
+
a = parseInt(h[3] + h[3], 16) / 255;
|
|
99
|
+
} else if (h.length === 6) {
|
|
100
|
+
r = parseInt(h.slice(0, 2), 16) / 255;
|
|
101
|
+
g = parseInt(h.slice(2, 4), 16) / 255;
|
|
102
|
+
b = parseInt(h.slice(4, 6), 16) / 255;
|
|
103
|
+
} else if (h.length === 8) {
|
|
104
|
+
r = parseInt(h.slice(0, 2), 16) / 255;
|
|
105
|
+
g = parseInt(h.slice(2, 4), 16) / 255;
|
|
106
|
+
b = parseInt(h.slice(4, 6), 16) / 255;
|
|
107
|
+
a = parseInt(h.slice(6, 8), 16) / 255;
|
|
108
|
+
} else {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { r, g, b, a };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseComponent(value: string, max: number): number {
|
|
116
|
+
if (value.endsWith('%')) {
|
|
117
|
+
return (parseFloat(value) / 100) * max;
|
|
118
|
+
}
|
|
119
|
+
return parseFloat(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Default color: opaque black */
|
|
123
|
+
export const BLACK: RGBA = { r: 0, g: 0, b: 0, a: 1 };
|
|
124
|
+
/** Transparent black */
|
|
125
|
+
export const TRANSPARENT: RGBA = { r: 0, g: 0, b: 0, a: 0 };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ImageData implementation for Canvas 2D context
|
|
2
|
+
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/ImageData
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ImageData represents the pixel data of a canvas area.
|
|
6
|
+
* Each pixel is 4 bytes: R, G, B, A (0-255 each).
|
|
7
|
+
*/
|
|
8
|
+
export class OurImageData {
|
|
9
|
+
readonly data: Uint8ClampedArray;
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
readonly colorSpace: PredefinedColorSpace = 'srgb';
|
|
13
|
+
|
|
14
|
+
constructor(sw: number, sh: number);
|
|
15
|
+
constructor(data: Uint8ClampedArray, sw: number, sh?: number);
|
|
16
|
+
constructor(swOrData: number | Uint8ClampedArray, sh: number, maybeHeight?: number) {
|
|
17
|
+
if (typeof swOrData === 'number') {
|
|
18
|
+
// new ImageData(width, height)
|
|
19
|
+
this.width = swOrData;
|
|
20
|
+
this.height = sh;
|
|
21
|
+
this.data = new Uint8ClampedArray(this.width * this.height * 4);
|
|
22
|
+
} else {
|
|
23
|
+
// new ImageData(data, width[, height])
|
|
24
|
+
this.data = swOrData;
|
|
25
|
+
this.width = sh;
|
|
26
|
+
this.height = maybeHeight ?? (this.data.length / (4 * this.width));
|
|
27
|
+
if (this.data.length !== this.width * this.height * 4) {
|
|
28
|
+
throw new RangeError(
|
|
29
|
+
`Source data length ${this.data.length} is not a multiple of (4 * width=${this.width})`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Cairo-backed Canvas 2D core — no @gjsify/dom-elements dependency.
|
|
2
|
+
// Extracted from @gjsify/canvas2d so @gjsify/dom-elements can depend on it
|
|
3
|
+
// without creating a circular dependency.
|
|
4
|
+
//
|
|
5
|
+
// @gjsify/dom-elements imports this package to auto-register the '2d' context
|
|
6
|
+
// factory on HTMLCanvasElement, mirroring browser behavior where
|
|
7
|
+
// canvas.getContext('2d') works without any explicit import.
|
|
8
|
+
|
|
9
|
+
export { CanvasRenderingContext2D } from './canvas-rendering-context-2d.js';
|
|
10
|
+
export { CanvasGradient } from './canvas-gradient.js';
|
|
11
|
+
export { CanvasPattern } from './canvas-pattern.js';
|
|
12
|
+
export { Path2D } from './canvas-path.js';
|
|
13
|
+
export { OurImageData as ImageData } from './image-data.js';
|
|
14
|
+
export { parseColor } from './color.js';
|